Skip to main content

ironflow_cli/commands/
init.rs

1//! `ironflow-cli init` -- scaffold a new Ironflow project.
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use clap::Args;
8use dialoguer::{Input, Select};
9
10/// Arguments for the `init` command.
11#[derive(Debug, Args)]
12pub struct InitArgs {
13    /// Skip interactive prompts and use defaults.
14    #[arg(long)]
15    pub non_interactive: bool,
16    /// Project name (defaults to current directory name).
17    #[arg(long)]
18    pub name: Option<String>,
19    /// Force creation even if the directory is not empty.
20    #[arg(long)]
21    pub force: bool,
22}
23
24/// Backend store choices.
25const STORE_OPTIONS: &[&str] = &["in-memory", "postgres"];
26
27/// Execute the `init` command.
28///
29/// # Errors
30///
31/// Returns an error if the directory is not empty (without `--force`),
32/// or if any file cannot be written.
33pub 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}