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> {
181 create_dir(project_dir, "models/materialize/public")?;
182 create_dir(project_dir, "clusters")?;
183 create_dir(project_dir, "roles")?;
184 create_dir(project_dir, "network-policies")?;
185 create_dir(project_dir, ".vscode")?;
186 add_file(project_dir, "models/materialize/public/.gitkeep", "")?;
187 add_file(project_dir, "clusters/.gitkeep", "")?;
188 add_file(project_dir, "roles/.gitkeep", "")?;
189 add_file(project_dir, "network-policies/.gitkeep", "")?;
190 add_file(project_dir, ".gitignore", GITIGNORE)?;
191 add_file(project_dir, "project.toml", PROJECT_TOML)?;
192 add_file(
193 project_dir,
194 ".vscode/extensions.json",
195 VSCODE_EXTENSIONS_JSON,
196 )?;
197 add_file(
198 project_dir,
199 "README.md",
200 &README_MD.replace("{{name}}", name),
201 )?;
202
203 if opts.init_git {
204 let dir_arg = project_dir.as_os_str();
205 let status = Command::new("git")
206 .arg("init")
207 .arg(dir_arg)
208 .stdout(std::process::Stdio::null())
209 .stderr(std::process::Stdio::null())
210 .status()
211 .map_err(|e| CliError::Message(format!("failed to run git init: {}", e)))?;
212
213 if !status.success() {
214 return Err(CliError::Message("git init failed".to_string()));
215 }
216
217 let status = Command::new("git")
218 .args(["add", "."])
219 .current_dir(project_dir)
220 .stdout(std::process::Stdio::null())
221 .stderr(std::process::Stdio::null())
222 .status()
223 .map_err(|e| CliError::Message(format!("failed to run git add: {}", e)))?;
224
225 if !status.success() {
226 return Err(CliError::Message("git add failed".to_string()));
227 }
228
229 let nothing_staged = Command::new("git")
230 .args(["diff", "--cached", "--quiet"])
231 .current_dir(project_dir)
232 .stdout(std::process::Stdio::null())
233 .stderr(std::process::Stdio::null())
234 .status()
235 .map_err(|e| CliError::Message(format!("failed to run git diff: {}", e)))?
236 .success();
237
238 if !nothing_staged {
239 let status = Command::new("git")
240 .args([
241 "commit",
242 "--author",
243 "Materialize Inc <noreply@materialize.com>",
244 "-m",
245 "Initial commit",
246 ])
247 .env("GIT_COMMITTER_NAME", "Materialize Inc")
248 .env("GIT_COMMITTER_EMAIL", "noreply@materialize.com")
249 .current_dir(project_dir)
250 .stdout(std::process::Stdio::null())
251 .stderr(std::process::Stdio::null())
252 .status()
253 .map_err(|e| CliError::Message(format!("failed to run git commit: {}", e)))?;
254
255 if !status.success() {
256 return Err(CliError::Message("git commit failed".to_string()));
257 }
258 }
259 }
260
261 Ok(())
262}
263
264fn add_file(project_dir: &Path, file: &str, content: &str) -> Result<(), CliError> {
266 let path = project_dir.join(file);
267 if path.exists() {
268 return Ok(());
269 }
270 fs::write(&path, content)
271 .map_err(|e| CliError::Message(format!("failed to write {}: {}", file, e)))
272}
273
274fn create_dir(project_dir: &Path, path: &str) -> Result<(), CliError> {
275 fs::create_dir_all(project_dir.join(path))
276 .map_err(|e| CliError::Message(format!("failed to create directories: {}", e)))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[cfg_attr(miri, ignore)] #[mz_ore::test]
288 fn scaffold_is_idempotent() {
289 let dir = tempfile::tempdir().unwrap();
290 let opts = ScaffoldOpts { init_git: true };
291 scaffold(dir.path(), "proj", &opts).expect("first scaffold should succeed");
292 scaffold(dir.path(), "proj", &opts).expect("second scaffold should be idempotent");
293 assert!(dir.path().join("project.toml").exists());
294 }
295
296 #[cfg_attr(miri, ignore)] #[mz_ore::test]
299 fn scaffold_preserves_existing_files() {
300 let dir = tempfile::tempdir().unwrap();
301 let custom = "mz_version = \"cloud\"\ndependencies = [\"app.public.foo\"]\n";
302 fs::write(dir.path().join("project.toml"), custom).unwrap();
303
304 scaffold(dir.path(), "proj", &ScaffoldOpts { init_git: false })
305 .expect("scaffold should succeed over an existing project.toml");
306
307 let contents = fs::read_to_string(dir.path().join("project.toml")).unwrap();
308 assert_eq!(contents, custom, "existing project.toml must be preserved");
309 }
310}