1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
//! `tonin service ...` — service lifecycle.
use std::path::PathBuf;
use anyhow::{Result, bail};
use clap::{Subcommand, ValueEnum};
use super::new;
#[derive(Subcommand)]
pub enum ServiceCmd {
/// Scaffold a new service.
///
/// Creates `./<name>/` with a three-crate Cargo workspace by default
/// (Rust: `<name>-proto` + `<name>-server` + `<name>-rs`), pre-generated
/// `k8s/` manifests, CLAUDE.md, AGENTS.md, and .gitignore for each crate.
/// Python and TypeScript use their own layouts (`server/` package and
/// Vite SPA / Next.js BFF respectively). Use `--flat` to opt out of the
/// workspace layout for Rust.
///
/// Idempotency: refuses to overwrite an existing `./<name>/`. Delete
/// it (or pick a different name) before re-running.
#[command(after_long_help = "PREREQUISITES:
- Write access to the current directory
- For `--lang rust`, a Rust toolchain in PATH (used for cargo new under
the hood)
- For `--lang python`, no system deps required at scaffold time; the
generated service brings its own pyproject.toml
- For `--lang ts`, no system deps required at scaffold time; the
generated service brings its own package.json
SIDE EFFECTS:
- Creates ./<name>/ with the full service tree
- May prompt to add the new crate to a parent Cargo workspace if it
detects one (skip with --no-workspace)
EXAMPLES:
# The minimum — a Rust backend service
tonin service new greeter
# Python backend service with a Rust client SDK alongside
tonin service new notifier --lang python --client-lang rust
# TypeScript SPA frontend
tonin service new dashboard --lang ts --type web --web-mode spa
# Next.js BFF that proxies to backend services
tonin service new web-bff --lang ts --type web --web-mode bff
# Backend with two background jobs and S3 storage
tonin service new orders --with-job reconcile --with-job dunning --with-storage s3
# Rust server that also emits Python + TS client packages
tonin service new greeter --lang rust --client-lang python --client-lang ts
SEE ALSO:
docs/03-grpc-service.md, docs/15-multi-language.md")]
New {
name: String,
/// Target language. All scaffolds are async-by-default.
#[arg(long, value_enum, default_value_t = Lang::Rust)]
lang: Lang,
/// Service shape. Only meaningful for `--lang ts`.
#[arg(
long,
value_enum,
long_help = "Service shape. Only meaningful for `--lang ts`:
--type web Vite SPA OR Next.js BFF (see --web-mode) — default for ts
--type backend Node + ConnectRPC service
For --lang rust and --lang python, `backend` is the only sensible value
and is applied automatically."
)]
r#type: Option<ServiceType>,
/// Web template shape. Only valid with `--lang ts --type web`.
#[arg(
long,
value_enum,
long_help = "Web template shape. Only valid with `--lang ts --type web`:
--web-mode spa Vite + React; pure client-side; served as static files
by nginx. Default for --type web.
--web-mode bff Next.js (Backend-for-Frontend); the Node server proxies
and aggregates declared backend services."
)]
web_mode: Option<WebMode>,
/// Skip the interactive workspace prompt (treat as 'no').
#[arg(long)]
no_workspace: bool,
/// Custom template repository. Examples:
/// github.com/Rushit/tonin-templates-flat
/// github.com/myorg/my-templates
/// Defaults to github.com/Rushit/tonin-templates (standard variant)
#[arg(long)]
template_repo: Option<String>,
/// Scaffold a flat single-directory layout instead of the default
/// three-crate workspace (proto + server + rs). Only valid with
/// --lang rust. Use when embedding the service inside an existing
/// workspace that manages its own crate split.
#[arg(long)]
flat: bool,
/// Add a background-job binary. Repeatable. Each job gets its
/// own `src/bin/<name>.rs` entry point that bootstraps via
/// `tonin::job::bootstrap(...)` — telemetry + service-identity
/// auth + State, no gRPC server. Rust scaffolds only.
#[arg(long = "with-job", value_name = "NAME")]
with_jobs: Vec<String>,
/// Wire object storage into State (opendal-backed).
#[arg(
long = "with-storage",
value_enum,
long_help = "Wire object storage into State (opendal-backed). The chosen
backend determines feature flags and env-var contract:
--with-storage s3 STORAGE_BUCKET, STORAGE_REGION,
STORAGE_ENDPOINT (optional),
STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY
--with-storage gcs STORAGE_BUCKET, STORAGE_CREDENTIAL_PATH
--with-storage azure STORAGE_CONTAINER, STORAGE_ACCOUNT,
STORAGE_ACCESS_KEY
--with-storage local STORAGE_ROOT (path on disk)
The framework runs a LIST-limit-1 probe at boot; if it fails, the
service refuses to start."
)]
with_storage: Option<StorageKind>,
/// Emit additional client SDKs as sibling folders alongside the
/// primary one. Repeatable. The server's own native client is
/// always emitted (determined by `--lang`); these are extras.
/// Each gets its own package: `client-python/` or `client-ts/`.
///
/// Example — a Rust server with both Rust and Python SDKs:
/// tonin service new greeter --lang rust --client-lang python
#[arg(long = "client-lang", value_enum)]
client_langs: Vec<ClientLang>,
},
/// Run the service (and its MCP sidecar) locally via docker-compose.
///
/// **Stub today** — full implementation lands when the local dev-loop
/// design ships (see docs/00-overview.md). Use `cargo run -p <service>`
/// in the meantime for the Rust path.
#[command(after_long_help = "PREREQUISITES (when implemented):
- docker + docker compose on PATH
- tonin.toml at --path
TODAY:
Prints a stub message. For Rust services, use:
cargo run -p <service>")]
Run {
#[arg(long, default_value = ".")]
path: PathBuf,
},
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum Lang {
Rust,
Python,
Ts,
}
#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq)]
pub enum ServiceType {
Backend,
Web,
}
#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq, Hash)]
pub enum ClientLang {
/// Generate a Rust client crate (client-rust/).
Rust,
/// Generate a Python client package (client-python/).
Python,
/// Generate a TypeScript client package (client-ts/).
Ts,
}
impl ClientLang {
pub fn as_str(&self) -> &'static str {
match self {
ClientLang::Rust => "rust",
ClientLang::Python => "python",
ClientLang::Ts => "ts",
}
}
/// Which `Lang` is the implicit client for a given server language?
/// Used to dedupe `--client-lang` against the server's own client.
pub fn matches_server_lang(self, lang: Lang) -> bool {
matches!(
(self, lang),
(ClientLang::Rust, Lang::Rust)
| (ClientLang::Python, Lang::Python)
| (ClientLang::Ts, Lang::Ts)
)
}
}
#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq)]
pub enum WebMode {
/// Vite + React, served as static files by nginx.
Spa,
/// Next.js Backend-for-Frontend. Server proxies/aggregates declared backends.
Bff,
}
#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq)]
pub enum StorageKind {
/// AWS S3 or any S3-compatible (MinIO, R2, Wasabi).
S3,
/// Google Cloud Storage.
Gcs,
/// Azure Blob Storage.
Azure,
/// Local filesystem — useful for dev / single-node.
Local,
}
impl StorageKind {
pub fn as_str(&self) -> &'static str {
match self {
StorageKind::S3 => "s3",
StorageKind::Gcs => "gcs",
StorageKind::Azure => "azure",
StorageKind::Local => "local",
}
}
/// opendal Cargo feature name that backs this kind.
pub fn opendal_feature(&self) -> &'static str {
match self {
StorageKind::S3 => "services-s3",
StorageKind::Gcs => "services-gcs",
StorageKind::Azure => "services-azblob",
StorageKind::Local => "services-fs",
}
}
}
impl Lang {
pub fn as_str(&self) -> &'static str {
match self {
Lang::Rust => "rust",
Lang::Python => "python",
Lang::Ts => "ts",
}
}
pub fn default_type(&self) -> ServiceType {
match self {
Lang::Ts => ServiceType::Web,
_ => ServiceType::Backend,
}
}
}
impl ServiceType {
pub fn as_str(&self) -> &'static str {
match self {
ServiceType::Backend => "backend",
ServiceType::Web => "web",
}
}
}
impl WebMode {
pub fn as_str(&self) -> &'static str {
match self {
WebMode::Spa => "spa",
WebMode::Bff => "bff",
}
}
}
pub fn run(cmd: ServiceCmd) -> Result<()> {
match cmd {
ServiceCmd::New {
name,
lang,
r#type,
web_mode,
no_workspace,
template_repo,
flat,
with_jobs,
with_storage,
client_langs,
} => {
let st = r#type.unwrap_or_else(|| lang.default_type());
if st == ServiceType::Web && !matches!(lang, Lang::Ts) {
bail!("--type web is only supported with --lang ts");
}
if web_mode.is_some() && !(st == ServiceType::Web && matches!(lang, Lang::Ts)) {
bail!("--web-mode is only valid with --lang ts --type web");
}
if !with_jobs.is_empty() && !matches!(lang, Lang::Rust | Lang::Python) {
bail!("--with-job is supported only for --lang rust|python");
}
if with_storage.is_some() && !matches!(lang, Lang::Rust | Lang::Python) {
bail!("--with-storage is supported only for --lang rust|python");
}
if flat && !matches!(lang, Lang::Rust) {
bail!("--flat is only supported for --lang rust");
}
// Default web mode is SPA.
let wm = if st == ServiceType::Web {
Some(web_mode.unwrap_or(WebMode::Spa))
} else {
None
};
// Dedupe --client-lang: skip any entry that matches the
// server's own implicit client. Keep order, preserve first.
let mut extras: Vec<ClientLang> = Vec::new();
for cl in client_langs {
if cl.matches_server_lang(lang) {
continue;
}
if !extras.contains(&cl) {
extras.push(cl);
}
}
new::run(
&name,
lang,
st,
wm,
no_workspace,
template_repo.as_deref(),
flat,
&with_jobs,
with_storage,
&extras,
)
}
ServiceCmd::Run { path } => {
eprintln!("tonin service run: not yet implemented");
eprintln!("path = {}", path.display());
Ok(())
}
}
}