@smbcloud / cli / commits / 494af12

wip

keypair34 committed May 10, 2026 at 12:32 UTC 494af12dd49daa3f9cd452abf328c1d05b26f667
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;