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`.
176fn 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}