Skip to main content

mz_deploy/cli/commands/
new_project.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Scaffold a new mz-deploy project directory.
11//!
12//! Creates the standard directory layout (`models/`, `clusters/`, `roles/`),
13//! writes starter `project.toml` and `profiles.toml` files, and optionally
14//! initializes a git repository.
15
16use 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
31/// Shared options for project scaffolding.
32pub struct ScaffoldOpts {
33    pub init_git: bool,
34}
35
36/// `mz-deploy new <name>` — create a new directory and scaffold into it.
37pub 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
57/// `mz-deploy init` — scaffold the current directory as an mz-deploy project.
58pub 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
72/// Interactive nudge to pick a default profile right after scaffolding.
73///
74/// Skipped silently in non-interactive contexts (JSON output, `--quiet`, or a
75/// non-TTY stdin) so this never blocks CI. When skipped, the user can still
76/// run `mz-deploy profile set <name>` later — we don't want to re-explain
77/// that on every script invocation.
78fn 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
159/// One-line nudge to the help page; appended to every profile-prompt outcome.
160fn 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
167/// Nudge users toward installing the optional Materialize agent skill.
168/// Mirrors the `## Agent skills` section of the scaffolded `README.md`.
169fn 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
175/// Common scaffolding logic shared by `new` and `init`.
176///
177/// Idempotent: files that already exist are left untouched, and the git commit
178/// is skipped when there is nothing staged. The commit sets an explicit author
179/// and committer so it does not depend on the user's git identity.
180fn 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
264/// Writes a scaffold file, leaving any file that already exists untouched.
265fn 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    /// Scaffolding the same directory twice succeeds — including the git path,
284    /// where the second run has nothing to commit. Regression for `init`
285    /// failing with "git commit failed" on an already-committed project.
286    #[cfg_attr(miri, ignore)] // spawns the `git` binary
287    #[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    /// Re-scaffolding never overwrites a file the user already has.
297    #[cfg_attr(miri, ignore)] // touches the filesystem
298    #[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}