Skip to main content

theater_cli/commands/
create.rs

1use anyhow::Result;
2use clap::Parser;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use tracing::{debug, info, warn};
6
7use crate::{error::CliError, output::formatters::ProjectCreated, templates, CommandContext};
8
9#[derive(Debug, Parser)]
10pub struct CreateArgs {
11    /// Name of the new actor project
12    #[arg(required = true)]
13    pub name: String,
14
15    /// Template to use for the new actor (available: basic, http-server, message-server, supervisor)
16    #[arg(short, long, default_value = "basic")]
17    pub template: String,
18
19    /// Output directory to create the project in
20    #[arg(short, long)]
21    pub output_dir: Option<PathBuf>,
22
23    /// Skip automatic dependency fetching
24    #[arg(long)]
25    pub skip_deps: bool,
26
27    /// Skip automatic build check
28    #[arg(long)]
29    pub skip_build_check: bool,
30
31    /// Initialize a git repository and make the first commit
32    #[arg(long)]
33    pub git: bool,
34
35    /// Skip git repository initialization (opposite of --git)
36    #[arg(long, conflicts_with = "git")]
37    pub no_git: bool,
38}
39
40/// Execute the create command asynchronously (modernized)
41pub async fn execute_async(args: &CreateArgs, ctx: &CommandContext) -> Result<(), CliError> {
42    debug!("Creating new actor project: {}", args.name);
43    debug!("Using template: {}", args.template);
44
45    // Check if the name is valid
46    if !is_valid_project_name(&args.name) {
47        return Err(CliError::invalid_input(
48            "project_name",
49            &args.name,
50            "Project names must only contain alphanumeric characters, hyphens, and underscores",
51        ));
52    }
53
54    // Get the output directory
55    let output_dir = match &args.output_dir {
56        Some(dir) => dir.clone(),
57        None => std::env::current_dir()
58            .map_err(|e| CliError::file_operation_failed("get current directory", ".", e))?,
59    };
60
61    debug!("Output directory: {}", output_dir.display());
62
63    // Get available templates
64    let templates_list = templates::available_templates()
65        .map_err(|e| CliError::file_operation_failed("load templates", "templates directory", e))?;
66
67    // Check if the template exists
68    if !templates_list.contains_key(&args.template) {
69        let available_templates: Vec<String> = templates_list.keys().cloned().collect();
70        return Err(CliError::template_not_found(
71            &args.template,
72            available_templates,
73        ));
74    }
75
76    // Create the project
77    let project_path = output_dir.join(&args.name);
78
79    // Check if directory already exists
80    if project_path.exists() {
81        return Err(CliError::file_operation_failed(
82            "create project",
83            project_path.display().to_string(),
84            std::io::Error::new(
85                std::io::ErrorKind::AlreadyExists,
86                "Directory already exists",
87            ),
88        ));
89    }
90
91    // Step 1: Create project from template
92    println!("Creating project structure...");
93    templates::create_project(&args.template, &args.name, &project_path).map_err(|e| {
94        CliError::file_operation_failed("create project", project_path.display().to_string(), e)
95    })?;
96    println!("✅ Project created from '{}' template", args.template);
97
98    // Step 2: Fetch WIT dependencies
99    if !args.skip_deps {
100        println!("\nFetching WIT dependencies...");
101        fetch_wit_dependencies(&project_path)?;
102    }
103
104    // Step 3: Try to build the project to validate everything works
105    if !args.skip_deps && !args.skip_build_check {
106        println!("\nBuilding project...");
107        match build_project(&project_path) {
108            Ok(_) => println!("✅ Build successful"),
109            Err(e) => {
110                warn!("⚠️  Project created but initial build failed: {}", e);
111                warn!("You may need to run 'cargo build --target wasm32-unknown-unknown --release' manually");
112            }
113        }
114    }
115
116    // Step 5: Initialize git repository if requested
117    if args.git || (!args.no_git && should_init_git()) {
118        println!("\nInitializing git repository...");
119        match init_git_repo(&project_path, &args.name) {
120            Ok(_) => println!("✅ Git repository initialized with initial commit"),
121            Err(e) => {
122                warn!("⚠️  Failed to initialize git repository: {}", e);
123                warn!("You can run 'git init' manually if needed");
124            }
125        }
126    }
127
128    // Add conclusion message
129    println!("\nProject '{}' created successfully!", args.name);
130
131    // Create success result and output
132    let mut build_instructions = vec![format!("cd {}", args.name)];
133
134    if args.skip_deps {
135        build_instructions.push("wkg wit fetch".to_string());
136    }
137
138    build_instructions.extend(vec![
139        "cargo build --target wasm32-unknown-unknown --release".to_string(),
140        "theater start manifest.toml".to_string(),
141    ]);
142
143    // Add git instructions if git was not initialized
144    if !args.git && (args.no_git || !should_init_git()) {
145        build_instructions.insert(1, "git init".to_string());
146        build_instructions.insert(2, "git add .".to_string());
147        build_instructions.insert(3, "git commit -m 'Initial commit'".to_string());
148    }
149
150    let result = ProjectCreated {
151        name: args.name.clone(),
152        template: args.template.clone(),
153        path: project_path,
154        build_instructions,
155    };
156
157    ctx.output.output(&result, None)?;
158    Ok(())
159}
160
161/// Fetch WIT dependencies using wkg
162fn fetch_wit_dependencies(project_path: &PathBuf) -> Result<(), CliError> {
163    // First try wkg wit fetch with streaming output
164    let child = Command::new("wkg")
165        .args(["wit", "fetch"])
166        .current_dir(project_path)
167        .spawn();
168
169    match child {
170        Ok(mut child) => {
171            let status = child.wait().map_err(|e| CliError::BuildFailed {
172                output: format!("Failed to wait for wkg wit fetch: {}", e),
173            })?;
174
175            if status.success() {
176                println!("✅ Dependencies fetched");
177                Ok(())
178            } else {
179                warn!("wkg wit fetch failed, trying alternative methods...");
180                try_wasm_tools_fetch(project_path)
181            }
182        }
183        Err(_) => {
184            warn!("wkg not found, trying alternative methods...");
185            try_wasm_tools_fetch(project_path)
186        }
187    }
188}
189
190/// Try using wasm-tools or other methods to fetch dependencies
191fn try_wasm_tools_fetch(_project_path: &Path) -> Result<(), CliError> {
192    // For now, just warn the user and provide instructions
193    warn!("⚠️  Could not automatically fetch WIT dependencies");
194    warn!("Please run one of the following manually:");
195    warn!("  - wkg wit fetch  (if you have wkg installed)");
196    warn!("  - Or manually download theater:simple WIT files to wit/deps/theater-simple/");
197
198    // Don't fail the creation, just warn
199    Ok(())
200}
201
202/// Build the project to validate it works
203fn build_project(project_path: &PathBuf) -> Result<(), CliError> {
204    debug!("Building project at {}", project_path.display());
205
206    let mut child = Command::new("cargo")
207        .args(["build", "--target", "wasm32-unknown-unknown", "--release"])
208        .current_dir(project_path)
209        .spawn()
210        .map_err(|e| CliError::BuildFailed {
211            output: format!("Failed to execute cargo build: {}", e),
212        })?;
213
214    let status = child.wait().map_err(|e| CliError::BuildFailed {
215        output: format!("Failed to wait for cargo build: {}", e),
216    })?;
217
218    if !status.success() {
219        return Err(CliError::BuildFailed {
220            output: "Build failed - see output above for details".to_string(),
221        });
222    }
223
224    Ok(())
225}
226
227fn is_valid_project_name(name: &str) -> bool {
228    // Check that the name only contains alphanumeric characters, hyphens, and underscores
229    !name.is_empty()
230        && name
231            .chars()
232            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
233}
234
235/// Determine if we should initialize git by default (when git is available)
236fn should_init_git() -> bool {
237    // Check if git is available
238    Command::new("git")
239        .args(["--version"])
240        .output()
241        .map(|output| output.status.success())
242        .unwrap_or(false)
243}
244
245/// Initialize a git repository and make the first commit
246fn init_git_repo(project_path: &PathBuf, project_name: &str) -> Result<(), CliError> {
247    debug!("Initializing git repository at {}", project_path.display());
248
249    // Initialize git repository
250    let init_output = Command::new("git")
251        .args(["init"])
252        .current_dir(project_path)
253        .output()
254        .map_err(|_e| CliError::MissingTool {
255            tool: "git".to_string(),
256            install_command: "Install git from https://git-scm.com/".to_string(),
257        })?;
258
259    if !init_output.status.success() {
260        return Err(CliError::BuildFailed {
261            output: format!(
262                "Failed to initialize git repository: {}",
263                String::from_utf8_lossy(&init_output.stderr)
264            ),
265        });
266    }
267
268    // Add all files
269    let add_output = Command::new("git")
270        .args(["add", "."])
271        .current_dir(project_path)
272        .output()
273        .map_err(|e| CliError::BuildFailed {
274            output: format!("Failed to add files to git: {}", e),
275        })?;
276
277    if !add_output.status.success() {
278        return Err(CliError::BuildFailed {
279            output: format!(
280                "Failed to add files to git: {}",
281                String::from_utf8_lossy(&add_output.stderr)
282            ),
283        });
284    }
285
286    // Make initial commit
287    let commit_message = format!("Initial commit: Theater actor project '{}'", project_name);
288    let commit_output = Command::new("git")
289        .args(["commit", "-m", &commit_message])
290        .current_dir(project_path)
291        .output()
292        .map_err(|e| CliError::BuildFailed {
293            output: format!("Failed to make initial commit: {}", e),
294        })?;
295
296    if !commit_output.status.success() {
297        // Check if the failure is due to missing git config
298        let stderr = String::from_utf8_lossy(&commit_output.stderr);
299        if stderr.contains("user.email") || stderr.contains("user.name") {
300            return Err(CliError::BuildFailed {
301                output: "Git commit failed: Please configure git with 'git config --global user.name \"Your Name\"' and 'git config --global user.email \"your.email@example.com\"'".to_string(),
302            });
303        } else {
304            return Err(CliError::BuildFailed {
305                output: format!("Failed to make initial commit: {}", stderr),
306            });
307        }
308    }
309
310    info!("Git repository initialized with initial commit");
311    Ok(())
312}