@smbcloud / cli / commits / 21ae2c4

Add Onde app and model management API to Gresiq SDK

- Implement OndeApp and OndeModel types - Add list_apps, create_app, assign_model, and list_models functions - Expose new API from crate root - Clarify PM2 ecosystem file usage in Next.js deploy skill doc - Minor formatting fixes in Gresiq SDK Cargo.toml

keypair34 committed Apr 18, 2026 at 14:14 UTC 21ae2c437ef203a4769f1220581ac73f6cd372ae
4 files changed +231 -12
.agents/skills/smbcloud-deploy-nextjs/SKILL.md
+48 -7
@@ -90,9 +90,7 @@ Note that `server.js` is expected at the remote root because the CLI uploads the
90
91 ## PM2 behavior
92
93 -The current SSR deploy implementation does not use `ecosystem.config.js`.
94 -
95 -It runs logic equivalent to:
93 +The CLI's deploy path runs logic equivalent to:
94
95 ```sh
96 cd "$APP_PATH"
@@ -109,9 +107,52 @@ pm2 save
107 This has two consequences:
108
109 - 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
110 +- the port is currently hardcoded to `3010` in the CLI for fresh starts unless patched
111 +
112 +### Environment variables — ecosystem file (standard pattern)
113 +
114 +The server runs in a **multi-tenant setup**: multiple Next.js apps share one server, each managed as a separate PM2 process. The standard way to manage per-app environment variables is an `ecosystem.config.js` file kept **on the server only** (never committed to the app repo).
115 +
116 +The flow works like this:
117 +
118 +1. On first setup, SSH in, create the ecosystem file, start with it, and save:
119 +
120 + ```sh
121 + ssh git@<server>
122 + cd /home/git/apps/web/<app>
123 +
124 + cat > ecosystem.config.js << 'EOF'
125 + module.exports = {
126 + apps: [{
127 + name: "<pm2_app>",
128 + script: "server.js",
129 + cwd: "/home/git/apps/web/<app>",
130 + env_production: {
131 + NODE_ENV: "production",
132 + PORT: 3026,
133 + HOSTNAME: "127.0.0.1",
134 + MY_SECRET: "...",
135 + }
136 + }]
137 + }
138 + EOF
139 +
140 + pm2 start ecosystem.config.js --env production
141 + pm2 save
142 + ```
143 +
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**.
145 +
146 +3. To change or add env vars, SSH in, edit `ecosystem.config.js`, then:
147 +
148 + ```sh
149 + pm2 restart <pm2_app> --update-env
150 + pm2 save
151 + ```
152 +
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.
154
114 -### Manual PM2 operations
155 +### Manual PM2 operations (without ecosystem file)
156
157 If the app is already deployed and `server.js` exists:
158
@@ -121,7 +162,7 @@ pm2 restart <pm2_app> --update-env
162 pm2 save
163 ```
164
124 -If starting from scratch:
165 +If starting from scratch without an ecosystem file:
166
167 ```sh
168 cd /home/git/apps/web/<app>
@@ -284,6 +325,6 @@ Use the smallest checks that prove the contract.
325 - forgetting `kind = "nextjs-ssr"`
326 - keeping an old git `post-receive` hook and expecting it to manage SSR deploys
327 - using static-file Nginx config for an SSR app
287 -- trusting an old `ecosystem.config.js` when the CLI actually runs `node server.js`
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
330 - treating browser CORS failures as real internet outages
crates/smbcloud-gresiq-sdk/Cargo.toml
+5 -5
@@ -8,10 +8,10 @@ description = "Rust client for the smbCloud GresIQ REST gateway."
8 path = "src/smbcloud_gresiq_sdk.rs"
9
10 [dependencies]
11 -log = { workspace = true }
12 -reqwest = { workspace = true, features = ["json", "rustls-tls"] }
13 -serde = { workspace = true, features = ["derive"] }
11 +log = { workspace = true }
12 +reqwest = { workspace = true, features = ["json", "rustls-tls"] }
13 +serde = { workspace = true, features = ["derive"] }
14 serde_json = { workspace = true }
15 smbcloud-network = { workspace = true }
16 -thiserror = { workspace = true }
17 -tokio = { workspace = true, features = ["rt"] }
16 +thiserror = { workspace = true }
17 +tokio = { workspace = true, features = ["rt"] }
crates/smbcloud-gresiq-sdk/src/onde_apps.rs new
+176
@@ -0,0 +1,176 @@
1 +//! Apps and models management for Onde Inference.
2 +//!
3 +//! Each function opens its own `reqwest::Client`. These are low-frequency
4 +//! management calls, so there's no benefit to a shared pool.
5 +//!
6 +//! Every request needs two things: the Onde app's client credentials as
7 +//! query params, and the user's bearer token as an Authorization header.
8 +//!
9 +//! ```text
10 +//! {protocol}://{host}/v1/client/gresiq/{path}
11 +//! ?client_id={app_id}&client_secret={app_secret}
12 +//! Authorization: Bearer {access_token}
13 +//! ```
14 +
15 +use crate::error::GresiqError;
16 +use serde::{Deserialize, Serialize};
17 +use smbcloud_network::environment::Environment;
18 +
19 +/// An app registered to the user's account.
20 +#[derive(Debug, Clone, Serialize, Deserialize)]
21 +pub struct OndeApp {
22 + pub id: String,
23 + pub name: String,
24 + pub status: Option<String>,
25 + pub app_secret: Option<String>,
26 + pub current_model_id: Option<String>,
27 + pub created_at: Option<String>,
28 + pub updated_at: Option<String>,
29 +}
30 +
31 +/// A model from the Onde catalog, assignable to an [`OndeApp`].
32 +#[derive(Debug, Clone, Serialize, Deserialize)]
33 +pub struct OndeModel {
34 + pub id: String,
35 + pub name: Option<String>,
36 + pub hf_repo_id: Option<String>,
37 + pub gguf_file: Option<String>,
38 + pub family: Option<String>,
39 + pub parameter_class: Option<String>,
40 + pub format: Option<String>,
41 + pub approx_size_bytes: Option<i64>,
42 + pub description: Option<String>,
43 +}
44 +
45 +// The models endpoint wraps its array: { "models": [...] }.
46 +#[derive(Deserialize)]
47 +struct ModelsEnvelope {
48 + models: Vec<OndeModel>,
49 +}
50 +
51 +// POST /apps body shape: { "gresiq_app": { "name": "..." } }
52 +#[derive(Serialize)]
53 +struct CreateAppBody<'a> {
54 + gresiq_app: CreateAppParams<'a>,
55 +}
56 +
57 +#[derive(Serialize)]
58 +struct CreateAppParams<'a> {
59 + name: &'a str,
60 +}
61 +
62 +fn endpoint(environment: &Environment, path: &str, app_id: &str, app_secret: &str) -> String {
63 + format!(
64 + "{}://{}/v1/client/gresiq/{}?client_id={}&client_secret={}",
65 + environment.api_protocol(),
66 + environment.api_host(),
67 + path,
68 + app_id,
69 + app_secret,
70 + )
71 +}
72 +
73 +fn bearer(token: &str) -> String {
74 + format!("Bearer {token}")
75 +}
76 +
77 +// Returns the response on 2xx. On anything else, reads the body as text
78 +// before returning so callers don't have to think about it.
79 +async fn check(response: reqwest::Response) -> Result<reqwest::Response, GresiqError> {
80 + if response.status().is_success() {
81 + return Ok(response);
82 + }
83 + let status = response.status().as_u16();
84 + let message = response
85 + .text()
86 + .await
87 + .unwrap_or_else(|_| "unreadable response body".to_string());
88 + Err(GresiqError::Api { status, message })
89 +}
90 +
91 +/// Fetch all apps for the authenticated user.
92 +///
93 +/// `GET /v1/client/gresiq/apps`
94 +pub async fn list_apps(
95 + environment: &Environment,
96 + app_id: &str,
97 + app_secret: &str,
98 + access_token: &str,
99 +) -> Result<Vec<OndeApp>, GresiqError> {
100 + let url = endpoint(environment, "apps", app_id, app_secret);
101 + let response = reqwest::Client::new()
102 + .get(&url)
103 + .header("Authorization", bearer(access_token))
104 + .header("Content-Type", "application/json")
105 + .send()
106 + .await?;
107 + Ok(check(response).await?.json::<Vec<OndeApp>>().await?)
108 +}
109 +
110 +/// Create a new app under the authenticated user's account.
111 +///
112 +/// `POST /v1/client/gresiq/apps` — body: `{ "gresiq_app": { "name": "..." } }`
113 +pub async fn create_app(
114 + environment: &Environment,
115 + app_id: &str,
116 + app_secret: &str,
117 + access_token: &str,
118 + name: &str,
119 +) -> Result<OndeApp, GresiqError> {
120 + let url = endpoint(environment, "apps", app_id, app_secret);
121 + let body = CreateAppBody {
122 + gresiq_app: CreateAppParams { name },
123 + };
124 + let response = reqwest::Client::new()
125 + .post(&url)
126 + .header("Authorization", bearer(access_token))
127 + .header("Content-Type", "application/json")
128 + .json(&body)
129 + .send()
130 + .await?;
131 + Ok(check(response).await?.json::<OndeApp>().await?)
132 +}
133 +
134 +/// Assign a catalog model to an app. Creates the record if none exists yet.
135 +///
136 +/// `PATCH /v1/client/gresiq/apps/{onde_app_id}/model` — body: `{ "model_id": "..." }`
137 +pub async fn assign_model(
138 + environment: &Environment,
139 + app_id: &str,
140 + app_secret: &str,
141 + access_token: &str,
142 + onde_app_id: &str,
143 + model_id: &str,
144 +) -> Result<(), GresiqError> {
145 + let path = format!("apps/{}/model", onde_app_id);
146 + let url = endpoint(environment, &path, app_id, app_secret);
147 + let body = serde_json::json!({ "model_id": model_id });
148 + let response = reqwest::Client::new()
149 + .patch(&url)
150 + .header("Authorization", bearer(access_token))
151 + .header("Content-Type", "application/json")
152 + .json(&body)
153 + .send()
154 + .await?;
155 + check(response).await?;
156 + Ok(())
157 +}
158 +
159 +/// Fetch all models in the Onde catalog.
160 +///
161 +/// `GET /v1/client/gresiq/models` — response: `{ "models": [...] }`
162 +pub async fn list_models(
163 + environment: &Environment,
164 + app_id: &str,
165 + app_secret: &str,
166 + access_token: &str,
167 +) -> Result<Vec<OndeModel>, GresiqError> {
168 + let url = endpoint(environment, "models", app_id, app_secret);
169 + let response = reqwest::Client::new()
170 + .get(&url)
171 + .header("Authorization", bearer(access_token))
172 + .header("Content-Type", "application/json")
173 + .send()
174 + .await?;
175 + Ok(check(response).await?.json::<ModelsEnvelope>().await?.models)
176 +}
crates/smbcloud-gresiq-sdk/src/smbcloud_gresiq_sdk.rs
+2
@@ -38,8 +38,10 @@
38 mod client;
39 mod client_credentials;
40 mod error;
41 +mod onde_apps;
42
43 pub use client::GresiqClient;
44 pub use client_credentials::{base_url, GresiqCredentials};
45 pub use error::GresiqError;
46 +pub use onde_apps::{assign_model, create_app, list_apps, list_models, OndeApp, OndeModel};
47 pub use smbcloud_network::environment::Environment;