ironflow_cli/commands/
init.rs1use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use clap::Args;
8use dialoguer::{Input, Select};
9
10#[derive(Debug, Args)]
12pub struct InitArgs {
13 #[arg(long)]
15 pub non_interactive: bool,
16 #[arg(long)]
18 pub name: Option<String>,
19 #[arg(long)]
21 pub force: bool,
22}
23
24const STORE_OPTIONS: &[&str] = &["in-memory", "postgres"];
26
27pub fn execute(args: &InitArgs) -> Result<()> {
34 let cwd = std::env::current_dir().context("cannot determine current directory")?;
35
36 let (project_name, store_index) = if args.non_interactive {
37 let name = args.name.clone().unwrap_or_else(|| {
38 cwd.file_name()
39 .and_then(|n| n.to_str())
40 .unwrap_or("my-ironflow-project")
41 .to_string()
42 });
43 (name, 0)
44 } else {
45 let default_name = args.name.clone().unwrap_or_else(|| {
46 cwd.file_name()
47 .and_then(|n| n.to_str())
48 .unwrap_or("my-ironflow-project")
49 .to_string()
50 });
51 let name: String = Input::new()
52 .with_prompt("Project name")
53 .default(default_name)
54 .interact_text()?;
55 let store = Select::new()
56 .with_prompt("Backend store")
57 .items(STORE_OPTIONS)
58 .default(0)
59 .interact()?;
60 (name, store)
61 };
62
63 let project_dir = cwd.join(&project_name);
64
65 if project_dir.exists() {
66 let is_empty = project_dir.read_dir()?.next().is_none();
67 if !is_empty && !args.force {
68 bail!(
69 "directory '{}' is not empty; use --force to overwrite",
70 project_dir.display()
71 );
72 }
73 }
74
75 fs::create_dir_all(&project_dir)?;
76
77 let store_name = STORE_OPTIONS[store_index];
78 let use_postgres = store_name == "postgres";
79
80 write_cargo_toml(&project_dir, &project_name, use_postgres)?;
81 write_server_main(&project_dir, &project_name)?;
82 write_worker_main(&project_dir, &project_name)?;
83 write_hello_workflow(&project_dir)?;
84
85 if use_postgres {
86 write_docker_compose(&project_dir)?;
87 }
88
89 println!(
90 "Project '{project_name}' created at {}",
91 project_dir.display()
92 );
93 println!();
94 println!("Next steps:");
95 println!(" cd {project_name}");
96 println!(" cargo build");
97 if use_postgres {
98 println!(" docker compose up -d");
99 }
100
101 Ok(())
102}
103
104fn write_cargo_toml(dir: &Path, name: &str, use_postgres: bool) -> Result<()> {
105 let store_dep = if use_postgres {
106 r#"ironflow-store = { version = "0.1", features = ["store-postgres", "secret-store"] }"#
107 } else {
108 r#"ironflow-store = { version = "0.1", features = ["store-memory", "secret-store"] }"#
109 };
110
111 let content = format!(
112 r#"[package]
113name = "{name}"
114version = "0.1.0"
115edition = "2024"
116
117[dependencies]
118ironflow-core = "0.1"
119ironflow-engine = "0.1"
120ironflow-api = "0.1"
121ironflow-worker = "0.1"
122{store_dep}
123tokio = {{ version = "1", features = ["rt-multi-thread", "macros"] }}
124anyhow = "1"
125tracing = "0.1"
126tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
127serde_json = "1"
128"#
129 );
130
131 fs::write(dir.join("Cargo.toml"), content)?;
132 Ok(())
133}
134
135fn write_server_main(dir: &Path, _name: &str) -> Result<()> {
136 let src = dir.join("src");
137 fs::create_dir_all(&src)?;
138
139 let content = r#"use std::net::SocketAddr;
140use std::sync::Arc;
141
142use anyhow::Result;
143use ironflow_api::state::AppState;
144use ironflow_api::routes::{RouterConfig, create_router};
145use ironflow_core::providers::claude::ClaudeCodeProvider;
146use ironflow_engine::engine::Engine;
147use ironflow_store::memory::InMemoryStore;
148use tokio::net::TcpListener;
149use tokio::sync::broadcast;
150use tracing_subscriber::EnvFilter;
151
152mod workflows;
153
154#[tokio::main]
155async fn main() -> Result<()> {
156 tracing_subscriber::fmt()
157 .with_env_filter(EnvFilter::from_default_env())
158 .init();
159
160 let store = Arc::new(InMemoryStore::new());
161 let provider = Arc::new(ClaudeCodeProvider::new());
162 let mut engine = Engine::new(store.clone(), provider);
163
164 engine.register(workflows::hello::HelloWorkflow)?;
165
166 let jwt_cfg = Arc::new(ironflow_auth::jwt::JwtConfig {
167 secret: "change-me-in-production".to_string(),
168 access_token_ttl_secs: 900,
169 refresh_token_ttl_secs: 604_800,
170 cookie_domain: None,
171 cookie_secure: false,
172 });
173 let (event_tx, _) = broadcast::channel(128);
174
175 let state = AppState::new(
176 store,
177 Arc::new(engine),
178 jwt_cfg,
179 "worker-token".to_string(),
180 event_tx,
181 );
182
183 let router = create_router(state, RouterConfig::default());
184 let listener = TcpListener::bind("0.0.0.0:3000").await?;
185 tracing::info!("listening on {}", listener.local_addr()?);
186
187 // The rate limiters key on the client address: without connect info
188 // they cannot see it.
189 axum::serve(
190 listener,
191 router.into_make_service_with_connect_info::<SocketAddr>(),
192 )
193 .await?;
194 Ok(())
195}
196"#;
197
198 fs::write(src.join("main.rs"), content)?;
199 Ok(())
200}
201
202fn write_worker_main(dir: &Path, _name: &str) -> Result<()> {
203 let src = dir.join("src");
204 fs::create_dir_all(dir.join("src/bin"))?;
205
206 let content = r#"use std::sync::Arc;
207
208use anyhow::Result;
209use ironflow_core::providers::claude::ClaudeCodeProvider;
210use ironflow_engine::engine::Engine;
211use ironflow_store::memory::InMemoryStore;
212use ironflow_worker::Worker;
213use tracing_subscriber::EnvFilter;
214
215#[path = "../workflows/mod.rs"]
216mod workflows;
217
218#[tokio::main]
219async fn main() -> Result<()> {
220 tracing_subscriber::fmt()
221 .with_env_filter(EnvFilter::from_default_env())
222 .init();
223
224 let store = Arc::new(InMemoryStore::new());
225 let provider = Arc::new(ClaudeCodeProvider::new());
226 let mut engine = Engine::new(store.clone(), provider);
227
228 engine.register(workflows::hello::HelloWorkflow)?;
229
230 let worker = Worker::new(
231 store,
232 Arc::new(engine),
233 "http://localhost:3000",
234 "worker-token",
235 );
236
237 worker.run().await?;
238 Ok(())
239}
240"#;
241
242 fs::write(src.join("bin/worker.rs"), content)?;
243 Ok(())
244}
245
246fn write_hello_workflow(dir: &Path) -> Result<()> {
247 let workflows_dir = dir.join("src/workflows");
248 fs::create_dir_all(&workflows_dir)?;
249
250 let mod_content = "pub mod hello;\n";
251 fs::write(workflows_dir.join("mod.rs"), mod_content)?;
252
253 let content = r#"use ironflow_engine::context::WorkflowContext;
254use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
255
256pub struct HelloWorkflow;
257
258impl WorkflowHandler for HelloWorkflow {
259 fn name(&self) -> &str {
260 "hello"
261 }
262
263 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
264 Box::pin(async move {
265 ctx.shell("greet", "echo 'Hello from Ironflow!'").await?;
266 Ok(())
267 })
268 }
269}
270"#;
271
272 fs::write(workflows_dir.join("hello.rs"), content)?;
273 Ok(())
274}
275
276fn write_docker_compose(dir: &Path) -> Result<()> {
277 let content = r#"services:
278 postgres:
279 image: postgres:16-alpine
280 environment:
281 POSTGRES_DB: ironflow
282 POSTGRES_USER: ironflow
283 POSTGRES_PASSWORD: ironflow
284 ports:
285 - "5432:5432"
286 volumes:
287 - pgdata:/var/lib/postgresql/data
288
289volumes:
290 pgdata:
291"#;
292
293 fs::write(dir.join("docker-compose.yml"), content)?;
294 Ok(())
295}
296
297#[cfg(test)]
298mod tests {
299 use serial_test::serial;
300 use tempfile::TempDir;
301
302 use super::*;
303
304 #[test]
307 #[serial]
308 fn init_non_interactive_creates_project_structure() {
309 let tmp = TempDir::new().unwrap();
310 let project_dir = tmp.path().join("test-project");
311 fs::create_dir_all(&project_dir).unwrap();
312
313 std::env::set_current_dir(tmp.path()).unwrap();
314
315 let args = InitArgs {
316 non_interactive: true,
317 name: Some("test-project".to_string()),
318 force: false,
319 };
320
321 execute(&args).unwrap();
322
323 assert!(project_dir.join("Cargo.toml").exists());
324 assert!(project_dir.join("src/main.rs").exists());
325 assert!(project_dir.join("src/bin/worker.rs").exists());
326 assert!(project_dir.join("src/workflows/hello.rs").exists());
327 assert!(project_dir.join("src/workflows/mod.rs").exists());
328 assert!(!project_dir.join("docker-compose.yml").exists());
329
330 let cargo_toml = fs::read_to_string(project_dir.join("Cargo.toml")).unwrap();
331 assert!(cargo_toml.contains("store-memory"));
332 assert!(cargo_toml.contains(r#"name = "test-project""#));
333 }
334
335 #[test]
336 #[serial]
337 fn init_refuses_non_empty_dir_without_force() {
338 let tmp = TempDir::new().unwrap();
339 let project_dir = tmp.path().join("existing");
340 fs::create_dir_all(&project_dir).unwrap();
341 fs::write(project_dir.join("something.txt"), "data").unwrap();
342
343 std::env::set_current_dir(tmp.path()).unwrap();
344
345 let args = InitArgs {
346 non_interactive: true,
347 name: Some("existing".to_string()),
348 force: false,
349 };
350
351 let result = execute(&args);
352 assert!(result.is_err());
353 assert!(result.unwrap_err().to_string().contains("not empty"));
354 }
355
356 #[test]
357 #[serial]
358 fn init_server_serves_with_connect_info() {
359 let tmp = TempDir::new().unwrap();
360 let project_dir = tmp.path().join("connect-info");
361 fs::create_dir_all(&project_dir).unwrap();
362
363 std::env::set_current_dir(tmp.path()).unwrap();
364
365 let args = InitArgs {
366 non_interactive: true,
367 name: Some("connect-info".to_string()),
368 force: false,
369 };
370
371 execute(&args).unwrap();
372
373 let main_rs = fs::read_to_string(project_dir.join("src/main.rs")).unwrap();
374 assert!(main_rs.contains("use std::net::SocketAddr;"));
375 assert!(main_rs.contains("into_make_service_with_connect_info::<SocketAddr>()"));
376 }
377}