1use crate::cli::CliError;
17use crate::cli::progress;
18use crate::config::{ProfilesConfig, write_mzprofile};
19use crate::{info, info_nonl, log};
20use owo_colors::{OwoColorize, Stream, Style};
21use std::fs;
22use std::io::{self, IsTerminal, Write};
23use std::path::Path;
24use std::process::Command;
25
26const GITIGNORE: &str = include_str!("../scaffold/gitignore");
27const PROJECT_TOML: &str = include_str!("../scaffold/project.toml");
28const README_MD: &str = include_str!("../scaffold/README.md");
29const VSCODE_EXTENSIONS_JSON: &str = include_str!("../scaffold/vscode-extensions.json");
30
31pub struct ScaffoldOpts {
33 pub init_git: bool,
34}
35
36pub fn run(name: &str, opts: ScaffoldOpts) -> Result<(), CliError> {
38 let project_dir = Path::new(name);
39
40 if project_dir.exists() {
41 return Err(CliError::Message(format!(
42 "destination `{}` already exists",
43 name
44 )));
45 }
46
47 fs::create_dir_all(project_dir)
48 .map_err(|e| CliError::Message(format!("failed to create directory: {}", e)))?;
49
50 scaffold(project_dir, name, &opts)?;
51 progress::success(&format!("Created project `{}`", name));
52 prompt_default_profile(project_dir)?;
53 print_skill_hint();
54 Ok(())
55}
56
57pub fn init(opts: ScaffoldOpts) -> Result<(), CliError> {
59 let project_dir = Path::new(".");
60 let name = std::env::current_dir()
61 .ok()
62 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
63 .unwrap_or_else(|| "my-project".to_string());
64
65 scaffold(project_dir, &name, &opts)?;
66 progress::success(&format!("Initialized project `{}`", name));
67 prompt_default_profile(project_dir)?;
68 print_skill_hint();
69 Ok(())
70}
71
72fn prompt_default_profile(project_dir: &Path) -> Result<(), CliError> {
79 if log::json_output_enabled() || log::quiet_enabled() || !io::stdin().is_terminal() {
80 return Ok(());
81 }
82
83 info!("");
84
85 let profiles_config = match ProfilesConfig::load(None) {
86 Ok(c) => c,
87 Err(_) => {
88 info!("No profiles configured yet.");
89 info!(" Add one to ~/.mz/profiles.toml, then run:");
90 info!(
91 " {}",
92 "mz-deploy profile set <name>".if_supports_color(Stream::Stderr, |t| t.cyan())
93 );
94 print_profile_help_hint();
95 return Ok(());
96 }
97 };
98
99 let names = profiles_config.profile_names();
100 if names.is_empty() {
101 info!("No profiles configured yet.");
102 info!(
103 " Add one to {}, then run:",
104 profiles_config.source_path().display()
105 );
106 info!(
107 " {}",
108 "mz-deploy profile set <name>".if_supports_color(Stream::Stderr, |t| t.cyan())
109 );
110 print_profile_help_hint();
111 return Ok(());
112 }
113
114 info!("Pick a default profile for this project:");
115 for (i, name) in names.iter().enumerate() {
116 info!(" {}. {}", i + 1, name);
117 }
118 info_nonl!("Enter a number, or press Enter to skip: ");
119 io::stderr().flush()?;
120
121 let mut input = String::new();
122 io::stdin().read_line(&mut input)?;
123 let trimmed = input.trim();
124 if trimmed.is_empty() {
125 info!(
126 "Skipped. Set a default later with {}",
127 "mz-deploy profile set <name>".if_supports_color(Stream::Stderr, |t| t.cyan())
128 );
129 print_profile_help_hint();
130 return Ok(());
131 }
132
133 let choice: usize = match trimmed.parse() {
134 Ok(n) if (1..=names.len()).contains(&n) => n,
135 _ => {
136 info!(
137 "Invalid choice. Skipped — set a default later with {}",
138 "mz-deploy profile set <name>".if_supports_color(Stream::Stderr, |t| t.cyan())
139 );
140 print_profile_help_hint();
141 return Ok(());
142 }
143 };
144
145 let name = names[choice - 1];
146 write_mzprofile(project_dir, name)?;
147
148 let check_style = Style::new().green().bold();
149 info!(
150 " {} default profile set to {}. Update with {}",
151 "✓".if_supports_color(Stream::Stderr, |t| check_style.style(t)),
152 name.if_supports_color(Stream::Stderr, |t| t.green()),
153 "mz-deploy profile set <name>".if_supports_color(Stream::Stderr, |t| t.cyan()),
154 );
155 print_profile_help_hint();
156 Ok(())
157}
158
159fn print_profile_help_hint() {
161 info!(
162 " Learn more: {}",
163 "mz-deploy help profile".if_supports_color(Stream::Stderr, |t| t.cyan())
164 );
165}
166
167fn print_skill_hint() {
170 info!("");
171 info!("Tip: install the Materialize agent skill for AI coding agents:");
172 info!(" npx -y skills add MaterializeInc/agent-skills -a universal -a claude-code --project");
173}
174
175fn scaffold(project_dir: &Path, name: &str, opts: &ScaffoldOpts) -> Result<(), CliError> {
177 create_dir(project_dir, "models/materialize/public")?;
178 create_dir(project_dir, "clusters")?;
179 create_dir(project_dir, "roles")?;
180 create_dir(project_dir, "network-policies")?;
181 create_dir(project_dir, ".vscode")?;
182 add_file(project_dir, "models/materialize/public/.gitkeep", "")?;
183 add_file(project_dir, "clusters/.gitkeep", "")?;
184 add_file(project_dir, "roles/.gitkeep", "")?;
185 add_file(project_dir, "network-policies/.gitkeep", "")?;
186 add_file(project_dir, ".gitignore", GITIGNORE)?;
187 add_file(project_dir, "project.toml", PROJECT_TOML)?;
188 add_file(
189 project_dir,
190 ".vscode/extensions.json",
191 VSCODE_EXTENSIONS_JSON,
192 )?;
193 add_file(
194 project_dir,
195 "README.md",
196 &README_MD.replace("{{name}}", name),
197 )?;
198
199 if opts.init_git {
200 let dir_arg = project_dir.as_os_str();
201 let status = Command::new("git")
202 .arg("init")
203 .arg(dir_arg)
204 .stdout(std::process::Stdio::null())
205 .stderr(std::process::Stdio::null())
206 .status()
207 .map_err(|e| CliError::Message(format!("failed to run git init: {}", e)))?;
208
209 if !status.success() {
210 return Err(CliError::Message("git init failed".to_string()));
211 }
212
213 let status = Command::new("git")
214 .args(["add", "."])
215 .current_dir(project_dir)
216 .stdout(std::process::Stdio::null())
217 .stderr(std::process::Stdio::null())
218 .status()
219 .map_err(|e| CliError::Message(format!("failed to run git add: {}", e)))?;
220
221 if !status.success() {
222 return Err(CliError::Message("git add failed".to_string()));
223 }
224
225 let status = Command::new("git")
226 .args([
227 "commit",
228 "--author",
229 "Materialize Inc <noreply@materialize.com>",
230 "-m",
231 "Initial commit",
232 ])
233 .current_dir(project_dir)
234 .stdout(std::process::Stdio::null())
235 .stderr(std::process::Stdio::null())
236 .status()
237 .map_err(|e| CliError::Message(format!("failed to run git commit: {}", e)))?;
238
239 if !status.success() {
240 return Err(CliError::Message("git commit failed".to_string()));
241 }
242 }
243
244 Ok(())
245}
246
247fn add_file(project_dir: &Path, file: &str, content: &str) -> Result<(), CliError> {
248 fs::write(project_dir.join(file), content)
249 .map_err(|e| CliError::Message(format!("failed to write {}: {}", file, e)))
250}
251
252fn create_dir(project_dir: &Path, path: &str) -> Result<(), CliError> {
253 fs::create_dir_all(project_dir.join(path))
254 .map_err(|e| CliError::Message(format!("failed to create directories: {}", e)))
255}