@smbcloud / cli / commits / 0603f24

Add deployment skills docs for Next.js SSR and rsync paths

Update Runner detection to treat any package.json as NodeJs Preserve auth error codes in request_login for 401 responses

Seto Elkahfi committed Mar 29, 2026 at 00:20 UTC 0603f2437e93c34d90a42ff0beebe52d622e3c08
4 files changed +480 -36
.agents/skills/smbcloud-deploy-nextjs/SKILL.md new
+289
@@ -0,0 +1,289 @@
1 +---
2 +name: smbcloud-deploy-nextjs
3 +description: Use when deploying or debugging Next.js apps on smbCloud, especially the dedicated `nextjs-ssr` flow that builds locally, uploads `.next/standalone` via rsync, restarts PM2 over SSH, and serves traffic behind Nginx.
4 +---
5 +
6 +# smbCloud Deploy Next.js
7 +
8 +Use this skill when work touches Next.js deployment on smbCloud.
9 +
10 +Applies to:
11 +
12 +- `kind = "nextjs-ssr"` projects
13 +- `.smb/config.toml` deploy configuration
14 +- `process_deploy_nextjs_ssr.rs`
15 +- PM2 process management for standalone builds
16 +- Nginx proxying for SSR apps
17 +- production-only issues caused by CORS, ports, or runtime mismatch
18 +
19 +## Deployment model
20 +
21 +For `nextjs-ssr`, smbCloud does not use git-push deploy.
22 +
23 +The CLI route in `process_deploy.rs` short-circuits on:
24 +
25 +- `kind = "nextjs-ssr"`
26 +
27 +That path builds locally, then:
28 +
29 +- rsyncs `.next/standalone/` into the remote app directory root
30 +- rsyncs `.next/static/` into `remote/.next/static/`
31 +- rsyncs `public/` into `remote/public/`
32 +- runs `ssh` and restarts the app with PM2
33 +
34 +Do not describe this flow as git based unless the project is actually using the generic git deploy path.
35 +
36 +## Required app configuration
37 +
38 +The Next.js app must produce a standalone server bundle.
39 +
40 +Create `next.config.mjs` or equivalent with:
41 +
42 +```js
43 +/** @type {import('next').NextConfig} */
44 +const nextConfig = {
45 + output: "standalone",
46 +};
47 +
48 +export default nextConfig;
49 +```
50 +
51 +Without `output: "standalone"`, the deploy code fails because `.next/standalone/` is missing.
52 +
53 +The app package scripts should support:
54 +
55 +- `build`: `next build`
56 +
57 +`pnpm` is the safe package manager for the current CLI implementation.
58 +
59 +## Required smbCloud config
60 +
61 +Minimum `.smb/config.toml` for SSR:
62 +
63 +```toml
64 +[project]
65 +deployment_method = 1
66 +kind = "nextjs-ssr"
67 +source = "."
68 +package_manager = "pnpm"
69 +pm2_app = "my-app"
70 +path = "apps/web/my-app"
71 +```
72 +
73 +Important rules:
74 +
75 +- `kind = "nextjs-ssr"` is what activates the SSR deploy path
76 +- `source` is the local Next.js app directory
77 +- `path` is the remote destination directory on the server
78 +- `pm2_app` is mandatory because the CLI restarts PM2 by name
79 +- `deployment_method = 1` may still be present, but the `kind` routing is what matters most
80 +
81 +## Expected remote layout
82 +
83 +After a successful deploy, the remote directory should look like:
84 +
85 +- `/home/git/apps/web/<app>/server.js`
86 +- `/home/git/apps/web/<app>/.next/static/...`
87 +- `/home/git/apps/web/<app>/public/...`
88 +
89 +Note that `server.js` is expected at the remote root because the CLI uploads the contents of `.next/standalone/`, not the folder itself.
90 +
91 +## PM2 behavior
92 +
93 +The current SSR deploy implementation does not use `ecosystem.config.js`.
94 +
95 +It runs logic equivalent to:
96 +
97 +```sh
98 +cd "$APP_PATH"
99 +
100 +if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
101 + pm2 restart "$PM2_APP"
102 +else
103 + PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
104 +fi
105 +
106 +pm2 save
107 +```
108 +
109 +This has two consequences:
110 +
111 +- old git-based `post-receive` hooks are irrelevant for the SSR path
112 +- the port is currently hardcoded to `3010` in the CLI unless the code is patched
113 +
114 +### Manual PM2 operations
115 +
116 +If the app is already deployed and `server.js` exists:
117 +
118 +```sh
119 +cd /home/git/apps/web/<app>
120 +pm2 restart <pm2_app> --update-env
121 +pm2 save
122 +```
123 +
124 +If starting from scratch:
125 +
126 +```sh
127 +cd /home/git/apps/web/<app>
128 +PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <pm2_app> -- server.js
129 +pm2 save
130 +```
131 +
132 +## Port management
133 +
134 +Use one source of truth for the runtime port.
135 +
136 +If an older `ecosystem.config.js` exists and contains both:
137 +
138 +- `args: "start -p 3026"`
139 +- `env_production.PORT = 3025`
140 +
141 +that is a broken configuration. Standardize on one port value.
142 +
143 +For the current SSR deploy code, the real runtime is controlled by the environment passed to `node server.js`, not by `next start` or an ecosystem file.
144 +
145 +If the app must run on a port other than `3010`, patch the CLI or restart PM2 manually with the correct port.
146 +
147 +## Nginx for SSR
148 +
149 +Do not use a static-site Nginx config like:
150 +
151 +```nginx
152 +root /home/git/apps/web/my-app;
153 +location / {
154 + try_files $uri $uri/ =404;
155 +}
156 +```
157 +
158 +That is for static files, not Next.js SSR.
159 +
160 +Use reverse proxying to the local Node process instead:
161 +
162 +```nginx
163 +location / {
164 + proxy_pass http://127.0.0.1:3026;
165 + proxy_http_version 1.1;
166 +
167 + proxy_set_header Host $host;
168 + proxy_set_header X-Real-IP $remote_addr;
169 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
170 + proxy_set_header X-Forwarded-Proto $scheme;
171 +
172 + proxy_set_header Upgrade $http_upgrade;
173 + proxy_set_header Connection "upgrade";
174 +
175 + proxy_cache_bypass $http_upgrade;
176 +}
177 +```
178 +
179 +Nginx must proxy to the actual PM2 port.
180 +
181 +## Common failure modes
182 +
183 +### `.next/standalone` missing
184 +
185 +Cause:
186 +
187 +- no `output: "standalone"` in Next config
188 +
189 +Fix:
190 +
191 +- add standalone output
192 +- rebuild locally
193 +
194 +### `server.js` missing on the server
195 +
196 +Check:
197 +
198 +- whether the SSR deploy path actually ran
199 +- whether `path` points to the directory you inspected
200 +- whether `rsync` uploaded to a different location
201 +
202 +### Missing `public/`
203 +
204 +The current deploy code tries to rsync `public/` unconditionally.
205 +
206 +If the project has no `public/` directory, deploy may fail. Either:
207 +
208 +- create an empty `public/`
209 +- or patch the CLI to skip missing directories
210 +
211 +### Wrong port after deploy
212 +
213 +Cause:
214 +
215 +- CLI hardcodes `PORT=3010` for fresh starts
216 +
217 +Fix:
218 +
219 +- manually restart with the correct port
220 +- or patch the CLI to read a configurable port from `.smb/config.toml`
221 +
222 +### Misleading deploy output
223 +
224 +The CLI may print generic deploy messages such as:
225 +
226 +- `Deploying > Building the app`
227 +- `App is running`
228 +
229 +These messages do not mean git push happened. Confirm behavior from the code path, not the spinner text.
230 +
231 +## Auth and production-only web issues
232 +
233 +If the Next.js app uses the wasm auth client from `smbcloud-networking-account-wasm`, production failures that show:
234 +
235 +- `Network error. Please check your internet connection and try again.`
236 +
237 +are often not actual connectivity failures.
238 +
239 +Check these first:
240 +
241 +- Rails CORS allows the production origin
242 +- `Authorization` response header is exposed over CORS
243 +- backend auth status codes match the Rust parser logic
244 +
245 +### Rails requirement
246 +
247 +For browser clients using `POST /v1/client/users/sign_in`, if the token is returned in the `Authorization` response header, CORS must include:
248 +
249 +- production origins such as `https://ondeinference.com`
250 +- `expose: %w[... Authorization]`
251 +
252 +Without that, browser fetch in wasm can surface as a fake network error.
253 +
254 +### Rust requirement
255 +
256 +The shared Rust networking layer must not collapse all non-200 auth responses into `NetworkError`.
257 +
258 +If backend returns meaningful auth errors such as `401` invalid password, update the parser in `request_login` to preserve the real failure.
259 +
260 +## Recommended validation
261 +
262 +Use the smallest checks that prove the contract.
263 +
264 +### Next.js app
265 +
266 +- `pnpm build`
267 +- verify `.next/standalone/server.js` exists
268 +
269 +### smbcloud-cli
270 +
271 +- `cargo check -p smbcloud-network`
272 +- `cargo check -p cli` when deploy code changed
273 +
274 +### Server
275 +
276 +- `pm2 list`
277 +- `pm2 logs <pm2_app>`
278 +- `ls -la /home/git/apps/web/<app>`
279 +- `sudo nginx -t`
280 +
281 +## Common mistakes
282 +
283 +- assuming `deployment_method = 1` alone activates SSR deploy
284 +- forgetting `kind = "nextjs-ssr"`
285 +- keeping an old git `post-receive` hook and expecting it to manage SSR deploys
286 +- using static-file Nginx config for an SSR app
287 +- trusting an old `ecosystem.config.js` when the CLI actually runs `node server.js`
288 +- leaving port values inconsistent across PM2, Nginx, and CLI
289 +- treating browser CORS failures as real internet outages
.agents/skills/smbcloud-deploy-rsync/SKILL.md new
+158
@@ -0,0 +1,158 @@
1 +---
2 +name: smbcloud-deploy-rsync
3 +description: Use when implementing or debugging smbCloud's generic rsync-based deployment path in `smbcloud-cli`, especially `rsync_deploy.rs`, workspace dependency wiring, SSH identity handling, remote path normalization, and parity with shell rsync commands like `rsync -a source/ host:path/`.
4 +---
5 +
6 +# smbCloud Deploy Rsync
7 +
8 +Use this skill when work is about smbCloud's generic rsync deploy path in the Rust CLI, not the specialized Next.js SSR flow.
9 +
10 +Applies to:
11 +
12 +- `crates/cli/src/deploy/rsync_deploy.rs`
13 +- workspace dependency wiring in `Cargo.toml`
14 +- embedding the Rust rsync crate instead of shelling out to system `rsync`
15 +- reproducing command semantics like `rsync -a source/ host:path/`
16 +- SSH key and remote destination handling for smbCloud runners
17 +
18 +## Scope boundary
19 +
20 +This skill is for the generic rsync deploy implementation in `smbcloud-cli`.
21 +
22 +Do not use it for:
23 +
24 +- `kind = "nextjs-ssr"` local-build deploys
25 +- PM2 restart logic for standalone Next.js bundles
26 +- Nginx config for SSR apps
27 +
28 +Those belong to the Next.js deploy skill.
29 +
30 +## Target behavior
31 +
32 +The baseline command to replicate is:
33 +
34 +```sh
35 +rsync -a 5mb-dot-app/ rsync-deploy-id-11:apps/web/5mb.app/
36 +```
37 +
38 +Important semantics:
39 +
40 +- trailing slash on the source means copy the contents of the directory
41 +- trailing slash on the destination means sync into that directory
42 +- archive mode preserves the usual rsync metadata behavior
43 +- the remote target is `host:path`, not a local filesystem path
44 +
45 +When implementing in Rust, preserve these semantics explicitly. Do not assume path handling will match shell rsync unless you normalize it yourself.
46 +
47 +## Current smbCloud conventions
48 +
49 +Generic rsync deploy uses smbCloud config and runner metadata.
50 +
51 +Expect these responsibilities:
52 +
53 +- get the logged-in smbCloud user
54 +- resolve the SSH identity path from `Config::ssh_key_path(user.id)`
55 +- determine the runner host, usually through `runner.rsync_host()`
56 +- build the remote destination from configured project `path`
57 +- deploy the current source tree or configured source directory
58 +
59 +The user-visible output may still mention the SSH key path. Preserve useful diagnostics, but avoid misleading claims about git deploy.
60 +
61 +## Dependency wiring
62 +
63 +When the task is to replace shell `rsync` with the Rust embedding crate:
64 +
65 +1. add the upstream rsync embedding crate at the workspace root `Cargo.toml`
66 +2. add it as a workspace dependency in the consuming crate
67 +3. wire only the minimal surface needed by `rsync_deploy.rs`
68 +
69 +Prefer the embedding crate over copying CLI subprocess behavior when the goal is portability or tighter control.
70 +
71 +Before coding, inspect:
72 +
73 +- the embedding crate API surface
74 +- whether it supports client mode directly
75 +- how stdout/stderr and exit status are reported
76 +- how remote-shell transport is configured, if at all
77 +
78 +If the crate cannot actually express `host:path` SSH transport parity, say so explicitly instead of forcing a partial migration.
79 +
80 +## Implementation guidance
81 +
82 +### Path normalization
83 +
84 +Be precise with trailing slashes.
85 +
86 +Preferred rule:
87 +
88 +- normalize local source to end with `/`
89 +- normalize remote destination to end with `/`
90 +
91 +That matches the intended `rsync -a source/ host:path/` behavior.
92 +
93 +### SSH identity
94 +
95 +The CLI currently resolves the private key path from the smbCloud user id.
96 +
97 +If the embedded rsync client still needs an ssh transport command, keep the identity handling equivalent to the current shell behavior.
98 +
99 +Typical concerns:
100 +
101 +- identity file path
102 +- strict host key checking
103 +- known hosts file or pinned host key strategy
104 +- batch mode / no password prompts
105 +
106 +### Failure reporting
107 +
108 +Preserve actionable errors.
109 +
110 +Good failures include:
111 +
112 +- local source path missing
113 +- remote project path missing in config
114 +- SSH key missing
115 +- embedded client non-zero exit status
116 +- stderr emitted by the transfer implementation
117 +
118 +Do not flatten all deploy failures into a generic message.
119 +
120 +### Behavior parity
121 +
122 +If replacing subprocess `rsync`, confirm parity for:
123 +
124 +- source contents vs source directory copying
125 +- remote path handling
126 +- recursive transfer
127 +- deletion behavior, if smbCloud depends on it
128 +- stdout/stderr capture
129 +- non-zero status mapping
130 +
131 +## Validation
132 +
133 +Use small checks first.
134 +
135 +### Rust
136 +
137 +- `cargo check -p smbcloud-cli`
138 +- targeted tests if added around path normalization or command construction
139 +
140 +### Behavioral
141 +
142 +Verify the resulting implementation matches the shell command shape conceptually:
143 +
144 +- source `dir/`
145 +- destination `host:path/`
146 +- ssh identity derived from smbCloud config
147 +
148 +If the code still shells out to `rsync`, validate the exact arguments.
149 +If it migrates to the embedding crate, validate equivalent output and error handling.
150 +
151 +## Common mistakes
152 +
153 +- mixing this generic rsync deploy path with the specialized `nextjs-ssr` flow
154 +- losing trailing slash semantics and copying the wrong directory level
155 +- adding the dependency in one `Cargo.toml` but not wiring the workspace dependency correctly
156 +- assuming the embedding crate automatically supports ssh remote syntax without checking
157 +- hiding transport stderr that the user needs to debug deploy failures
158 +- hardcoding local paths instead of using smbCloud config and user-derived SSH identity
crates/smbcloud-model/src/runner.rs
+4 -33
@@ -4,7 +4,6 @@ use {
4 serde_repr::{Deserialize_repr, Serialize_repr},
5 std::{
6 fmt::{self, Display, Formatter},
7 - fs,
7 path::PathBuf,
8 },
9 };
@@ -56,10 +55,10 @@ pub enum SwiftFramework {
55
56 impl Runner {
57 pub fn from(repo_path: &PathBuf) -> Result<Runner, ErrorResponse> {
59 - // See if we have a framework-based config.
60 - if repo_path.join("package.json").exists()
61 - && (next_config_exists(repo_path) || astro_config_exists(repo_path))
62 - {
58 + // Any package.json-driven app belongs on the NodeJs runner.
59 + // Framework-specific checks are not reliable because modern Next.js apps
60 + // do not need a next.config.* file at all.
61 + if repo_path.join("package.json").exists() {
62 return Ok(Runner::NodeJs);
63 }
64
@@ -99,31 +98,3 @@ fn non_framework_runner() -> Result<Runner, ErrorResponse> {
98 message: UnsupportedRunner.message(None).to_string(),
99 })
100 }
102 -
103 -// Helper function to detect any next.config.* file
104 -fn next_config_exists(repo_path: &PathBuf) -> bool {
105 - if let Ok(entries) = fs::read_dir(repo_path) {
106 - for entry in entries.flatten() {
107 - let filename = entry.file_name();
108 - let filename_str = filename.to_string_lossy();
109 - if filename_str.starts_with("next.config.") {
110 - return true;
111 - }
112 - }
113 - }
114 - false
115 -}
116 -
117 -// Helper function to detect any astro.config.* file
118 -fn astro_config_exists(repo_path: &PathBuf) -> bool {
119 - if let Ok(entries) = fs::read_dir(repo_path) {
120 - for entry in entries.flatten() {
121 - let filename = entry.file_name();
122 - let filename_str = filename.to_string_lossy();
123 - if filename_str.starts_with("astro.config.") {
124 - return true;
125 - }
126 - }
127 - }
128 - false
129 -}
crates/smbcloud-network/src/network.rs
+29 -3
@@ -182,9 +182,35 @@ pub async fn request_login(builder: RequestBuilder) -> Result<AccountStatus, Err
182 };
183 Ok(AccountStatus::Incomplete { status: error_code })
184 }
185 - _ => Err(ErrorResponse::Error {
186 - error_code: ErrorCode::NetworkError,
187 - message: ErrorCode::NetworkError.message(None).to_string(),
185 + (StatusCode::UNAUTHORIZED, _) => {
186 + let result: SmbAuthorization = match response.json().await {
187 + Ok(res) => res,
188 + Err(_) => {
189 + return Err(ErrorResponse::Error {
190 + error_code: ErrorCode::NetworkError,
191 + message: ErrorCode::NetworkError.message(None).to_string(),
192 + });
193 + }
194 + };
195 + let error_code = match result.error_code {
196 + Some(code) => code,
197 + None => {
198 + return Err(ErrorResponse::Error {
199 + error_code: ErrorCode::NetworkError,
200 + message: ErrorCode::NetworkError.message(None).to_string(),
201 + });
202 + }
203 + };
204 + Err(ErrorResponse::Error {
205 + error_code: ErrorCode::Unauthorized,
206 + message: error_code.to_string(),
207 + })
208 + }
209 + (status, _) => parse_error_response(response).await.or_else(|_| {
210 + Err(ErrorResponse::Error {
211 + error_code: ErrorCode::NetworkError,
212 + message: format!("Unexpected login response status: {}", status),
213 + })
214 }),
215 }
216 }