18 files changed
+379
-76
.agents/skills/smbcloud-deploy-monorepo/SKILL.md
+48
-9
@@ -18,7 +18,16 @@ Applies to:
18
19
## What makes a monorepo
20
21
-A monorepo is a repository where `[project]` has `runner = 255` (the `Monorepo` variant) and deployable sub-projects are listed as `[[projects]]` entries. The root project is never deployed directly — it is a container.
21
+A monorepo is a repository where `[project]` has `runner = 255` (the `Monorepo` variant) and deployable targets are listed as `[[projects]]` entries. The root project is never deployed directly — it is a container.
22
+
23
+Keep the deploy hierarchy straight:
24
+
25
+- root `[project]` = umbrella workspace in smbCloud
26
+- repo = the monorepo itself
27
+- each `[[projects]]` entry = one deployable app target inside that repo
28
+- each deployment record = one release of one app target
29
+
30
+The config format still uses `[[projects]]` for historical reasons, but conceptually those entries are app targets, not separate umbrella workspaces.
31
32
The CLI detects this in `process_deploy.rs`:
33
@@ -54,6 +63,8 @@ created_at = "2025-09-20T16:33:05.154Z"
63
updated_at = "2025-09-20T16:33:05.154Z"
64
```
65
66
+The root `id` is the workspace ID. It is not a deployable app ID.
67
+
68
Key rules:
69
70
- `runner = 255` is mandatory — this is what activates monorepo behavior
@@ -62,11 +73,13 @@ Key rules:
73
74
### Sub-projects (deployable units)
75
65
-Each `[[projects]]` entry is a self-contained deploy target with its own `kind`, `source`, `path`, `runner`, and strategy-specific fields.
76
+Each `[[projects]]` entry is a self-contained deployable app target with its own `kind`, `source`, `path`, `runner`, and strategy-specific fields.
77
78
```toml
79
[[projects]]
69
-id = 54
80
+id = 38
81
+frontend_app_id = "a7f6d5d4-..."
82
+deploy_repo_id = 12
83
name = "splitfireweb"
84
repository = "splitfireweb"
85
description = "SplitFire AI website."
@@ -112,7 +125,9 @@ updated_at = "2025-09-20T16:33:05.154Z"
125
126
| Field | Required | Description |
127
| ------------------- | -------- | ------------------------------------------------------------------------------------------- |
115
-| `id` | yes | smbCloud project ID |
128
+| `id` | yes | umbrella workspace ID in smbCloud |
129
+| `frontend_app_id` | no | deployable app ID for precise deployment tracking |
130
+| `deploy_repo_id` | no | repo ID backing the deploy target |
131
| `name` | yes | unique identifier, used with `--project` flag and in the selection prompt |
132
| `repository` | yes | remote repository name on the smbCloud server (used for git deploy and SSH paths) |
133
| `description` | no | human-readable description |
@@ -163,7 +178,7 @@ smb deploy --project <name>
178
179
### Strategy: `nextjs-ssr`
180
166
-Local build, rsync three directories, SSH PM2 restart. See `smbcloud-deploy-nextjs` skill for details.
181
+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.
182
183
Required fields: `kind`, `source`, `path`, `pm2_app`, `package_manager`, `port`
184
@@ -368,8 +383,12 @@ steps:
383
ssh $GIT_USER@$GIT_HOST bash << 'EOF'
384
set -e
385
cd ~/apps/web/splitfireweb
386
+ mkdir -p logs
387
if pm2 describe "splitfire-web" > /dev/null 2>&1; then
372
- pm2 restart "splitfire-web" --update-env
388
+ pm2 delete "splitfire-web"
389
+ fi
390
+ if [ -f ecosystem.config.js ]; then
391
+ pm2 start ecosystem.config.js --only "splitfire-web" --env production
392
else
393
PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "splitfire-web" -- server.js
394
fi
@@ -427,7 +446,14 @@ All CI rsync commands must include:
446
447
### CI PM2 restart
448
430
-The CI restart command must use `pm2 restart <app> --update-env` on subsequent deploys so environment variable changes from the ecosystem file are picked up. Without `--update-env`, PM2 keeps the environment from the original `pm2 start` invocation.
449
+Current CI and CLI parity should use delete + fresh start, not restart-in-place:
450
+
451
+- `pm2 delete <app>` if it exists
452
+- `pm2 start ecosystem.config.js --only <app> --env production` when the server file exists
453
+- otherwise fall back to `PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <app> -- server.js`
454
+- `pm2 save`
455
+
456
+This matches the standalone Next.js deploy model and avoids stale PM2 command state from older deploy methods.
457
458
## Deployment tracking
459
@@ -437,6 +463,11 @@ Every deploy records status in the smbCloud API regardless of strategy:
463
2. `PATCH /deployments/:id` with `status: Done` on success
464
3. `PATCH /deployments/:id` with `status: Failed` on failure
465
466
+Current tracking model in the CLI:
467
+
468
+- requests still route through the umbrella workspace `id`
469
+- when `frontend_app_id` is present in the deploy target config, the payload also carries it so the API can attribute the deploy to the correct app inside the repo or monorepo
470
+
471
For non-git deploys (rsync, nextjs-ssr, vite-spa), the `commit_hash` field is a UTC timestamp (`20250920T163305Z`) since there is no git commit on the deploy path.
472
473
## Adding a new sub-project to a monorepo
@@ -496,7 +527,13 @@ The Nginx `alias` directive for `/_next/static/` or the `root` for a Vite SPA po
527
528
### PM2 not picking up new environment variables
529
499
-Use `pm2 restart <app> --update-env && pm2 save`. Without `--update-env`, PM2 reuses the environment from the original start.
530
+Current CLI parity is delete + fresh start. Check these in order:
531
+
532
+1. the server's `ecosystem.config.js` was edited, not just the repo copy
533
+2. the deploy script is actually starting from `ecosystem.config.js`
534
+3. `pm2 save` ran after the fresh start
535
+
536
+If you are changing env manually outside the deploy flow, `pm2 restart <app> --update-env && pm2 save` is still a valid operator command.
537
538
## Validation
539
@@ -530,5 +567,7 @@ Use `pm2 restart <app> --update-env && pm2 save`. Without `--update-env`, PM2 re
567
- forgetting `--mkpath` on rsync in CI workflows — first deploys fail because the remote directory does not exist
568
- keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
569
- mixing up `path` (remote destination) and `source` (local directory) — they serve different purposes
533
-- using `pm2 restart` without `--update-env` after changing ecosystem file variables
570
+- treating the root workspace id as the deployable app id
571
+- forgetting to add `frontend_app_id` on `[[projects]]` entries once the API exposes it
572
+- using `pm2 restart` without `--update-env` after changing ecosystem file variables in older restart-in-place workflows
573
- assuming the CLI generates Nginx configs — it does not; Nginx is always configured manually on the server
.agents/skills/smbcloud-deploy-nextjs/SKILL.md
+47
-18
@@ -9,7 +9,7 @@ Use this skill when work touches Next.js deployment on smbCloud.
9
10
Applies to:
11
12
-- `kind = "nextjs-ssr"` projects
12
+- `kind = "nextjs-ssr"` deploy targets
13
- `.smb/config.toml` deploy configuration
14
- `process_deploy_nextjs_ssr.rs`
15
- PM2 process management for standalone builds
@@ -18,6 +18,13 @@ Applies to:
18
19
## Deployment model
20
21
+Keep the deploy hierarchy straight:
22
+
23
+- `Project` = umbrella workspace in smbCloud
24
+- `DeployRepo` = repo or monorepo root inside that workspace
25
+- Next.js deploy target in `.smb/config.toml` = the deployable app unit
26
+- deployment record = one release of that app
27
+
28
For `nextjs-ssr`, smbCloud does not use git-push deploy.
29
30
The CLI route in `process_deploy.rs` short-circuits on:
@@ -62,20 +69,26 @@ Minimum `.smb/config.toml` for SSR:
69
70
```toml
71
[project]
72
+id = 28 # workspace id
73
+frontend_app_id = "uuid" # deployable app id (preferred when available)
74
deployment_method = 1
75
kind = "nextjs-ssr"
76
source = "."
77
package_manager = "pnpm"
78
pm2_app = "my-app"
79
path = "apps/web/my-app"
80
+port = 3028
81
```
82
83
Important rules:
84
85
- `kind = "nextjs-ssr"` is what activates the SSR deploy path
86
+- `id` remains the umbrella workspace id used for auth and API routing
87
+- `frontend_app_id` should be present when the API exposes it so deployment tracking is app-specific
88
- `source` is the local Next.js app directory
89
- `path` is the remote destination directory on the server
90
- `pm2_app` is mandatory because the CLI restarts PM2 by name
91
+- `port` should be set explicitly and must match nginx
92
- `deployment_method = 1` may still be present, but the `kind` routing is what matters most
93
94
## Expected remote layout
@@ -90,15 +103,23 @@ Note that `server.js` is expected at the remote root because the CLI uploads the
103
104
## PM2 behavior
105
93
-The CLI's deploy path runs logic equivalent to:
106
+The CLI's deploy path now deletes and starts fresh, preferring the server's
107
+`ecosystem.config.js` when it exists.
108
+
109
+Conceptually it behaves like:
110
111
```sh
112
cd "$APP_PATH"
113
+mkdir -p logs
114
115
if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
99
- pm2 restart "$PM2_APP"
116
+ pm2 delete "$PM2_APP"
117
+fi
118
+
119
+if [ -f ecosystem.config.js ]; then
120
+ pm2 start ecosystem.config.js --only "$PM2_APP" --env production
121
else
101
- PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
122
+ NODE_ENV=production PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
123
fi
124
125
pm2 save
@@ -107,7 +128,8 @@ pm2 save
128
This has two consequences:
129
130
- old git-based `post-receive` hooks are irrelevant for the SSR path
110
-- the port is currently hardcoded to `3010` in the CLI for fresh starts unless patched
131
+- the configured `port` in `.smb/config.toml` is used for the fallback start path
132
+- the live server `ecosystem.config.js` is the runtime source of truth when present
133
134
### Environment variables â ecosystem file (standard pattern)
135
@@ -141,7 +163,7 @@ The flow works like this:
163
pm2 save
164
```
165
144
-2. Every subsequent CLI deploy runs `pm2 restart <pm2_app>`. Because `pm2 save` already persisted the env, PM2 restarts with the same environment â **the CLI does not need to know about the ecosystem file at all**.
166
+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`.
167
168
3. To change or add env vars, SSH in, edit `ecosystem.config.js`, then:
169
@@ -150,23 +172,26 @@ The flow works like this:
172
pm2 save
173
```
174
153
-The ecosystem file is the server-side source of truth for each appâs runtime config. Future versions of the smbCloud web console will provide a UI to manage these vars per app, which will write to this file and trigger the restart remotely.
175
+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.
176
177
### Manual PM2 operations (without ecosystem file)
178
157
-If the app is already deployed and `server.js` exists:
179
+If you are operating without an ecosystem file, prefer the same fresh-start model the CLI uses:
180
181
```sh
182
cd /home/git/apps/web/<app>
161
-pm2 restart <pm2_app> --update-env
183
+if pm2 describe <pm2_app> > /dev/null 2>&1; then
184
+ pm2 delete <pm2_app>
185
+fi
186
+PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <pm2_app> -- server.js
187
pm2 save
188
```
189
165
-If starting from scratch without an ecosystem file:
190
+If you are using an ecosystem file and only changed env vars manually, this is still valid:
191
192
```sh
193
cd /home/git/apps/web/<app>
169
-PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <pm2_app> -- server.js
194
+pm2 restart <pm2_app> --update-env
195
pm2 save
196
```
197
@@ -181,9 +206,11 @@ If an older `ecosystem.config.js` exists and contains both:
206
207
that is a broken configuration. Standardize on one port value.
208
184
-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.
209
+Current rules:
210
186
-If the app must run on a port other than `3010`, patch the CLI or restart PM2 manually with the correct port.
211
+- fallback fresh-start path uses `port` from `.smb/config.toml`
212
+- ecosystem-managed apps should set the same port in `env_production.PORT`
213
+- nginx upstream must match that port
214
215
## Nginx for SSR
216
@@ -253,12 +280,12 @@ If the project has no `public/` directory, deploy may fail. Either:
280
281
Cause:
282
256
-- CLI hardcodes `PORT=3010` for fresh starts
283
+- `port` in `.smb/config.toml`, server `ecosystem.config.js`, and nginx upstream disagree
284
285
Fix:
286
260
-- manually restart with the correct port
261
-- or patch the CLI to read a configurable port from `.smb/config.toml`
287
+- standardize the same port in all three places
288
+- if using ecosystem config, make sure PM2 is actually starting from that file
289
290
### Misleading deploy output
291
@@ -325,6 +352,8 @@ Use the smallest checks that prove the contract.
352
- forgetting `kind = "nextjs-ssr"`
353
- keeping an old git `post-receive` hook and expecting it to manage SSR deploys
354
- using static-file Nginx config for an SSR app
328
-- editing `ecosystem.config.js` but forgetting `pm2 restart <app> --update-env && pm2 save` â PM2 keeps the old env until explicitly refreshed
329
-- leaving port values inconsistent across PM2, Nginx, and CLI
355
+- editing the repo copy of `ecosystem.config.js` and expecting a normal CLI deploy to overwrite the server copy — it is preserved intentionally
356
+- editing the server `ecosystem.config.js` but the deploy script falling back to inline `node server.js` because the file path check is wrong
357
+- leaving port values inconsistent across PM2, Nginx, and `.smb/config.toml`
358
+- forgetting to set `frontend_app_id` on the deploy target once app-level deployment tracking is available
359
- treating browser CORS failures as real internet outages
.agents/skills/smbcloud-deploy-rsync/SKILL.md
+12
@@ -48,6 +48,15 @@ When implementing in Rust, preserve these semantics explicitly. Do not assume pa
48
49
Generic rsync deploy uses smbCloud config and runner metadata.
50
51
+Keep the deploy model straight:
52
+
53
+- `Project` = umbrella workspace in smbCloud
54
+- `DeployRepo` = the git/source repository inside that workspace
55
+- deploy target entry in `.smb/config.toml` = the deployable app unit
56
+- deployment record = one release event for that app
57
+
58
+The CLI config still uses `[project]` / `[[projects]]` entries for deploy targets during the migration, but they should be thought of as app targets, not umbrella workspaces.
59
+
60
Expect these responsibilities:
61
62
- get the logged-in smbCloud user
@@ -55,6 +64,7 @@ Expect these responsibilities:
64
- determine the runner host, usually through `runner.rsync_host()`
65
- build the remote destination from configured project `path`
66
- deploy the current source tree or configured source directory
67
+- when present, pass `frontend_app_id` into deployment tracking payloads so the API can attribute the deploy to the correct app inside a repo or monorepo
68
69
The user-visible output may still mention the SSH key path. Preserve useful diagnostics, but avoid misleading claims about git deploy.
70
@@ -127,6 +137,7 @@ If replacing subprocess `rsync`, confirm parity for:
137
- deletion behavior, if smbCloud depends on it
138
- stdout/stderr capture
139
- non-zero status mapping
140
+- deploy tracking still posts against the workspace `project_id` while carrying the optional `frontend_app_id` for app-level attribution
141
142
## Validation
143
@@ -156,3 +167,4 @@ If it migrates to the embedding crate, validate equivalent output and error hand
167
- assuming the embedding crate automatically supports ssh remote syntax without checking
168
- hiding transport stderr that the user needs to debug deploy failures
169
- hardcoding local paths instead of using smbCloud config and user-derived SSH identity
170
+- treating the workspace `project.id` as the deployable app identity once app-level deploy tracking is available
crates/cli/src/deploy/process_deploy.rs
+6
@@ -215,9 +215,12 @@ async fn git_deploy(
215
Err(_) => return Err(anyhow!("Cannot resolve main branch.")),
216
};
217
218
+ let frontend_app_id = config.project.frontend_app_id.clone();
219
+
220
let payload = DeploymentPayload {
221
commit_hash: commit_hash.to_string(),
222
status: DeploymentStatus::Started,
223
+ frontend_app_id: frontend_app_id.clone(),
224
};
225
226
let created_deployment =
@@ -254,6 +257,7 @@ async fn git_deploy(
257
let access_token_for_update_cb = update_access_token.clone();
258
let project_id_for_update_cb = update_project_id;
259
let deployment_id_for_update_cb = update_deployment_id;
260
+ let frontend_app_id_for_update_cb = frontend_app_id.clone();
261
262
move |_refname, status_message| {
263
if let Some(e) = status_message {
@@ -267,6 +271,7 @@ async fn git_deploy(
271
let update_payload = DeploymentPayload {
272
commit_hash: commit_hash.to_string(),
273
status: DeploymentStatus::Failed,
274
+ frontend_app_id: frontend_app_id_for_update_cb.clone(),
275
};
276
277
// We are in a sync callback, so we need to block on the async task.
@@ -307,6 +312,7 @@ async fn git_deploy(
312
let update_payload = DeploymentPayload {
313
commit_hash: commit_hash.to_string(),
314
status: DeploymentStatus::Done,
315
+ frontend_app_id: frontend_app_id.clone(),
316
};
317
let result = update(
318
env,
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+3
@@ -164,6 +164,7 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
164
DeploymentPayload {
165
commit_hash: deploy_ref.clone(),
166
status: DeploymentStatus::Started,
167
+ frontend_app_id: config.project.frontend_app_id.clone(),
168
},
169
)
170
.await
@@ -463,6 +464,7 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
464
DeploymentPayload {
465
commit_hash: deploy_ref,
466
status: DeploymentStatus::Done,
467
+ frontend_app_id: config.project.frontend_app_id.clone(),
468
},
469
)
470
.await
@@ -498,6 +500,7 @@ async fn mark_failed(
500
DeploymentPayload {
501
commit_hash: deploy_ref.to_owned(),
502
status: DeploymentStatus::Failed,
503
+ frontend_app_id: config.project.frontend_app_id.clone(),
504
},
505
)
506
.await;
crates/cli/src/deploy/process_deploy_rails.rs
+3
@@ -197,6 +197,7 @@ source ~/.bashrc 2>/dev/null || true
197
DeploymentPayload {
198
commit_hash: deploy_ref.clone(),
199
status: DeploymentStatus::Started,
200
+ frontend_app_id: config.project.frontend_app_id.clone(),
201
},
202
)
203
.await
@@ -312,6 +313,7 @@ source ~/.bashrc 2>/dev/null || true
313
DeploymentPayload {
314
commit_hash: deploy_ref,
315
status: DeploymentStatus::Done,
316
+ frontend_app_id: config.project.frontend_app_id.clone(),
317
},
318
)
319
.await
@@ -385,6 +387,7 @@ async fn mark_failed(
387
DeploymentPayload {
388
commit_hash: deploy_ref.to_owned(),
389
status: DeploymentStatus::Failed,
390
+ frontend_app_id: config.project.frontend_app_id.clone(),
391
},
392
)
393
.await;
crates/cli/src/deploy/process_deploy_rust.rs
+3
@@ -72,6 +72,7 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
72
DeploymentPayload {
73
commit_hash: deploy_ref.clone(),
74
status: DeploymentStatus::Started,
75
+ frontend_app_id: config.project.frontend_app_id.clone(),
76
},
77
)
78
.await
@@ -233,6 +234,7 @@ echo \"Done.\"
234
DeploymentPayload {
235
commit_hash: deploy_ref,
236
status: DeploymentStatus::Done,
237
+ frontend_app_id: config.project.frontend_app_id.clone(),
238
},
239
)
240
.await
@@ -303,6 +305,7 @@ async fn mark_failed(
305
DeploymentPayload {
306
commit_hash: deploy_ref.to_owned(),
307
status: DeploymentStatus::Failed,
308
+ frontend_app_id: config.project.frontend_app_id.clone(),
309
},
310
)
311
.await;
crates/cli/src/deploy/process_deploy_vite_spa.rs
+3
@@ -92,6 +92,7 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
92
let start_payload = DeploymentPayload {
93
commit_hash: deploy_ref.clone(),
94
status: DeploymentStatus::Started,
95
+ frontend_app_id: config.project.frontend_app_id.clone(),
96
};
97
98
let created_deployment = create_deployment(
@@ -122,6 +123,7 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
123
let failed_payload = DeploymentPayload {
124
commit_hash: deploy_ref.clone(),
125
status: DeploymentStatus::Failed,
126
+ frontend_app_id: config.project.frontend_app_id.clone(),
127
};
128
let _ = update(
129
env,
@@ -143,6 +145,7 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
145
let done_payload = DeploymentPayload {
146
commit_hash: deploy_ref,
147
status: DeploymentStatus::Done,
148
+ frontend_app_id: config.project.frontend_app_id.clone(),
149
};
150
match update(
151
env,
crates/cli/src/deploy/setup_create_new_project.rs
+9
-3
@@ -1,5 +1,8 @@
1
use {
2
- crate::{client, token::get_smb_token::get_smb_token},
2
+ crate::{
3
+ client, project::deploy_target::ensure_default_frontend_app_for_project,
4
+ token::get_smb_token::get_smb_token,
5
+ },
6
dialoguer::{console::Term, theme::ColorfulTheme, Input, Select},
7
regex::Regex,
8
smbcloud_model::{
@@ -101,7 +104,7 @@ pub(crate) async fn create_new_project(
104
match create_project(
105
env,
106
client(),
104
- access_token,
107
+ access_token.clone(),
108
ProjectCreate {
109
name,
110
runner,
@@ -112,7 +115,10 @@ pub(crate) async fn create_new_project(
115
)
116
.await
117
{
115
- Ok(project) => Ok(project),
118
+ Ok(project) => {
119
+ let _ = ensure_default_frontend_app_for_project(env, &access_token, &project).await;
120
+ Ok(project)
121
+ }
122
Err(e) => Err(e),
123
}
124
}
crates/cli/src/deploy/setup_project.rs
+28
-4
@@ -4,6 +4,10 @@ use {
4
deploy::{
5
setup_create_new_project::create_new_project, setup_select_project::select_project,
6
},
7
+ project::deploy_target::{
8
+ ensure_default_frontend_app_for_project, merge_project_with_frontend_app,
9
+ resolve_frontend_app_for_project,
10
+ },
11
token::get_smb_token::get_smb_token,
12
ui::highlight,
13
},
@@ -58,18 +62,38 @@ pub(crate) async fn setup_project(
62
63
let projects = get_projects(env, client(), access_token.to_string()).await?;
64
61
- let project: Project = if !projects.is_empty() {
65
+ let workspace_project: Project = if !projects.is_empty() {
66
select_project(env, projects, &path_str).await?
67
} else {
68
create_new_project(env, &path_str).await?
69
};
70
67
- let name = project.name.clone();
68
- let description = project.description.clone();
71
+ let deploy_target =
72
+ match resolve_frontend_app_for_project(env, &access_token, &workspace_project, true).await?
73
+ {
74
+ Some(frontend_app) => {
75
+ merge_project_with_frontend_app(&workspace_project, &frontend_app)
76
+ }
77
+ None => match ensure_default_frontend_app_for_project(
78
+ env,
79
+ &access_token,
80
+ &workspace_project,
81
+ )
82
+ .await
83
+ {
84
+ Ok(frontend_app) => {
85
+ merge_project_with_frontend_app(&workspace_project, &frontend_app)
86
+ }
87
+ Err(_) => workspace_project.clone(),
88
+ },
89
+ };
90
+
91
+ let name = workspace_project.name.clone();
92
+ let description = workspace_project.description.clone();
93
94
// Create config struct
95
let config = Config {
72
- project,
96
+ project: deploy_target,
97
name,
98
description,
99
projects: None,
crates/cli/src/project/crud_create.rs
+47
-37
@@ -3,18 +3,22 @@ use crate::token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in};
3
use crate::{
4
account::login::process_login,
5
cli::CommandResult,
6
+ project::deploy_target::{
7
+ ensure_default_frontend_app_for_project, merge_project_with_frontend_app,
8
+ resolve_frontend_app_for_project,
9
+ },
10
ui::{fail_message, succeed_message, succeed_symbol},
11
};
12
use anyhow::{anyhow, Result};
9
-use chrono::Utc;
13
use console::style;
14
use dialoguer::console::Term;
15
use dialoguer::Select;
16
use dialoguer::{theme::ColorfulTheme, Input};
14
-use smbcloud_model::project::ProjectCreate;
17
+use smbcloud_model::project::{Project, ProjectCreate};
18
use smbcloud_model::runner::Runner;
19
use smbcloud_network::environment::Environment;
20
use smbcloud_networking_project::crud_project_create::create_project;
21
+use smbcloud_utils::config::Config as DeployConfig;
22
use spinners::Spinner;
23
24
pub async fn process_project_init(
@@ -69,10 +73,6 @@ pub async fn process_project_init(
73
}
74
};
75
72
- if should_init_project {
73
- setup_smb_folder(&project_name, &description, runner).await?;
74
- }
75
-
76
let spinner = Spinner::new(
77
spinners::Spinners::SimpleDotsScrolling,
78
style("Creating a project...").green().bold().to_string(),
@@ -82,7 +82,7 @@ pub async fn process_project_init(
82
match create_project(
83
env,
84
client(),
85
- access_token,
85
+ access_token.clone(),
86
ProjectCreate {
87
name: project_name.clone(),
88
runner,
@@ -93,11 +93,18 @@ pub async fn process_project_init(
93
)
94
.await
95
{
96
- Ok(_) => Ok(CommandResult {
97
- spinner,
98
- symbol: succeed_symbol(),
99
- msg: succeed_message(&format!("{project_name} has been created.")),
100
- }),
96
+ Ok(project) => {
97
+ if should_init_project {
98
+ let _ = ensure_default_frontend_app_for_project(env, &access_token, &project).await;
99
+ write_smb_config(env, &access_token, project.clone()).await?;
100
+ }
101
+
102
+ Ok(CommandResult {
103
+ spinner,
104
+ symbol: succeed_symbol(),
105
+ msg: succeed_message(&format!("{project_name} has been created.")),
106
+ })
107
+ }
108
Err(e) => {
109
println!("Error: {e:#?}");
110
Err(anyhow!(fail_message("Failed to create project.")))
@@ -105,30 +112,33 @@ pub async fn process_project_init(
112
}
113
}
114
108
-async fn setup_smb_folder(name: &str, description: &str, runner: Runner) -> Result<()> {
109
- // Create .smb folder in the current directory
110
- std::fs::create_dir(".smb")?;
111
- // Create config.toml file in the .smb folder
112
- let repository_name = name.to_lowercase().replace(" ", "");
113
- let now = Utc::now().to_rfc3339();
114
- std::fs::write(
115
- ".smb/config.toml",
116
- format!(
117
- r#"
118
-name = "{name}"
119
-description = "{description}"
120
-[project]
121
-id = 1
122
-name = "{repository_name}"
123
-runner = {runner}
124
-deployment_method = 0
125
-repository = "{repository_name}"
126
-description = "{description}"
127
-created_at = "{now}"
128
-updated_at = "{now}"
129
-"#,
130
- runner = runner as u8,
131
- ),
132
- )?;
115
+async fn write_smb_config(
116
+ env: Environment,
117
+ access_token: &str,
118
+ workspace_project: Project,
119
+) -> Result<()> {
120
+ let deploy_target = match resolve_frontend_app_for_project(
121
+ env,
122
+ access_token,
123
+ &workspace_project,
124
+ false,
125
+ )
126
+ .await
127
+ {
128
+ Ok(Some(frontend_app)) => {
129
+ merge_project_with_frontend_app(&workspace_project, &frontend_app)
130
+ }
131
+ _ => workspace_project.clone(),
132
+ };
133
+
134
+ let config = DeployConfig {
135
+ name: workspace_project.name.clone(),
136
+ description: workspace_project.description.clone(),
137
+ project: deploy_target,
138
+ projects: None,
139
+ };
140
+
141
+ std::fs::create_dir_all(".smb")?;
142
+ std::fs::write(".smb/config.toml", toml::to_string(&config)?)?;
143
Ok(())
144
}
crates/cli/src/project/crud_read.rs
+4
-2
@@ -4,6 +4,7 @@ use crate::client;
4
use crate::token::get_smb_token::get_smb_token;
5
use crate::{
6
cli::CommandResult,
7
+ project::deploy_target::resolve_frontend_app_for_project,
8
ui::{
9
fail_message, fail_symbol, project_detail_view::show_project_detail_tui,
10
project_table::show_projects_tui, succeed_message, succeed_symbol,
@@ -91,11 +92,12 @@ pub(crate) fn show_project_detail(project: &Project) -> Result<()> {
92
93
pub(crate) async fn process_project_use(env: Environment, id: String) -> Result<CommandResult> {
94
let access_token = get_smb_token(env)?;
94
- let project = get_project(env, client(), access_token, id).await?;
95
+ let project = get_project(env, client(), access_token.clone(), id).await?;
96
+ let frontend_app = resolve_frontend_app_for_project(env, &access_token, &project, true).await?;
97
98
let config = Config {
99
current_project: Some(project),
98
- current_frontend_app: None,
100
+ current_frontend_app: frontend_app,
101
current_auth_app: None,
102
};
103
crates/cli/src/project/deploy_target.rs
new
+104
@@ -0,0 +1,104 @@
1
+use {
2
+ crate::client,
3
+ dialoguer::{console::Term, theme::ColorfulTheme, Select},
4
+ smbcloud_model::{
5
+ frontend_app::{AppType, FrontendApp, FrontendAppCreate},
6
+ project::Project,
7
+ },
8
+ smbcloud_network::environment::Environment,
9
+ smbcloud_networking_project::{
10
+ crud_frontend_app_create::create_frontend_app,
11
+ crud_frontend_app_read::get_frontend_apps_by_project,
12
+ },
13
+};
14
+
15
+pub async fn resolve_frontend_app_for_project(
16
+ env: Environment,
17
+ access_token: &str,
18
+ project: &Project,
19
+ interactive: bool,
20
+) -> Result<Option<FrontendApp>, smbcloud_model::error_codes::ErrorResponse> {
21
+ let frontend_apps =
22
+ get_frontend_apps_by_project(env, client(), access_token.to_string(), project.id).await?;
23
+
24
+ if frontend_apps.is_empty() {
25
+ return Ok(None);
26
+ }
27
+
28
+ if frontend_apps.len() == 1 || !interactive {
29
+ return Ok(frontend_apps.into_iter().next());
30
+ }
31
+
32
+ let labels: Vec<String> = frontend_apps
33
+ .iter()
34
+ .map(|frontend_app| {
35
+ let source_path = frontend_app.source_path.as_deref().unwrap_or(".");
36
+ let repo_name = frontend_app
37
+ .deploy_repo
38
+ .as_ref()
39
+ .map(|deploy_repo| deploy_repo.repository.as_str())
40
+ .or(frontend_app.repository.as_deref())
41
+ .unwrap_or("no-repo");
42
+
43
+ format!("{} [{}] {}", frontend_app.name, repo_name, source_path)
44
+ })
45
+ .collect();
46
+
47
+ let selection = Select::with_theme(&ColorfulTheme::default())
48
+ .with_prompt("Select app target")
49
+ .items(&labels)
50
+ .default(0)
51
+ .interact_on_opt(&Term::stderr())
52
+ .ok()
53
+ .flatten();
54
+
55
+ Ok(selection.map(|index| frontend_apps[index].clone()))
56
+}
57
+
58
+pub async fn ensure_default_frontend_app_for_project(
59
+ env: Environment,
60
+ access_token: &str,
61
+ project: &Project,
62
+) -> Result<FrontendApp, smbcloud_model::error_codes::ErrorResponse> {
63
+ create_frontend_app(
64
+ env,
65
+ client(),
66
+ access_token.to_string(),
67
+ FrontendAppCreate {
68
+ name: project.name.clone(),
69
+ project_id: project.id,
70
+ app_type: AppType::Web,
71
+ runner: project.runner,
72
+ deployment_method: project.deployment_method,
73
+ repository: project.repository.clone(),
74
+ description: project.description.clone(),
75
+ },
76
+ )
77
+ .await
78
+}
79
+
80
+pub fn merge_project_with_frontend_app(project: &Project, frontend_app: &FrontendApp) -> Project {
81
+ let mut deploy_target = project.clone();
82
+ deploy_target.name = frontend_app.name.clone();
83
+ deploy_target.description = frontend_app
84
+ .description
85
+ .clone()
86
+ .or_else(|| project.description.clone());
87
+ deploy_target.runner = frontend_app.runner;
88
+ deploy_target.deployment_method = frontend_app.deployment_method;
89
+ deploy_target.repository = frontend_app
90
+ .deploy_repo
91
+ .as_ref()
92
+ .map(|deploy_repo| deploy_repo.repository.clone())
93
+ .or_else(|| frontend_app.repository.clone())
94
+ .or_else(|| project.repository.clone());
95
+ deploy_target.frontend_app_id = Some(frontend_app.id.clone());
96
+ deploy_target.deploy_repo_id = frontend_app.deploy_repo_id;
97
+ deploy_target.source_path = frontend_app.source_path.clone();
98
+
99
+ if deploy_target.source.is_none() {
100
+ deploy_target.source = frontend_app.source_path.clone();
101
+ }
102
+
103
+ deploy_target
104
+}
crates/cli/src/project/mod.rs
+1
@@ -3,5 +3,6 @@ pub mod crud_create;
3
pub mod crud_delete;
4
pub mod crud_read;
5
pub mod crud_update;
6
+pub mod deploy_target;
7
mod deployment;
8
pub mod process;
crates/smbcloud-model/src/frontend_app.rs
+24
-3
@@ -34,9 +34,27 @@ impl Display for AppType {
34
35
/// A deployable frontend application on the smbCloud platform.
36
///
37
-/// Replaces the legacy `Project` as the primary unit of deployment.
38
-/// A `FrontendApp` belongs to a `Tenant` directly and is associated with an
39
-/// owner workspace (Project). It can be shared across multiple workspaces.
37
+/// A `FrontendApp` is the unit that actually ships.
38
+///
39
+/// - `Project` is the umbrella workspace
40
+/// - `DeployRepo` is the git repository or monorepo root
41
+/// - `FrontendApp` is the deployable app inside that repo
42
+///
43
+/// A `FrontendApp` belongs to a `Tenant`, is associated with an owner workspace,
44
+/// and may optionally point at a `DeployRepo` plus a repo-relative `source_path`
45
+/// for monorepo deployments.
46
+#[derive(Deserialize, Serialize, Debug, Clone)]
47
+#[tsync]
48
+pub struct DeployRepo {
49
+ pub id: i64,
50
+ pub name: String,
51
+ pub repository: String,
52
+ pub root_path: String,
53
+ pub repo_kind: Option<String>,
54
+ pub runner: Option<String>,
55
+ pub deployment_method: Option<String>,
56
+}
57
+
58
#[derive(Deserialize, Serialize, Debug, Clone)]
59
#[tsync]
60
pub struct FrontendApp {
@@ -50,6 +68,9 @@ pub struct FrontendApp {
68
pub tenant_id: i32,
69
pub repository: Option<String>,
70
pub description: Option<String>,
71
+ pub deploy_repo_id: Option<i64>,
72
+ pub source_path: Option<String>,
73
+ pub deploy_repo: Option<DeployRepo>,
74
pub project_ids: Vec<i32>,
75
76
// ── CLI-local deployment config fields ───────────────────────────────────
crates/smbcloud-model/src/project.rs
+12
@@ -44,6 +44,7 @@ pub struct Config {
44
#[derive(Deserialize, Serialize, Debug, Clone)]
45
#[tsync]
46
pub struct Project {
47
+ /// Umbrella smbCloud workspace ID.
48
pub id: i32,
49
pub name: String,
50
pub runner: Runner,
@@ -53,6 +54,14 @@ pub struct Project {
54
pub path: Option<String>,
55
pub repository: Option<String>,
56
pub description: Option<String>,
57
+ /// Deployable app ID for precise deployment tracking. Optional during the
58
+ /// migration away from project-as-app semantics.
59
+ pub frontend_app_id: Option<String>,
60
+ /// Repo ID backing this deploy target. Optional until the API exposes it
61
+ /// consistently to the CLI.
62
+ pub deploy_repo_id: Option<i64>,
63
+ /// Repo-relative app path for monorepo targets, e.g. "apps/web/console".
64
+ pub source_path: Option<String>,
65
pub created_at: DateTime<Utc>,
66
pub updated_at: DateTime<Utc>,
67
/// Deployment kind, e.g. "vite-spa". Absent for server-side runners.
@@ -101,6 +110,8 @@ pub struct ProjectCreate {
110
pub struct Deployment {
111
pub id: i32,
112
pub project_id: i32,
113
+ pub frontend_app_id: Option<String>,
114
+ pub frontend_app_name: Option<String>,
115
pub commit_hash: String,
116
pub status: DeploymentStatus,
117
#[serde(with = "ar_date_format")]
@@ -113,6 +124,7 @@ pub struct Deployment {
124
pub struct DeploymentPayload {
125
pub commit_hash: String,
126
pub status: DeploymentStatus,
127
+ pub frontend_app_id: Option<String>,
128
}
129
130
#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy)] // Added Clone, Copy
crates/smbcloud-networking-project/src/crud_frontend_app_create.rs
new
+24
@@ -0,0 +1,24 @@
1
+use {
2
+ crate::url_builder::build_frontend_apps_url,
3
+ reqwest::Client,
4
+ smbcloud_model::{
5
+ error_codes::ErrorResponse,
6
+ frontend_app::{FrontendApp, FrontendAppCreate},
7
+ },
8
+ smbcloud_network::{environment::Environment, network::request},
9
+ smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient},
10
+};
11
+
12
+pub async fn create_frontend_app(
13
+ env: Environment,
14
+ client: (&SmbClient, &str),
15
+ access_token: String,
16
+ payload: FrontendAppCreate,
17
+) -> Result<FrontendApp, ErrorResponse> {
18
+ let builder = Client::new()
19
+ .post(build_frontend_apps_url(env, client))
20
+ .json(&payload)
21
+ .header("Authorization", access_token)
22
+ .header("User-agent", SMB_USER_AGENT);
23
+ request(builder).await
24
+}
crates/smbcloud-networking-project/src/lib.rs
+1
@@ -1,3 +1,4 @@
1
+pub mod crud_frontend_app_create;
2
pub mod crud_frontend_app_read;
3
pub mod crud_project_create;
4
pub mod crud_project_delete;