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;