@smbcloud / cli / commits / 69daacb

smbCloud Deploy enhancement

Seto Elkahfi committed May 24, 2026 at 18:01 UTC 69daacb965c4c598575ddbaa61d83a93bee4a62c
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),