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
}