16 files changed
+851
-139
.agents/skills/smbcloud-deploy-monorepo/SKILL.md
+22
-6
@@ -180,7 +180,7 @@ smb deploy --project <name>
180
181
### Strategy: `nextjs-ssr`
182
183
-Local build, rsync three directories, then SSH delete + fresh PM2 start (prefer server `ecosystem.config.js` when present). See `smbcloud-deploy-nextjs` skill for details.
183
+Local build, rsync three directories, then SSH delete + fresh PM2 start (prefer server `ecosystem.config.cjs` when present; falls back to legacy `.js`). The `.next/standalone/` upload should use `--copy-links` so `pnpm` symlinked dependencies land as real files in the deployed tree. If `outputFileTracingRoot` points above the sub-project directory, Next may preserve the sub-project path inside standalone output, and the deploy path must place `.next/static` and `public` under that nested runtime directory. See `smbcloud-deploy-nextjs` skill for details.
184
185
Required fields: `kind`, `source`, `path`, `pm2_app`, `package_manager`, `port`
186
@@ -211,6 +211,7 @@ For `nextjs-ssr` and `vite-spa`, the CLI changes into the `source` directory bef
211
- `pnpm install` runs inside `source/`
212
- `pnpm build` runs inside `source/`
213
- rsync sources are relative to `source/`
214
+- for Next.js SSR, standalone output may still preserve the `source/` path inside `.next/standalone/` when tracing is rooted above the app directory
215
216
For `rails`, the CLI:
217
@@ -376,7 +377,7 @@ steps:
377
- name: Upload
378
run: |
379
cd frontend/splitfire-web
379
- rsync -az --delete --mkpath .next/standalone/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/
380
+ rsync -az --delete --copy-links --mkpath .next/standalone/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/
381
rsync -az --delete --mkpath .next/static/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/.next/static/
382
rsync -az --delete --mkpath public/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/public/
383
@@ -389,7 +390,9 @@ steps:
390
if pm2 describe "splitfire-web" > /dev/null 2>&1; then
391
pm2 delete "splitfire-web"
392
fi
392
- if [ -f ecosystem.config.js ]; then
393
+ if [ -f ecosystem.config.cjs ]; then
394
+ pm2 start ecosystem.config.cjs --only "splitfire-web" --env production
395
+ elif [ -f ecosystem.config.js ]; then
396
pm2 start ecosystem.config.js --only "splitfire-web" --env production
397
else
398
PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "splitfire-web" -- server.js
@@ -446,15 +449,23 @@ All CI rsync commands must include:
449
- `--delete` — removes stale files on the remote (prevents serving old assets)
450
- `-az` — archive mode with compression
451
452
+For Next.js SSR standalone uploads, also include:
453
+
454
+- `--copy-links` on `.next/standalone/` — dereferences `pnpm` symlinked dependencies so the server does not end up with broken `node_modules` links
455
+
456
### CI PM2 restart
457
458
Current CI and CLI parity should use delete + fresh start, not restart-in-place:
459
460
- `pm2 delete <app>` if it exists
454
-- `pm2 start ecosystem.config.js --only <app> --env production` when the server file exists
461
+- `pm2 start ecosystem.config.cjs --only <app> --env production` when the `.cjs` file exists (preferred)
462
+- fall back to `pm2 start ecosystem.config.js --only <app> --env production` if only the legacy `.js` file exists
463
- otherwise fall back to `PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <app> -- server.js`
464
- `pm2 save`
465
466
+
467
+When a monorepo root `package.json` has `"type": "module"`, PM2 cannot load a `.js` ecosystem config because Node treats it as ESM. The CLI auto-migrates `.js` → `.cjs` on the server during deploy and checks `.cjs` first when starting PM2.
468
+
469
This matches the standalone Next.js deploy model and avoids stale PM2 command state from older deploy methods.
470
471
## Deployment tracking
@@ -527,12 +538,16 @@ Two `nextjs-ssr` apps have the same `port` value. PM2 will start both, but only
538
539
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`.
540
541
+### `Cannot find module 'next'` after Next.js SSR deploy
542
+
543
+The remote app directory still contains broken `pnpm` symlinks from `.next/standalone/`, or the standalone output itself is missing traced runtime files. Use the current CLI or CI flow with `rsync --copy-links` for the standalone upload, verify `node .next/standalone/server.js` works locally, then redeploy and inspect `/home/git/apps/web/<app>/node_modules/next` on the server.
544
+
545
### PM2 not picking up new environment variables
546
547
Current CLI parity is delete + fresh start. Check these in order:
548
534
-1. the server's `ecosystem.config.js` was edited, not just the repo copy
535
-2. the deploy script is actually starting from `ecosystem.config.js`
549
+1. the server's `ecosystem.config.cjs` (or legacy `.js`) was edited, not just the repo copy
550
+2. the deploy script is actually starting from `ecosystem.config.cjs` — if only a `.js` file exists and `"type": "module"` is set in `package.json`, PM2 will fail; rename to `.cjs`
551
3. `pm2 save` ran after the fresh start
552
553
If you are changing env manually outside the deploy flow, `pm2 restart <app> --update-env && pm2 save` is still a valid operator command.
@@ -567,6 +582,7 @@ If you are changing env manually outside the deploy flow, `pm2 restart <app> --u
582
- using `kind = "nextjs-ssr"` on a project without `output: "standalone"` in its Next.js config
583
- running `smb deploy` from a subdirectory instead of the monorepo root — `source` paths will not resolve
584
- forgetting `--mkpath` on rsync in CI workflows — first deploys fail because the remote directory does not exist
585
+- forgetting `--copy-links` on `.next/standalone/` uploads — the server receives broken `pnpm` symlinks and PM2 crashes with `Cannot find module 'next'`
586
- keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
587
- mixing up `path` (remote destination) and `source` (local directory) — they serve different purposes
588
- treating the root workspace id as the deployable app id
.agents/skills/smbcloud-deploy-nextjs/SKILL.md
+89
-27
@@ -36,10 +36,12 @@ The CLI route in `process_deploy.rs` short-circuits on:
36
That path builds locally, then:
37
38
- rsyncs `.next/standalone/` into the remote app directory root
39
-- rsyncs `.next/static/` into `remote/.next/static/`
40
-- rsyncs `public/` into `remote/public/`
39
+- rsyncs `.next/static/` into the runtime directory's `.next/static/`
40
+- rsyncs `public/` into the runtime directory's `public/`
41
- runs `ssh` and restarts the app with PM2
42
43
+When `outputFileTracingRoot` points above the app directory, Next may preserve the source path inside `.next/standalone/` (for example `.next/standalone/web/server.js`). The deploy path must keep that nested runtime layout intact.
44
+
45
Do not describe this flow as git based unless the project is actually using the generic git deploy path.
46
47
## Required app configuration
@@ -97,18 +99,26 @@ Important rules:
99
100
## Expected remote layout
101
100
-After a successful deploy, the remote directory should look like:
102
+After a successful deploy, the remote directory should look like one of these layouts:
103
102
-- `/home/git/apps/web/<app>/server.js`
103
-- `/home/git/apps/web/<app>/.next/static/...`
104
-- `/home/git/apps/web/<app>/public/...`
104
+- flat runtime
105
+ - `/home/git/apps/web/<app>/server.js`
106
+ - `/home/git/apps/web/<app>/.next/static/...`
107
+ - `/home/git/apps/web/<app>/public/...`
108
+- nested runtime when standalone preserves the source path
109
+ - `/home/git/apps/web/<app>/node_modules/...`
110
+ - `/home/git/apps/web/<app>/web/server.js`
111
+ - `/home/git/apps/web/<app>/web/.next/static/...`
112
+ - `/home/git/apps/web/<app>/web/public/...`
113
106
-Note that `server.js` is expected at the remote root because the CLI uploads the contents of `.next/standalone/`, not the folder itself.
114
+The CLI uploads the contents of `.next/standalone/`, not the folder itself. In nested-runtime mode it also needs to keep PM2 compatibility for old root-level `server.js` and `.next/standalone/server.js` entrypoints.
115
116
## PM2 behavior
117
118
The CLI's deploy path now deletes and starts fresh, preferring the server's
111
-`ecosystem.config.js` when it exists.
119
+ecosystem config file when it exists. The CLI checks for `ecosystem.config.cjs`
120
+first, then falls back to `ecosystem.config.js`. During deploy, the server-side
121
+migration auto-renames `.js` to `.cjs` when applicable.
122
123
Conceptually it behaves like:
124
@@ -120,7 +130,9 @@ if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
130
pm2 delete "$PM2_APP"
131
fi
132
123
-if [ -f ecosystem.config.js ]; then
133
+if [ -f ecosystem.config.cjs ]; then
134
+ pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
135
+elif [ -f ecosystem.config.js ]; then
136
pm2 start ecosystem.config.js --only "$PM2_APP" --env production
137
else
138
NODE_ENV=production PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
@@ -133,11 +145,13 @@ This has two consequences:
145
146
- old git-based `post-receive` hooks are irrelevant for the SSR path
147
- the configured `port` in `.smb/config.toml` is used for the fallback start path
136
-- the live server `ecosystem.config.js` is the runtime source of truth when present
148
+- the live server `ecosystem.config.cjs` (or `.js`) is the runtime source of truth when present
149
+
150
+### Environment variables — ecosystem file (standard pattern)
151
138
-### Environment variables â ecosystem file (standard pattern)
152
+The server runs in a **multi-tenant setup**: multiple Next.js apps share one server, each managed as a separate PM2 process. The standard way to manage per-app environment variables is an `ecosystem.config.cjs` file kept **on the server only** (never committed to the app repo).
153
140
-The server runs in a **multi-tenant setup**: multiple Next.js apps share one server, each managed as a separate PM2 process. The standard way to manage per-app environment variables is an `ecosystem.config.js` file kept **on the server only** (never committed to the app repo).
154
+Use the `.cjs` extension. This is required when the project's `package.json` has `"type": "module"`, because Node treats `.js` files as ESM in that case and `module.exports` syntax will fail.
155
156
The flow works like this:
157
@@ -147,7 +161,7 @@ The flow works like this:
161
ssh git@<server>
162
cd /home/git/apps/web/<app>
163
150
- cat > ecosystem.config.js << 'EOF'
164
+ cat > ecosystem.config.cjs << 'EOF'
165
module.exports = {
166
apps: [{
167
name: "<pm2_app>",
@@ -163,20 +177,20 @@ The flow works like this:
177
}
178
EOF
179
166
- pm2 start ecosystem.config.js --env production
180
+ pm2 start ecosystem.config.cjs --env production
181
pm2 save
182
```
183
170
-2. Every subsequent CLI deploy deletes the PM2 process and starts it fresh. If the server has `ecosystem.config.js`, the CLI starts PM2 from that file again. If not, it falls back to `node server.js` with inline `NODE_ENV`, `PORT`, and `HOSTNAME`.
184
+2. Every subsequent CLI deploy deletes the PM2 process and starts it fresh. The CLI checks for `ecosystem.config.cjs` first, then `ecosystem.config.js`. If neither exists, it falls back to `node server.js` with inline `NODE_ENV`, `PORT`, and `HOSTNAME`.
185
172
-3. To change or add env vars, SSH in, edit `ecosystem.config.js`, then:
186
+3. To change or add env vars, SSH in, edit `ecosystem.config.cjs`, then:
187
188
```sh
189
pm2 restart <pm2_app> --update-env
190
pm2 save
191
```
192
179
-The ecosystem file is the server-side source of truth for each app's runtime config. The CLI preserves the server copy during rsync and must not delete it.
193
+The ecosystem file is the server-side source of truth for each app's runtime config. The CLI preserves both `ecosystem.config.cjs` and `ecosystem.config.js` on the server during rsync and must not delete either.
194
195
### Manual PM2 operations (without ecosystem file)
196
@@ -203,7 +217,7 @@ pm2 save
217
218
Use one source of truth for the runtime port.
219
206
-If an older `ecosystem.config.js` exists and contains both:
220
+If an older ecosystem config (`.cjs` or `.js`) exists and contains both:
221
222
- `args: "start -p 3026"`
223
- `env_production.PORT = 3025`
@@ -213,7 +227,7 @@ that is a broken configuration. Standardize on one port value.
227
Current rules:
228
229
- fallback fresh-start path uses `port` from `.smb/config.toml`
216
-- ecosystem-managed apps should set the same port in `env_production.PORT`
230
+- ecosystem-managed apps should set the same port in `env_production.PORT` (in `ecosystem.config.cjs`)
231
- nginx upstream must match that port
232
233
## Nginx for SSR
@@ -273,24 +287,67 @@ Check:
287
288
### Missing `public/`
289
276
-The current deploy code tries to rsync `public/` unconditionally.
290
+`public/` is optional. The current deploy code skips it when the directory does not exist.
291
+
292
+If assets are missing in production, check whether the app actually has a `public/` directory locally and whether the expected files were copied into the remote `public/` directory.
293
+
294
+### `Cannot find module 'next'` or `502 Bad Gateway`
295
+
296
+Cause:
297
+
298
+- `.next/standalone/node_modules/*` contains `pnpm` symlinks
299
+- rsync preserved the symlinks instead of copying their targets
300
+- or the standalone output itself is incomplete and missing traced runtime files
301
+- PM2 starts `server.js`, Node cannot resolve `next`, and nginx returns `502 Bad Gateway`
302
+
303
+Fix:
304
+
305
+- use the dedicated `nextjs-ssr` CLI path in current `smbcloud-cli`
306
+- make sure the standalone upload uses `rsync --copy-links`
307
+- verify `node .next/standalone/server.js` works locally before deploy
308
+- if local standalone fails too, add `outputFileTracingIncludes` for the missing runtime files and rebuild
309
+- then restart PM2 and recheck `pm2 logs <pm2_app>`
310
+
311
+### `Cannot find module 'styled-jsx'`, `@swc/helpers`, or other pnpm peer deps
312
278
-If the project has no `public/` directory, deploy may fail. Either:
313
+Cause:
314
+
315
+- pnpm stores peer dependencies inside `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>` with internal symlinks that Node's standard `require.resolve()` cannot follow
316
+- Next.js standalone traces the files into `.pnpm/` correctly, but does not create the flat `node_modules/<pkg>` entries needed for resolution
317
+- this affects `styled-jsx`, `@swc/helpers`, `@next/env`, and other packages that `next` requires at startup
318
+
319
+Fix:
320
280
-- create an empty `public/`
281
-- or patch the CLI to skip missing directories
321
+- the CLI deploy script now auto-hoists: after uploading standalone output, it scans `node_modules/.pnpm/*/node_modules/*` and creates symlinks at `node_modules/<pkg>` for any packages not already present
322
+- this restores the resolution structure that pnpm normally provides via its `.pnpm/node_modules/` virtual directory
323
+- no manual intervention needed for new deploys
324
+- to fix a server manually: `find node_modules/.pnpm -mindepth 2 -maxdepth 2 -type d -name node_modules` and symlink each child into the root `node_modules/`
325
326
### Wrong port after deploy
327
328
Cause:
329
287
-- `port` in `.smb/config.toml`, server `ecosystem.config.js`, and nginx upstream disagree
330
+- `port` in `.smb/config.toml`, server ecosystem config (`.cjs` or `.js`), and nginx upstream disagree
331
332
Fix:
333
334
- standardize the same port in all three places
335
- if using ecosystem config, make sure PM2 is actually starting from that file
336
337
+### PM2 "module is not defined" or "require is not defined"
338
+
339
+Cause:
340
+
341
+- the project's `package.json` has `"type": "module"`, which makes Node treat `.js` files as ESM
342
+- `ecosystem.config.js` uses `module.exports` (CommonJS), which is invalid under ESM
343
+- deploy shims that use `require()` will also fail under ESM
344
+
345
+Fix:
346
+
347
+- rename `ecosystem.config.js` to `ecosystem.config.cjs` on the server
348
+- deploy shims must use `import()` not `require()`
349
+- the CLI now handles both automatically: it checks for `.cjs` first and the server-side migration auto-renames `.js` to `.cjs` during deploy
350
+
351
### Misleading deploy output
352
353
The CLI may print generic deploy messages such as:
@@ -336,7 +393,9 @@ Use the smallest checks that prove the contract.
393
### Next.js app
394
395
- `pnpm build`
339
-- verify `.next/standalone/server.js` exists
396
+- verify standalone contains a runnable server entrypoint
397
+- if `.next/standalone/server.js` exists, verify `node .next/standalone/server.js` starts locally
398
+- if standalone preserves the source path, verify `node .next/standalone/<source>/server.js` starts locally
399
400
### smbcloud-cli
401
@@ -348,6 +407,7 @@ Use the smallest checks that prove the contract.
407
- `pm2 list`
408
- `pm2 logs <pm2_app>`
409
- `ls -la /home/git/apps/web/<app>`
410
+- `ls -ld /home/git/apps/web/<app>/node_modules/next`
411
- `sudo nginx -t`
412
413
## Common mistakes
@@ -356,8 +416,10 @@ Use the smallest checks that prove the contract.
416
- forgetting `kind = "nextjs-ssr"`
417
- keeping an old git `post-receive` hook and expecting it to manage SSR deploys
418
- using static-file Nginx config for an SSR app
359
-- editing the repo copy of `ecosystem.config.js` and expecting a normal CLI deploy to overwrite the server copy — it is preserved intentionally
360
-- editing the server `ecosystem.config.js` but the deploy script falling back to inline `node server.js` because the file path check is wrong
419
+- rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server
420
+- editing the repo copy of `ecosystem.config.cjs` and expecting a normal CLI deploy to overwrite the server copy — it is preserved intentionally
421
+- editing the server `ecosystem.config.cjs` but the deploy script falling back to inline `node server.js` because the file path check is wrong
422
+- keeping an `ecosystem.config.js` when the project uses ESM (`"type": "module"` in `package.json`) — rename it to `.cjs` or let the CLI migration handle it
423
- leaving port values inconsistent across PM2, Nginx, and `.smb/config.toml`
424
- forgetting to set `frontend_app_id` on the deploy target once app-level deployment tracking is available
425
- treating browser CORS failures as real internet outages
crates/cli/src/cli/mod.rs
+5
@@ -70,4 +70,9 @@ pub enum Commands {
70
#[clap(subcommand)]
71
command: mail::cli::Commands,
72
},
73
+ #[clap(
74
+ about = "Migrate local .smb/config.toml deploy fields to the smbCloud server.",
75
+ display_order = 4
76
+ )]
77
+ Migrate {},
78
}
crates/cli/src/deploy/config.rs
+64
-78
@@ -57,7 +57,7 @@ pub(crate) async fn get_config(
57
message: ErrorCode::MissingConfig.message(None).to_string(),
58
})?;
59
60
- let mut config: Config = match toml::from_str(&config_content) {
60
+ let config: Config = match toml::from_str(&config_content) {
61
Ok(value) => value,
62
Err(e) => {
63
println!("Error parsing config: {}", e);
@@ -75,83 +75,6 @@ pub(crate) async fn get_config(
75
),
76
);
77
78
- // Attempt to fetch server-side deploy config when we have both a token
79
- // and a frontend_app_id. Falls back to local config silently on failure.
80
- if let Some(token) = access_token {
81
- if let Some(ref frontend_app_id) = config.project.frontend_app_id {
82
- match get_deploy_config(env, client(), token.to_string(), frontend_app_id).await {
83
- Ok(deploy_config) => {
84
- let has_server_fields =
85
- deploy_config.kind.is_some() || deploy_config.remote_path.is_some();
86
-
87
- if has_server_fields {
88
- println!(
89
- " {} {}",
90
- console::style(" ").dim(),
91
- console::style("Loaded from smbcloud.xyz").dim(),
92
- );
93
-
94
- if let Some(remote_kind) = deploy_config.kind {
95
- config.project.kind = Some(remote_kind);
96
- }
97
- if let Some(remote_path) = deploy_config.remote_path {
98
- config.project.path = Some(remote_path);
99
- }
100
- if let Some(remote_output_path) = deploy_config.output_path {
101
- config.project.output = Some(remote_output_path);
102
- }
103
- if let Some(remote_build_command) = deploy_config.build_command {
104
- config.project.compile_cmd = Some(remote_build_command);
105
- }
106
- if let Some(remote_install_command) = deploy_config.install_command {
107
- config.project.install_command = Some(remote_install_command);
108
- }
109
- if let Some(remote_binary_name) = deploy_config.binary_name {
110
- config.project.binary_name = Some(remote_binary_name);
111
- }
112
- if let Some(remote_build_target) = deploy_config.build_target {
113
- config.project.rust_target = Some(remote_build_target);
114
- }
115
- if let Some(remote_package_manager) = deploy_config.package_manager {
116
- config.project.package_manager = Some(remote_package_manager);
117
- }
118
- if let Some(remote_pm2_app) = deploy_config.pm2_app {
119
- config.project.pm2_app = Some(remote_pm2_app);
120
- }
121
- if let Some(remote_port) = deploy_config.port {
122
- config.project.port = Some(remote_port);
123
- }
124
- if let Some(remote_shared_lib_path) = deploy_config.shared_lib_path {
125
- config.project.shared_lib = Some(remote_shared_lib_path);
126
- }
127
- if let Some(remote_source_path) = deploy_config.source_path {
128
- config.project.source_path = Some(remote_source_path);
129
- }
130
- if let Some(remote_repository) = deploy_config.repository {
131
- config.project.repository = Some(remote_repository);
132
- }
133
- config.project.runner = deploy_config.runner;
134
- config.project.deployment_method = deploy_config.deployment_method;
135
- config.project.deploy_repo_id = deploy_config.deploy_repo_id;
136
- } else {
137
- println!(
138
- " {} {}",
139
- console::style(" ").dim(),
140
- console::style("Loaded from .smb/config.toml").dim(),
141
- );
142
- }
143
- }
144
- Err(_) => {
145
- println!(
146
- " {} {}",
147
- console::style(" ").dim(),
148
- console::style("Loaded from .smb/config.toml").dim(),
149
- );
150
- }
151
- }
152
- }
153
- }
154
-
78
Ok(config)
79
}
80
@@ -159,6 +82,69 @@ fn handle_config_error() -> Result<Config, ErrorResponse> {
82
todo!()
83
}
84
85
+pub(crate) async fn overlay_server_config(
86
+ env: Environment,
87
+ access_token: &str,
88
+ config: &mut Config,
89
+) -> bool {
90
+ let frontend_app_id = match config.project.frontend_app_id {
91
+ Some(ref id) => id.clone(),
92
+ None => return false,
93
+ };
94
+
95
+ match get_deploy_config(env, client(), access_token.to_string(), &frontend_app_id).await {
96
+ Ok(deploy_config) => {
97
+ if let Some(remote_kind) = deploy_config.kind {
98
+ config.project.kind = Some(remote_kind);
99
+ }
100
+ if let Some(remote_path) = deploy_config.remote_path {
101
+ config.project.path = Some(remote_path);
102
+ }
103
+ if let Some(remote_output_path) = deploy_config.output_path {
104
+ config.project.output = Some(remote_output_path);
105
+ }
106
+ if let Some(remote_build_command) = deploy_config.build_command {
107
+ config.project.compile_cmd = Some(remote_build_command);
108
+ }
109
+ if let Some(remote_install_command) = deploy_config.install_command {
110
+ config.project.install_command = Some(remote_install_command);
111
+ }
112
+ if let Some(remote_binary_name) = deploy_config.binary_name {
113
+ config.project.binary_name = Some(remote_binary_name);
114
+ }
115
+ if let Some(remote_build_target) = deploy_config.build_target {
116
+ config.project.rust_target = Some(remote_build_target);
117
+ }
118
+ if let Some(remote_package_manager) = deploy_config.package_manager {
119
+ config.project.package_manager = Some(remote_package_manager);
120
+ }
121
+ if let Some(remote_pm2_app) = deploy_config.pm2_app {
122
+ config.project.pm2_app = Some(remote_pm2_app);
123
+ }
124
+ if let Some(remote_pm2_env) = deploy_config.pm2_env {
125
+ config.project.pm2_env = Some(remote_pm2_env);
126
+ }
127
+ if let Some(remote_port) = deploy_config.port {
128
+ config.project.port = Some(remote_port);
129
+ }
130
+ if let Some(remote_shared_lib_path) = deploy_config.shared_lib_path {
131
+ config.project.shared_lib = Some(remote_shared_lib_path);
132
+ }
133
+ if let Some(remote_source_path) = deploy_config.source_path {
134
+ config.project.source_path = Some(remote_source_path);
135
+ }
136
+ if let Some(remote_repository) = deploy_config.repository {
137
+ config.project.repository = Some(remote_repository);
138
+ }
139
+ config.project.runner = deploy_config.runner;
140
+ config.project.deployment_method = deploy_config.deployment_method;
141
+ config.project.deploy_repo_id = deploy_config.deploy_repo_id;
142
+ true
143
+ }
144
+ Err(_) => false,
145
+ }
146
+}
147
+
148
pub(crate) async fn check_project(
149
env: Environment,
150
access_token: &str,
crates/cli/src/deploy/mod.rs
+1
@@ -7,6 +7,7 @@ pub mod process_deploy_nextjs_ssr;
7
pub mod process_deploy_rails;
8
pub mod process_deploy_rust;
9
pub mod process_deploy_vite_spa;
10
+pub mod process_migrate;
11
mod remote_messages;
12
pub(crate) mod rsync_deploy;
13
pub(crate) mod setup_create_new_project;
crates/cli/src/deploy/process_deploy.rs
+3
-1
@@ -4,7 +4,7 @@ use {
4
cli::CommandResult,
5
client,
6
deploy::{
7
- config::{check_project, credentials, get_config},
7
+ config::{check_project, credentials, get_config, overlay_server_config},
8
detect_runner::detect_runner,
9
git::remote_deployment_setup,
10
process_deploy_nextjs_ssr::process_deploy_nextjs_ssr,
@@ -116,6 +116,8 @@ pub async fn process_deploy(
116
config = resolve_sub_project(config, name)?;
117
}
118
119
+ overlay_server_config(env, &access_token, &mut config).await;
120
+
121
// Validate that the logged-in user has access to this project before doing
122
// any work — applies to every deployment path including vite-spa.
123
check_project(env, &access_token, config.project.id).await?;
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+186
-26
@@ -27,7 +27,7 @@ use {
27
/// Requires `output: 'standalone'` in next.config.js. This produces a
28
/// self-contained `.next/standalone/` directory that includes only the
29
/// production Node.js dependencies needed to run the server — no
30
-/// `node_modules` transfer required.
30
+/// project-level `node_modules` upload required.
31
///
32
/// Steps:
33
/// 1. `pnpm install --ignore-scripts`
@@ -36,7 +36,7 @@ use {
36
/// 4. rsync .next/standalone/ → server:path/ (server + bundled deps)
37
/// 5. rsync .next/static/ → server:path/.next/static/ (static chunks)
38
/// 6. rsync public/ → server:path/public/ (public assets)
39
-/// 7. SSH: pm2 delete + start fresh (prefer server `ecosystem.config.js` if present)
39
+/// 7. SSH: pm2 delete + start fresh (prefer server `ecosystem.config.cjs` or `.js` if present)
40
/// 8. PATCH deployment record as Done
41
pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Result<CommandResult> {
42
let source = config.project.source.as_deref().unwrap_or(".");
@@ -146,12 +146,20 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
146
// succeeds but .next/standalone/ is never created.
147
148
let standalone_dir = format!("{}/.next/standalone", source);
149
- if !std::path::Path::new(&standalone_dir).exists() {
149
+ let standalone_path = std::path::Path::new(&standalone_dir);
150
+ if !standalone_path.exists() {
151
return Err(anyhow!(fail_message(
152
".next/standalone not found. Add `output: 'standalone'` to next.config.js and rebuild."
153
)));
154
}
155
156
+ let runtime_subdir = if source != "." && standalone_path.join(source).join("server.js").exists()
157
+ {
158
+ Some(source.trim_end_matches('/').to_owned())
159
+ } else {
160
+ None
161
+ };
162
+
163
// ── Step 4: record deployment as Started ─────────────────────────────────
164
165
let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
@@ -174,7 +182,7 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
182
//
183
// Standalone mode produces everything needed to run the server:
184
//
177
- // .next/standalone/ — server.js + bundled production deps (no node_modules needed)
185
+ // .next/standalone/ — server.js + bundled production deps (no project node_modules upload)
186
// .next/static/ — client-side chunks (must be copied into standalone manually)
187
// public/ — static assets served directly by Next.js
188
//
@@ -217,34 +225,49 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
225
226
struct Transfer {
227
local_rel: &'static str,
220
- remote_rel: &'static str,
228
+ remote_rel: String,
229
protect_runtime_files: bool,
230
+ copy_links: bool,
231
}
232
233
+ let runtime_prefix = runtime_subdir
234
+ .as_ref()
235
+ .map(|path| format!("{}/", path))
236
+ .unwrap_or_default();
237
+
238
// (local_source, remote_destination)
239
// .next/standalone contents go to the root of remote_path.
226
- // .next/static and public go into their correct subdirectories within it.
227
- let transfers: &[Transfer] = &[
228
- // standalone contents → remote root (server.js lives here)
240
+ // .next/static and public go into the runtime directory that contains
241
+ // server.js. When outputFileTracingRoot points above the app directory,
242
+ // Next preserves the source path inside `.next/standalone/`.
243
+ let transfers = vec![
244
+ // standalone contents → remote root
245
Transfer {
246
local_rel: ".next/standalone/",
231
- remote_rel: "",
232
- // The server copy of ecosystem.config.js is operator-managed runtime
233
- // config and must survive deploys. Without this protection, rsync
234
- // `--delete` removes it because it does not exist in .next/standalone/.
247
+ remote_rel: String::new(),
248
+ // pnpm leaves symlinked package entries in standalone output. The
249
+ // server only receives this tree, so those symlinks must be
250
+ // dereferenced during upload.
251
+ copy_links: true,
252
+ // The server copy of ecosystem.config.cjs (or .js) is operator-managed
253
+ // runtime config and must survive deploys. Without this protection,
254
+ // rsync `--delete` removes it because it does not exist in
255
+ // .next/standalone/.
256
protect_runtime_files: true,
257
},
237
- // static chunks → .next/static/ inside the standalone tree
258
+ // static chunks → runtime/.next/static/
259
Transfer {
260
local_rel: ".next/static/",
240
- remote_rel: ".next/static/",
261
+ remote_rel: format!("{}.next/static/", runtime_prefix),
262
protect_runtime_files: false,
263
+ copy_links: false,
264
},
243
- // public assets → public/ inside the standalone tree
265
+ // public assets → runtime/public/
266
Transfer {
267
local_rel: "public/",
246
- remote_rel: "public/",
268
+ remote_rel: format!("{}public/", runtime_prefix),
269
protect_runtime_files: false,
270
+ copy_links: false,
271
},
272
];
273
@@ -257,18 +280,23 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
280
let local_path = format!("{}/{}", source, transfer.local_rel);
281
let destination = format!("{}{}", remote_base, transfer.remote_rel);
282
260
- // Skip optional directories that the project does not have.
283
if !std::path::Path::new(&local_path).exists() {
284
continue;
285
}
286
287
let mut rsync_args = vec!["-az".to_string(), "--delete".to_string()];
288
289
+ if transfer.copy_links {
290
+ rsync_args.push("--copy-links".to_string());
291
+ }
292
+
293
if transfer.protect_runtime_files {
294
rsync_args.extend([
295
"--exclude".to_string(),
296
"ecosystem.config.js".to_string(),
297
"--exclude".to_string(),
298
+ "ecosystem.config.cjs".to_string(),
299
+ "--exclude".to_string(),
300
"logs/".to_string(),
301
]);
302
}
@@ -320,19 +348,66 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
348
// working directory now contains standalone output instead of the full
349
// Next.js build tree.
350
//
323
- // If the server has an operator-managed `ecosystem.config.js`, prefer it as
324
- // the source of truth for runtime env and pm2 settings. Otherwise fall back
351
+ // If the server has an operator-managed ecosystem config (.cjs or .js),
352
+ // prefer it as the source of truth for runtime env and pm2 settings. Otherwise fall back
353
// to `node server.js` with the minimal inline env needed to bind the app.
354
//
355
// The port defaults to 3000 and can be overridden with `port = XXXX` in
356
// .smb/config.toml — it must match the nginx upstream configuration.
357
358
let port = config.project.port.unwrap_or(3000);
359
+ let runtime_subdir_for_shell = runtime_subdir.clone().unwrap_or_default();
360
+
361
+ // Build the ecosystem.config.cjs content from server-side pm2_env, if available.
362
+ // Injected into the deploy script and written only when no config file exists yet.
363
+ let ecosystem_config_content = {
364
+ let mut env_entries = format!(
365
+ r#" NODE_ENV: "production",
366
+ PORT: {port},
367
+ HOSTNAME: "127.0.0.1",
368
+"#
369
+ );
370
+ if let Some(pm2_env) = &config.project.pm2_env {
371
+ for (key, value) in pm2_env {
372
+ // Skip keys already emitted above
373
+ if key == "NODE_ENV" || key == "PORT" || key == "HOSTNAME" {
374
+ continue;
375
+ }
376
+ let val_str = match value {
377
+ serde_json::Value::String(s) => {
378
+ let escaped = s
379
+ .replace('\\', "\\\\")
380
+ .replace('"', "\\\"")
381
+ .replace('\n', "\\n")
382
+ .replace('\r', "\\r");
383
+ format!(r#""{escaped}""#)
384
+ }
385
+ other => other.to_string(),
386
+ };
387
+ env_entries.push_str(&format!(" {key}: {val_str},\n"));
388
+ }
389
+ }
390
+ format!(
391
+ r#"module.exports = {{
392
+ apps: [
393
+ {{
394
+ name: "{pm2_app}",
395
+ script: "server.js",
396
+ cwd: "", // filled at runtime via $APP_PATH
397
+ env_production: {{
398
+{env_entries} }},
399
+ }},
400
+ ],
401
+}};
402
+"#
403
+ )
404
+ };
405
406
let deploy_script = format!(
407
r#"set -e
408
APP_PATH="{remote_path}"
409
PM2_APP="{pm2_app}"
410
+ RUNTIME_SUBDIR="{runtime_subdir}"
411
412
case "$APP_PATH" in
413
/*) ;;
@@ -347,20 +422,103 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
422
cd "$APP_PATH"
423
mkdir -p logs
424
350
- # Backward compatibility: older operator-managed ecosystem.config.js files
351
- # still point PM2 at `.next/standalone/server.js`. The current deploy flow
352
- # rsyncs the *contents* of `.next/standalone/` into the app root, so the
353
- # real entrypoint is `server.js`. Create a compatibility symlink so both
354
- # layouts work and old PM2 configs do not crash after deploy.
425
+ # Migrate legacy ecosystem.config.js to .cjs so it works when
426
+ # package.json has "type": "module".
427
+ if [ -f ecosystem.config.js ] && [ ! -f ecosystem.config.cjs ]; then
428
+ mv ecosystem.config.js ecosystem.config.cjs
429
+ fi
430
+
431
+ # Write ecosystem.config.cjs from smbCloud server config if none exists yet.
432
+ if [ ! -f ecosystem.config.cjs ] && [ ! -f ecosystem.config.js ]; then
433
+ cat > ecosystem.config.cjs << 'ECOSYSTEM_EOF'
434
+{ecosystem_config}
435
+ECOSYSTEM_EOF
436
+ # Patch the cwd field to the actual runtime path
437
+ node --input-type=commonjs -e "
438
+ var fs = require('fs');
439
+ var src = fs.readFileSync('ecosystem.config.cjs', 'utf8');
440
+ src = src.replace('cwd: \"\"', 'cwd: \"' + process.argv[1] + '\"');
441
+ fs.writeFileSync('ecosystem.config.cjs', src);
442
+ " "$APP_PATH" 2>/dev/null || true
443
+ fi
444
+
445
+ # The monorepo root package.json may declare "type": "module", which
446
+ # Next.js copies into .next/standalone/. Standalone server.js and our
447
+ # shims use require() (CJS). Strip the field so Node treats .js as CJS.
448
+ if [ -f package.json ] && grep -q '"type"' package.json; then
449
+ node --input-type=commonjs -e "
450
+ var fs = require('fs');
451
+ var p = JSON.parse(fs.readFileSync('package.json','utf8'));
452
+ delete p.type;
453
+ fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');
454
+ " 2>/dev/null || sed -i '"'"'"type".*"module"'"'d' package.json
455
+ fi
456
+
457
+ # pnpm standalone output buries peer deps inside node_modules/.pnpm/
458
+ # without the top-level symlinks Node needs for require.resolve().
459
+ # Scan the .pnpm store and create flat symlinks. For scoped packages,
460
+ # always force-replace partial rsync copies with symlinks to the .pnpm
461
+ # store entry that has the most content (not just package.json).
462
+ if [ -d "node_modules/.pnpm" ]; then
463
+ find node_modules/.pnpm -mindepth 2 -maxdepth 2 -type d -name "node_modules" 2>/dev/null | while read pnpm_nm; do
464
+ for pkg_path in "$pnpm_nm"/*; do
465
+ [ -e "$pkg_path" ] || continue
466
+ pkg_name=$(basename "$pkg_path")
467
+ if [ "${{pkg_name:0:1}}" = "@" ] && [ -d "$pkg_path" ]; then
468
+ mkdir -p "node_modules/$pkg_name"
469
+ for sub_path in "$pkg_path"/*; do
470
+ [ -e "$sub_path" ] || continue
471
+ sub_name=$(basename "$sub_path")
472
+ # Count files in this .pnpm store entry.
473
+ entry_files=$(find "$sub_path" -maxdepth 2 -type f 2>/dev/null | wc -l | tr -d ' ')
474
+ existing="node_modules/$pkg_name/$sub_name"
475
+ if [ -e "$existing" ]; then
476
+ # Only replace if existing has fewer files than the .pnpm store entry.
477
+ existing_files=$(find "$existing" -maxdepth 2 -type f 2>/dev/null | wc -l | tr -d ' ')
478
+ if [ "$entry_files" -gt "$existing_files" ]; then
479
+ rm -rf "$existing"
480
+ ln -sfn "$APP_PATH/$sub_path" "$existing"
481
+ fi
482
+ else
483
+ ln -sfn "$APP_PATH/$sub_path" "$existing"
484
+ fi
485
+ done
486
+ else
487
+ [ -e "node_modules/$pkg_name" ] || ln -sfn "$APP_PATH/$pkg_path" "node_modules/$pkg_name"
488
+ fi
489
+ done
490
+ done
491
+ fi
492
+
493
+ if [ -n "$RUNTIME_SUBDIR" ]; then
494
+ cat > server.js <<EOF
495
+import("./$RUNTIME_SUBDIR/server.js").catch(function(e){{console.error(e);process.exit(1)}})
496
+EOF
497
+ fi
498
+
499
+ # Backward compatibility: older operator-managed ecosystem config files
500
+ # may still point PM2 at `server.js` or `.next/standalone/server.js` in the
501
+ # app root even when standalone preserves the source path inside `web/`.
502
mkdir -p .next/standalone
356
- ln -sfn ../../server.js .next/standalone/server.js
503
+ rm -rf .next/standalone/node_modules
504
+ ln -sfn ../../node_modules .next/standalone/node_modules
505
+
506
+ if [ -n "$RUNTIME_SUBDIR" ]; then
507
+ cat > .next/standalone/server.js <<EOF
508
+import("../../$RUNTIME_SUBDIR/server.js").catch(function(e){{console.error(e);process.exit(1)}})
509
+EOF
510
+ else
511
+ ln -sfn ../../server.js .next/standalone/server.js
512
+ fi
513
514
echo "Starting $PM2_APP with pm2..."
515
if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
516
pm2 delete "$PM2_APP"
517
fi
518
363
- if [ -f ecosystem.config.js ]; then
519
+ if [ -f ecosystem.config.cjs ]; then
520
+ pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
521
+ elif [ -f ecosystem.config.js ]; then
522
pm2 start ecosystem.config.js --only "$PM2_APP" --env production
523
else
524
NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
@@ -371,7 +529,9 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
529
"#,
530
remote_path = remote_path,
531
pm2_app = pm2_app,
532
+ runtime_subdir = runtime_subdir_for_shell,
533
port = port,
534
+ ecosystem_config = ecosystem_config_content,
535
);
536
537
let mut restart_spinner = Spinner::new(
crates/cli/src/deploy/process_migrate.rs
new
+355
@@ -0,0 +1,355 @@
1
+use {
2
+ crate::{
3
+ account::login::process_login,
4
+ cli::CommandResult,
5
+ client,
6
+ deploy::config::get_config,
7
+ token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in},
8
+ ui::{fail_message, succeed_message, succeed_symbol},
9
+ },
10
+ anyhow::{anyhow, Result},
11
+ smbcloud_model::{
12
+ deploy_config_update::DeployConfigUpdate,
13
+ project::{DeploymentMethod, Project},
14
+ },
15
+ smbcloud_network::environment::Environment,
16
+ smbcloud_networking_project::crud_frontend_app_update_deploy_config::update_deploy_config,
17
+ smbcloud_utils::{config::Config, write_config::write_config},
18
+ spinners::Spinner,
19
+ std::{
20
+ collections::HashMap,
21
+ path::{Path, PathBuf},
22
+ process::Command,
23
+ },
24
+};
25
+
26
+const ECOSYSTEM_ENV_EXTRACTOR: &str = r#"
27
+const fs = require('fs');
28
+const path = require('path');
29
+const vm = require('vm');
30
+
31
+const ecosystemPath = path.resolve(process.argv[1]);
32
+const pm2AppName = process.argv[2] || '';
33
+const source = fs.readFileSync(ecosystemPath, 'utf8');
34
+const moduleRef = { exports: {} };
35
+const sandbox = {
36
+ module: moduleRef,
37
+ exports: moduleRef.exports,
38
+ require,
39
+ process,
40
+ console,
41
+ __dirname: path.dirname(ecosystemPath),
42
+ __filename: ecosystemPath,
43
+};
44
+sandbox.global = sandbox;
45
+sandbox.globalThis = sandbox;
46
+
47
+vm.runInNewContext(source, sandbox, { filename: ecosystemPath });
48
+
49
+const loadedConfig = moduleRef.exports;
50
+const apps = Array.isArray(loadedConfig && loadedConfig.apps) ? loadedConfig.apps : [];
51
+let selectedApp = null;
52
+
53
+if (pm2AppName) {
54
+ selectedApp = apps.find((app) => app && app.name === pm2AppName) || null;
55
+}
56
+
57
+if (!selectedApp && apps.length === 1) {
58
+ selectedApp = apps[0];
59
+}
60
+
61
+const envProduction = selectedApp && typeof selectedApp.env_production === 'object'
62
+ ? selectedApp.env_production
63
+ : null;
64
+
65
+process.stdout.write(JSON.stringify(envProduction));
66
+"#;
67
+
68
+fn ecosystem_config_path(project: &Project) -> Option<PathBuf> {
69
+ let source_directory = project
70
+ .source
71
+ .as_deref()
72
+ .or(project.source_path.as_deref())
73
+ .unwrap_or(".");
74
+ let source_directory = PathBuf::from(source_directory);
75
+
76
+ let ecosystem_config_cjs = source_directory.join("ecosystem.config.cjs");
77
+ if ecosystem_config_cjs.is_file() {
78
+ return Some(ecosystem_config_cjs);
79
+ }
80
+
81
+ let ecosystem_config_js = source_directory.join("ecosystem.config.js");
82
+ if ecosystem_config_js.is_file() {
83
+ return Some(ecosystem_config_js);
84
+ }
85
+
86
+ None
87
+}
88
+
89
+fn read_pm2_env_from_ecosystem_file(
90
+ ecosystem_path: &Path,
91
+ pm2_app_name: Option<&str>,
92
+) -> Result<Option<HashMap<String, serde_json::Value>>> {
93
+ let output = Command::new("node")
94
+ .arg("-e")
95
+ .arg(ECOSYSTEM_ENV_EXTRACTOR)
96
+ .arg(ecosystem_path)
97
+ .arg(pm2_app_name.unwrap_or(""))
98
+ .output()
99
+ .map_err(|error| {
100
+ anyhow!(
101
+ "Failed to run `node` to read '{}': {}",
102
+ ecosystem_path.display(),
103
+ error
104
+ )
105
+ })?;
106
+
107
+ if !output.status.success() {
108
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
109
+ return Err(anyhow!(
110
+ "Failed to parse '{}': {}",
111
+ ecosystem_path.display(),
112
+ if stderr.is_empty() {
113
+ "node exited without error output".to_string()
114
+ } else {
115
+ stderr
116
+ }
117
+ ));
118
+ }
119
+
120
+ let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
121
+ if stdout.is_empty() || stdout == "null" {
122
+ return Ok(None);
123
+ }
124
+
125
+ serde_json::from_str(&stdout).map(Some).map_err(|error| {
126
+ anyhow!(
127
+ "Failed to decode env vars from '{}': {}",
128
+ ecosystem_path.display(),
129
+ error
130
+ )
131
+ })
132
+}
133
+
134
+/// Build a `DeployConfigUpdate` from the local fields on a `Project`.
135
+/// Returns `None` when there is nothing to migrate.
136
+fn build_payload(
137
+ project: &Project,
138
+ pm2_env: Option<HashMap<String, serde_json::Value>>,
139
+) -> Option<DeployConfigUpdate> {
140
+ // runner and deployment_method are always present (defaulted), but we only
141
+ // want to send them when they differ from the zero-value defaults, or when
142
+ // any other field is populated. Simpler: always include them so the server
143
+ // has the full picture.
144
+ let runner = Some(project.runner as u8);
145
+ let deployment_method = Some(project.deployment_method as u8);
146
+
147
+ let payload = DeployConfigUpdate {
148
+ runner,
149
+ deployment_method,
150
+ kind: project.kind.clone(),
151
+ // `source` is the local build directory; maps to source_path on server.
152
+ // Fall back to `source_path` if `source` is absent.
153
+ source_path: project
154
+ .source
155
+ .clone()
156
+ .or_else(|| project.source_path.clone()),
157
+ remote_path: project.path.clone(),
158
+ package_manager: project.package_manager.clone(),
159
+ pm2_app: project.pm2_app.clone(),
160
+ pm2_env,
161
+ port: project.port,
162
+ output_path: project.output.clone(),
163
+ build_command: project.compile_cmd.clone(),
164
+ install_command: project.install_command.clone(),
165
+ binary_name: project.binary_name.clone(),
166
+ build_target: project.rust_target.clone(),
167
+ shared_lib_path: project.shared_lib.clone(),
168
+ };
169
+
170
+ // runner and deployment_method are always Some, so the payload is never
171
+ // technically "empty" — but if every optional field is None and the runner
172
+ // and deployment_method are their defaults, there is nothing interesting to
173
+ // push. We still send it so the server record is always consistent.
174
+ Some(payload)
175
+}
176
+
177
+/// Strip all deploy fields from a `Project`, keeping only identity fields.
178
+fn strip_project(project: &Project) -> Project {
179
+ Project {
180
+ id: project.id,
181
+ name: project.name.clone(),
182
+ runner: project.runner,
183
+ deployment_method: DeploymentMethod::default(),
184
+ frontend_app_id: project.frontend_app_id.clone(),
185
+ description: project.description.clone(),
186
+ // Everything below is intentionally cleared.
187
+ path: None,
188
+ repository: None,
189
+ deploy_repo_id: None,
190
+ source_path: None,
191
+ created_at: project.created_at,
192
+ updated_at: project.updated_at,
193
+ kind: None,
194
+ source: None,
195
+ output: None,
196
+ package_manager: None,
197
+ pm2_app: None,
198
+ pm2_env: None,
199
+ port: None,
200
+ shared_lib: None,
201
+ compile_cmd: None,
202
+ install_command: None,
203
+ binary_name: None,
204
+ rust_target: None,
205
+ }
206
+}
207
+
208
+pub async fn process_migrate(env: Environment) -> Result<CommandResult> {
209
+ let is_logged_in = is_logged_in(env).await?;
210
+ if !is_logged_in {
211
+ let _ = process_login(env, Some(is_logged_in)).await?;
212
+ }
213
+
214
+ let access_token = get_smb_token(env)?;
215
+ let config = get_config(env, Some(&access_token))
216
+ .await
217
+ .map_err(|e| anyhow!(fail_message(&format!("Failed to load config: {:?}", e))))?;
218
+
219
+ // Collect all projects to consider: root project + all [[projects]] entries.
220
+ let mut candidates: Vec<Project> = vec![config.project.clone()];
221
+ if let Some(ref sub_projects) = config.projects {
222
+ candidates.extend(sub_projects.iter().cloned());
223
+ }
224
+
225
+ let to_migrate: Vec<Project> = candidates
226
+ .into_iter()
227
+ .filter(|project| project.frontend_app_id.is_some())
228
+ .collect();
229
+
230
+ if to_migrate.is_empty() {
231
+ let spinner = Spinner::new(spinners::Spinners::Hamburger, String::new());
232
+ return Ok(CommandResult {
233
+ spinner,
234
+ symbol: succeed_symbol(),
235
+ msg: succeed_message("No projects with a frontend_app_id found — nothing to migrate."),
236
+ });
237
+ }
238
+
239
+ let spinner = Spinner::new(
240
+ spinners::Spinners::Hamburger,
241
+ succeed_message("Migrating deploy config to server..."),
242
+ );
243
+
244
+ let mut migrated = 0usize;
245
+ let mut failed = 0usize;
246
+
247
+ for project in &to_migrate {
248
+ let frontend_app_id = project.frontend_app_id.as_deref().unwrap();
249
+
250
+ println!(
251
+ "\n Migrating '{}' (frontend_app_id: {})...",
252
+ project.name, frontend_app_id
253
+ );
254
+
255
+ let pm2_env = match ecosystem_config_path(project) {
256
+ Some(ecosystem_path) => {
257
+ match read_pm2_env_from_ecosystem_file(&ecosystem_path, project.pm2_app.as_deref())
258
+ {
259
+ Ok(Some(env_values)) => {
260
+ println!(
261
+ " {} Migrating {} env vars from '{}'.",
262
+ succeed_symbol(),
263
+ env_values.len(),
264
+ ecosystem_path.display()
265
+ );
266
+ Some(env_values)
267
+ }
268
+ Ok(None) => {
269
+ println!(
270
+ " {} No env_production block found in '{}'.",
271
+ succeed_symbol(),
272
+ ecosystem_path.display()
273
+ );
274
+ None
275
+ }
276
+ Err(error) => {
277
+ println!(
278
+ " {} Failed to read env vars from '{}': {}",
279
+ fail_message("✘"),
280
+ ecosystem_path.display(),
281
+ error
282
+ );
283
+ None
284
+ }
285
+ }
286
+ }
287
+ None => None,
288
+ };
289
+
290
+ let payload = match build_payload(project, pm2_env) {
291
+ Some(p) => p,
292
+ None => {
293
+ println!(" {} Nothing to migrate — skipping.", succeed_symbol());
294
+ continue;
295
+ }
296
+ };
297
+
298
+ match update_deploy_config(
299
+ env,
300
+ client(),
301
+ access_token.clone(),
302
+ frontend_app_id,
303
+ &payload,
304
+ )
305
+ .await
306
+ {
307
+ Ok(_) => {
308
+ println!(" {} Migrated successfully.", succeed_symbol());
309
+ migrated += 1;
310
+ }
311
+ Err(e) => {
312
+ println!(" {} Failed: {:?}", fail_message("✘"), e);
313
+ failed += 1;
314
+ }
315
+ }
316
+ }
317
+
318
+ // Rewrite config with all deploy fields stripped.
319
+ let stripped_root = strip_project(&config.project);
320
+ let stripped_projects = config
321
+ .projects
322
+ .as_ref()
323
+ .map(|sub_projects| sub_projects.iter().map(strip_project).collect::<Vec<_>>());
324
+
325
+ let stripped_config = Config {
326
+ name: config.name.clone(),
327
+ description: config.description.clone(),
328
+ project: stripped_root,
329
+ projects: stripped_projects,
330
+ };
331
+
332
+ if let Err(e) = write_config(".", stripped_config) {
333
+ println!(
334
+ "\n {} Failed to rewrite .smb/config.toml: {:?}",
335
+ fail_message("✘"),
336
+ e
337
+ );
338
+ } else {
339
+ println!(
340
+ "\n {} .smb/config.toml rewritten (identity-only).",
341
+ succeed_symbol()
342
+ );
343
+ }
344
+
345
+ let summary = format!(
346
+ "Migration complete: {} migrated, {} failed.",
347
+ migrated, failed
348
+ );
349
+
350
+ Ok(CommandResult {
351
+ spinner,
352
+ symbol: succeed_symbol(),
353
+ msg: succeed_message(&summary),
354
+ })
355
+}
crates/cli/src/main.rs
+3
-1
@@ -6,7 +6,7 @@ use {
6
account::{login::process_login, logout::process_logout, me::process_me, process_account},
7
clear_smb_token,
8
cli::{Cli, CommandResult, Commands},
9
- deploy::process_deploy::process_deploy,
9
+ deploy::{process_deploy::process_deploy, process_migrate::process_migrate},
10
mail::process::process_mail,
11
project::{crud_create::process_project_init, process::process_project},
12
},
@@ -128,6 +128,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
128
| Some(Commands::Account { .. })
129
| Some(Commands::Mail { .. })
130
| Some(Commands::Project { .. })
131
+ | Some(Commands::Migrate {})
132
| None => true,
133
Some(Commands::Init {}) => true,
134
};
@@ -148,6 +149,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
149
Some(Commands::Logout {}) => process_logout(cli.environment).await,
150
Some(Commands::Mail { command }) => process_mail(cli.environment, command).await,
151
Some(Commands::Project { command }) => process_project(cli.environment, command).await,
152
+ Some(Commands::Migrate {}) => process_migrate(cli.environment).await,
153
None => process_deploy(cli.environment, None).await,
154
}
155
}
crates/smbcloud-model/src/deploy_config.rs
+2
@@ -34,6 +34,8 @@ pub struct DeployConfig {
34
#[serde(default)]
35
pub pm2_app: Option<String>,
36
#[serde(default)]
37
+ pub pm2_env: Option<std::collections::HashMap<String, serde_json::Value>>,
38
+ #[serde(default)]
39
pub port: Option<u16>,
40
#[serde(default)]
41
pub shared_lib_path: Option<String>,
crates/smbcloud-model/src/deploy_config_update.rs
new
+78
@@ -0,0 +1,78 @@
1
+use serde::{Deserialize, Serialize};
2
+
3
+/// Payload sent to PATCH /v1/frontend_apps/:id with deploy config fields.
4
+/// Only non-None fields are serialized to the request body.
5
+#[derive(Debug, Serialize, Deserialize, Default)]
6
+pub struct DeployConfigUpdate {
7
+ #[serde(skip_serializing_if = "Option::is_none")]
8
+ pub runner: Option<u8>,
9
+ #[serde(skip_serializing_if = "Option::is_none")]
10
+ pub deployment_method: Option<u8>,
11
+ #[serde(skip_serializing_if = "Option::is_none")]
12
+ pub kind: Option<String>,
13
+ #[serde(skip_serializing_if = "Option::is_none")]
14
+ pub source_path: Option<String>,
15
+ #[serde(skip_serializing_if = "Option::is_none")]
16
+ pub remote_path: Option<String>,
17
+ #[serde(skip_serializing_if = "Option::is_none")]
18
+ pub package_manager: Option<String>,
19
+ #[serde(skip_serializing_if = "Option::is_none")]
20
+ pub pm2_app: Option<String>,
21
+ #[serde(skip_serializing_if = "Option::is_none")]
22
+ pub pm2_env: Option<std::collections::HashMap<String, serde_json::Value>>,
23
+ #[serde(skip_serializing_if = "Option::is_none")]
24
+ pub port: Option<u16>,
25
+ #[serde(skip_serializing_if = "Option::is_none")]
26
+ pub output_path: Option<String>,
27
+ #[serde(skip_serializing_if = "Option::is_none")]
28
+ pub build_command: Option<String>,
29
+ #[serde(skip_serializing_if = "Option::is_none")]
30
+ pub install_command: Option<String>,
31
+ #[serde(skip_serializing_if = "Option::is_none")]
32
+ pub binary_name: Option<String>,
33
+ #[serde(skip_serializing_if = "Option::is_none")]
34
+ pub build_target: Option<String>,
35
+ #[serde(skip_serializing_if = "Option::is_none")]
36
+ pub shared_lib_path: Option<String>,
37
+}
38
+
39
+impl DeployConfigUpdate {
40
+ /// Returns true when no field has a value — nothing to send to the server.
41
+ pub fn is_empty(&self) -> bool {
42
+ self.runner.is_none()
43
+ && self.deployment_method.is_none()
44
+ && self.kind.is_none()
45
+ && self.source_path.is_none()
46
+ && self.remote_path.is_none()
47
+ && self.package_manager.is_none()
48
+ && self.pm2_app.is_none()
49
+ && self.pm2_env.is_none()
50
+ && self.port.is_none()
51
+ && self.output_path.is_none()
52
+ && self.build_command.is_none()
53
+ && self.install_command.is_none()
54
+ && self.binary_name.is_none()
55
+ && self.build_target.is_none()
56
+ && self.shared_lib_path.is_none()
57
+ }
58
+}
59
+
60
+#[cfg(test)]
61
+mod tests {
62
+ use super::DeployConfigUpdate;
63
+ use serde_json::json;
64
+ use std::collections::HashMap;
65
+
66
+ #[test]
67
+ fn detects_non_empty_pm2_env() {
68
+ let mut pm2_env = HashMap::new();
69
+ pm2_env.insert("APP_PUBLIC_URL".to_string(), json!("https://example.com"));
70
+
71
+ let payload = DeployConfigUpdate {
72
+ pm2_env: Some(pm2_env),
73
+ ..DeployConfigUpdate::default()
74
+ };
75
+
76
+ assert!(!payload.is_empty());
77
+ }
78
+}
crates/smbcloud-model/src/lib.rs
+1
@@ -1,6 +1,7 @@
1
pub mod account;
2
pub mod app_auth;
3
pub mod deploy_config;
4
+pub mod deploy_config_update;
5
pub mod error_codes;
6
pub mod forgot;
7
pub mod frontend_app;
crates/smbcloud-model/src/project.rs
+4
@@ -86,6 +86,10 @@ pub struct Project {
86
/// PM2 process name to restart after a nextjs-ssr deploy, e.g. "my-app".
87
/// Matches the name passed to `pm2 start` on the server.
88
pub pm2_app: Option<String>,
89
+ /// Environment variables to seed into the PM2 ecosystem `env_production` block.
90
+ /// Populated from the server-side App record; not written to `.smb/config.toml`.
91
+ #[serde(default, skip_serializing_if = "Option::is_none")]
92
+ pub pm2_env: Option<std::collections::HashMap<String, serde_json::Value>>,
93
/// Port the standalone server binds to (default: 3000). Must match nginx upstream configuration.
94
#[serde(default)]
95
pub port: Option<u16>,
crates/smbcloud-networking-project/src/crud_frontend_app_update_deploy_config.rs
new
+26
@@ -0,0 +1,26 @@
1
+use {
2
+ crate::url_builder::build_frontend_app_update_deploy_config_url,
3
+ reqwest::Client,
4
+ smbcloud_model::{
5
+ deploy_config::DeployConfig, deploy_config_update::DeployConfigUpdate,
6
+ error_codes::ErrorResponse,
7
+ },
8
+ smbcloud_network::{environment::Environment, network::request},
9
+ smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient},
10
+};
11
+
12
+pub async fn update_deploy_config(
13
+ environment: Environment,
14
+ client: (&SmbClient, &str),
15
+ access_token: String,
16
+ frontend_app_id: &str,
17
+ payload: &DeployConfigUpdate,
18
+) -> Result<DeployConfig, ErrorResponse> {
19
+ let url = build_frontend_app_update_deploy_config_url(environment, client, frontend_app_id);
20
+ let builder = Client::new()
21
+ .patch(url)
22
+ .header("Authorization", access_token)
23
+ .header("User-agent", SMB_USER_AGENT)
24
+ .json(payload);
25
+ request(builder).await
26
+}
crates/smbcloud-networking-project/src/lib.rs
+1
@@ -1,6 +1,7 @@
1
pub mod crud_frontend_app_create;
2
pub mod crud_frontend_app_deploy_config;
3
pub mod crud_frontend_app_read;
4
+pub mod crud_frontend_app_update_deploy_config;
5
pub mod crud_project_create;
6
pub mod crud_project_delete;
7
pub mod crud_project_deployment_create;
crates/smbcloud-networking-project/src/url_builder.rs
+11
@@ -9,6 +9,17 @@ pub(crate) fn build_frontend_apps_url(env: Environment, client: (&SmbClient, &st
9
url_builder.build()
10
}
11
12
+pub(crate) fn build_frontend_app_update_deploy_config_url(
13
+ env: Environment,
14
+ client: (&SmbClient, &str),
15
+ frontend_app_id: &str,
16
+) -> String {
17
+ let mut url_builder = smb_base_url_builder(env, client);
18
+ url_builder.add_route("v1/frontend_apps");
19
+ url_builder.add_route(frontend_app_id);
20
+ url_builder.build()
21
+}
22
+
23
pub(crate) fn build_frontend_app_deploy_config_url(
24
env: Environment,
25
client: (&SmbClient, &str),