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::sync::Arc;
140
141use anyhow::Result;
142use ironflow_api::state::AppState;
143use ironflow_api::routes::{RouterConfig, create_router};
144use ironflow_core::providers::claude::ClaudeCodeProvider;
145use ironflow_engine::engine::Engine;
146use ironflow_store::memory::InMemoryStore;
147use tokio::net::TcpListener;
148use tokio::sync::broadcast;
149use tracing_subscriber::EnvFilter;
150
151mod workflows;
152
153#[tokio::main]
154async fn main() -> Result<()> {
155 tracing_subscriber::fmt()
156 .with_env_filter(EnvFilter::from_default_env())
157 .init();
158
159 let store = Arc::new(InMemoryStore::new());
160 let provider = Arc::new(ClaudeCodeProvider::new());
161 let mut engine = Engine::new(store.clone(), provider);
162
163 engine.register(workflows::hello::HelloWorkflow)?;
164
165 let jwt_cfg = Arc::new(ironflow_auth::jwt::JwtConfig {
166 secret: "change-me-in-production".to_string(),
167 access_token_ttl_secs: 900,
168 refresh_token_ttl_secs: 604_800,
169 cookie_domain: None,
170 cookie_secure: false,
171 });
172 let (event_tx, _) = broadcast::channel(128);
173
174 let state = AppState::new(
175 store,
176 Arc::new(engine),
177 jwt_cfg,
178 "worker-token".to_string(),
179 event_tx,
180 );
181
182 let router = create_router(state, RouterConfig::default());
183 let listener = TcpListener::bind("0.0.0.0:3000").await?;
184 tracing::info!("listening on {}", listener.local_addr()?);
185
186 axum::serve(listener, router).await?;
187 Ok(())
188}
189"#;
190
191 fs::write(src.join("main.rs"), content)?;
192 Ok(())
193}
194
195fn write_worker_main(dir: &Path, _name: &str) -> Result<()> {
196 let src = dir.join("src");
197 fs::create_dir_all(dir.join("src/bin"))?;
198
199 let content = r#"use std::sync::Arc;
200
201use anyhow::Result;
202use ironflow_core::providers::claude::ClaudeCodeProvider;
203use ironflow_engine::engine::Engine;
204use ironflow_store::memory::InMemoryStore;
205use ironflow_worker::Worker;
206use tracing_subscriber::EnvFilter;
207
208#[path = "../workflows/mod.rs"]
209mod workflows;
210
211#[tokio::main]
212async fn main() -> Result<()> {
213 tracing_subscriber::fmt()
214 .with_env_filter(EnvFilter::from_default_env())
215 .init();
216
217 let store = Arc::new(InMemoryStore::new());
218 let provider = Arc::new(ClaudeCodeProvider::new());
219 let mut engine = Engine::new(store.clone(), provider);
220
221 engine.register(workflows::hello::HelloWorkflow)?;
222
223 let worker = Worker::new(
224 store,
225 Arc::new(engine),
226 "http://localhost:3000",
227 "worker-token",
228 );
229
230 worker.run().await?;
231 Ok(())
232}
233"#;
234
235 fs::write(src.join("bin/worker.rs"), content)?;
236 Ok(())
237}
238
239fn write_hello_workflow(dir: &Path) -> Result<()> {
240 let workflows_dir = dir.join("src/workflows");
241 fs::create_dir_all(&workflows_dir)?;
242
243 let mod_content = "pub mod hello;\n";
244 fs::write(workflows_dir.join("mod.rs"), mod_content)?;
245
246 let content = r#"use ironflow_engine::context::WorkflowContext;
247use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
248
249pub struct HelloWorkflow;
250
251impl WorkflowHandler for HelloWorkflow {
252 fn name(&self) -> &str {
253 "hello"
254 }
255
256 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
257 Box::pin(async move {
258 ctx.shell("greet", "echo 'Hello from Ironflow!'").await?;
259 Ok(())
260 })
261 }
262}
263"#;
264
265 fs::write(workflows_dir.join("hello.rs"), content)?;
266 Ok(())
267}
268
269fn write_docker_compose(dir: &Path) -> Result<()> {
270 let content = r#"services:
271 postgres:
272 image: postgres:16-alpine
273 environment:
274 POSTGRES_DB: ironflow
275 POSTGRES_USER: ironflow
276 POSTGRES_PASSWORD: ironflow
277 ports:
278 - "5432:5432"
279 volumes:
280 - pgdata:/var/lib/postgresql/data
281
282volumes:
283 pgdata:
284"#;
285
286 fs::write(dir.join("docker-compose.yml"), content)?;
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use tempfile::TempDir;
293
294 use super::*;
295
296 #[test]
297 fn init_non_interactive_creates_project_structure() {
298 let tmp = TempDir::new().unwrap();
299 let project_dir = tmp.path().join("test-project");
300 fs::create_dir_all(&project_dir).unwrap();
301
302 std::env::set_current_dir(tmp.path()).unwrap();
303
304 let args = InitArgs {
305 non_interactive: true,
306 name: Some("test-project".to_string()),
307 force: false,
308 };
309
310 execute(&args).unwrap();
311
312 assert!(project_dir.join("Cargo.toml").exists());
313 assert!(project_dir.join("src/main.rs").exists());
314 assert!(project_dir.join("src/bin/worker.rs").exists());
315 assert!(project_dir.join("src/workflows/hello.rs").exists());
316 assert!(project_dir.join("src/workflows/mod.rs").exists());
317 assert!(!project_dir.join("docker-compose.yml").exists());
318
319 let cargo_toml = fs::read_to_string(project_dir.join("Cargo.toml")).unwrap();
320 assert!(cargo_toml.contains("store-memory"));
321 assert!(cargo_toml.contains(r#"name = "test-project""#));
322 }
323
324 #[test]
325 fn init_refuses_non_empty_dir_without_force() {
326 let tmp = TempDir::new().unwrap();
327 let project_dir = tmp.path().join("existing");
328 fs::create_dir_all(&project_dir).unwrap();
329 fs::write(project_dir.join("something.txt"), "data").unwrap();
330
331 std::env::set_current_dir(tmp.path()).unwrap();
332
333 let args = InitArgs {
334 non_interactive: true,
335 name: Some("existing".to_string()),
336 force: false,
337 };
338
339 let result = execute(&args);
340 assert!(result.is_err());
341 assert!(result.unwrap_err().to_string().contains("not empty"));
342 }
343}