use std::path::Path;
use inquire::{Confirm, Select, Text};
use owo_colors::OwoColorize;
use crate::client::ApiClient;
use crate::config::{EnvVar, PartiriConfig, ServiceConfig, CONFIG_FILE};
use crate::error::Result;
use crate::output::{print_success, print_warning};
struct DetectedProject {
runtime: String,
name: Option<String>,
build_command: Option<String>,
run_command: Option<String>,
}
fn detect_project() -> DetectedProject {
if Path::new("package.json").exists() {
if let Ok(content) = std::fs::read_to_string("package.json") {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
let name = pkg["name"].as_str().map(String::from);
let build = pkg["scripts"]["build"]
.as_str()
.map(|s| format!("npm run {}", s.trim_start_matches("npm run ")))
.or_else(|| Some("npm run build".to_string()));
let start = pkg["scripts"]["start"]
.as_str()
.map(|s| format!("npm run {}", s.trim_start_matches("npm run ")))
.or_else(|| Some("npm start".to_string()));
return DetectedProject {
runtime: "node".to_string(),
name,
build_command: build,
run_command: start,
};
}
}
return DetectedProject {
runtime: "node".to_string(),
name: None,
build_command: Some("npm run build".to_string()),
run_command: Some("npm start".to_string()),
};
}
if Path::new("Cargo.toml").exists() {
if let Ok(content) = std::fs::read_to_string("Cargo.toml") {
if let Ok(cargo) = toml::from_str::<toml::Value>(&content) {
let name = cargo["package"]["name"].as_str().map(String::from);
return DetectedProject {
runtime: "rust".to_string(),
name,
build_command: Some("cargo build --release".to_string()),
run_command: None,
};
}
}
}
if Path::new("requirements.txt").exists() || Path::new("pyproject.toml").exists() {
return DetectedProject {
runtime: "python".to_string(),
name: None,
build_command: Some("pip install -r requirements.txt".to_string()),
run_command: Some("python main.py".to_string()),
};
}
if Path::new("go.mod").exists() {
let name = std::fs::read_to_string("go.mod").ok().and_then(|c| {
c.lines()
.find(|l| l.starts_with("module "))
.map(|l| l.trim_start_matches("module ").trim().to_string())
});
return DetectedProject {
runtime: "go".to_string(),
name,
build_command: Some("go build -o app .".to_string()),
run_command: Some("./app".to_string()),
};
}
if Path::new("Gemfile").exists() {
return DetectedProject {
runtime: "ruby".to_string(),
name: None,
build_command: Some("bundle install".to_string()),
run_command: Some("ruby app.rb".to_string()),
};
}
if Path::new("mix.exs").exists() {
return DetectedProject {
runtime: "elixir".to_string(),
name: None,
build_command: Some("mix deps.get && mix compile".to_string()),
run_command: Some("mix run --no-halt".to_string()),
};
}
DetectedProject {
runtime: "node".to_string(),
name: None,
build_command: None,
run_command: None,
}
}
fn detect_git_remote() -> Option<String> {
std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
fn default_service_name() -> String {
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "my-service".to_string())
}
fn load_dotenv_vars() -> Vec<EnvVar> {
let Ok(content) = std::fs::read_to_string(".env") else {
return Vec::new();
};
content
.lines()
.filter(|l| !l.trim_start().starts_with('#') && l.contains('='))
.filter_map(|l| {
let (k, v) = l.split_once('=')?;
let k = k.trim().to_string();
let v = v.trim().trim_matches('"').to_string();
if k.is_empty() {
None
} else {
Some(EnvVar { key: k, value: v })
}
})
.collect()
}
fn prompt_for_workspace(client: Option<&ApiClient>) -> Result<String> {
if let Some(c) = client {
match c.list_workspaces() {
Ok(workspaces) if !workspaces.is_empty() => {
let labels: Vec<String> = workspaces
.iter()
.map(|w| format!("{} ({})", w.name, w.id))
.collect();
let choice = Select::new("Workspace:", labels.clone())
.prompt()
.map_err(|_| "Cancelled.")?;
let (_, workspace) = labels
.into_iter()
.zip(workspaces.into_iter())
.find(|(label, _)| label == &choice)
.ok_or("Selected workspace not found in list")?;
return Ok(workspace.id);
}
Ok(_) => eprintln!("warn: No workspaces found for your API key."),
Err(e) => eprintln!(
"warn: Could not fetch workspaces: {e}\n Enter the workspace ID manually."
),
}
}
Text::new("Workspace ID:")
.prompt()
.map_err(|_| "Cancelled.".into())
}
fn prompt_for_project(client: Option<&ApiClient>, workspace_id: &str) -> Result<String> {
if let Some(c) = client {
match c.list_projects(workspace_id) {
Ok(projects) if !projects.is_empty() => {
let labels: Vec<String> = projects
.iter()
.map(|p| format!("{} [{}] ({})", p.name, p.environment, p.id))
.collect();
let choice = Select::new("Project:", labels.clone())
.prompt()
.map_err(|_| "Cancelled.")?;
let (_, project) = labels
.into_iter()
.zip(projects.into_iter())
.find(|(label, _)| label == &choice)
.ok_or("Selected project not found in list")?;
return Ok(project.id);
}
Ok(_) => eprintln!("warn: No projects found in this workspace."),
Err(e) => {
eprintln!("warn: Could not fetch projects: {e}\n Enter the project ID manually.")
}
}
}
Text::new("Project ID:")
.prompt()
.map_err(|_| "Cancelled.".into())
}
fn prompt_for_region(client: Option<&ApiClient>, workspace_id: &str) -> Result<String> {
if let Some(c) = client {
match c.list_regions(workspace_id) {
Ok(regions) if !regions.is_empty() => {
let labels: Vec<String> = regions
.iter()
.map(|r| {
let display = r.label.as_deref().unwrap_or(&r.name);
match r.country_code.as_deref() {
Some(cc) => format!("{} [{}] ({})", display, cc, r.id),
None => format!("{} ({})", display, r.id),
}
})
.collect();
let choice = Select::new("Region:", labels.clone())
.prompt()
.map_err(|_| "Cancelled.")?;
let (_, region) = labels
.into_iter()
.zip(regions.into_iter())
.find(|(label, _)| label == &choice)
.ok_or("Selected region not found in list")?;
return Ok(region.id);
}
Ok(_) => print_warning("No regions returned by the API."),
Err(e) => print_warning(&format!("Could not fetch regions: {}", e)),
}
}
Text::new("Region ID:")
.prompt()
.map_err(|_| "Cancelled.".into())
}
fn prompt_for_pod(client: Option<&ApiClient>, workspace_id: &str) -> Result<String> {
if let Some(c) = client {
match c.list_pods(workspace_id) {
Ok(pods) if !pods.is_empty() => {
let labels: Vec<String> = pods
.iter()
.map(|p| {
let display = p.label.as_deref().unwrap_or(&p.name);
match (p.cpu.as_deref(), p.ram.as_deref()) {
(Some(cpu), Some(ram)) => {
format!("{} — {} CPU / {} ({})", display, cpu, ram, p.id)
}
_ => format!("{} ({})", display, p.id),
}
})
.collect();
let choice = Select::new("Compute pod:", labels.clone())
.prompt()
.map_err(|_| "Cancelled.")?;
let (_, pod) = labels
.into_iter()
.zip(pods.into_iter())
.find(|(label, _)| label == &choice)
.ok_or("Selected pod not found in list")?;
return Ok(pod.id);
}
Ok(_) => print_warning("No compute pods returned by the API."),
Err(e) => print_warning(&format!("Could not fetch compute pods: {}", e)),
}
}
Text::new("Compute pod ID:")
.prompt()
.map_err(|_| "Cancelled.".into())
}
fn prompt_for_token(
client: Option<&ApiClient>,
workspace_id: &str,
source_kind: &str,
) -> Option<String> {
let client = client?;
let use_token = Confirm::new(&format!(
"Use an authentication token for the {} source?",
source_kind
))
.with_default(false)
.prompt()
.ok()?;
if !use_token {
return None;
}
let secrets = if source_kind == "registry" {
client.list_registry_secrets(workspace_id).ok()?
} else {
client.list_repository_secrets(workspace_id).ok()?
};
if secrets.is_empty() {
println!(
" {} No {} secrets found in this workspace. Create one in the Partiri dashboard first.",
"Note:".yellow(),
source_kind
);
return None;
}
let labels: Vec<String> = secrets
.iter()
.map(|s| {
let name = s.name.as_deref().unwrap_or("unnamed");
match s.provider.as_deref() {
Some(p) => format!("{} [{}] ({})", name, p, s.id),
None => format!("{} ({})", name, s.id),
}
})
.collect();
let choice = Select::new("Select token:", labels.clone()).prompt().ok()?;
let idx = labels.iter().position(|l| l == &choice)?;
Some(secrets[idx].id.clone())
}
pub fn run() -> Result<()> {
if Path::new(CONFIG_FILE).exists() {
return Err(format!(
"{} already exists.\n Delete it manually to re-initialize.",
CONFIG_FILE
)
.into());
}
println!("\n{}\n", " partiri init".bold().cyan());
let client = ApiClient::new().ok();
if client.is_none() {
print_warning("PARTIRI_KEY is not set — workspace, project, region and pod will require manual input.");
println!(
" Run {} first to configure your API key.\n",
"'partiri auth'".bold()
);
}
let detected = detect_project();
println!(" Detected runtime: {}", detected.runtime.bold());
let default_name = detected.name.clone().unwrap_or_else(default_service_name);
let name = Text::new("Service name:")
.with_default(&default_name)
.prompt()
.map_err(|_| "Cancelled.")?;
let deploy_type_options = vec!["webservice", "static", "private-service"];
let deploy_type = Select::new("Service type:", deploy_type_options)
.prompt()
.map_err(|_| "Cancelled.")?
.to_string();
let (repository_url, repository_branch, registry_url, registry_repository_url) =
if deploy_type == "static" {
println!(
" {} deploy_type 'static' only supports repository source.",
"Note:".yellow()
);
let git_remote = detect_git_remote();
let repo_url = Text::new("Repository URL:")
.with_default(git_remote.as_deref().unwrap_or(""))
.prompt()
.map_err(|_| "Cancelled.")?;
let branch = Text::new("Branch:")
.with_default("main")
.prompt()
.map_err(|_| "Cancelled.")?;
(Some(repo_url), Some(branch), None, None)
} else {
let source_options = vec!["Git Repository", "Registry Image"];
let source = Select::new("Deployment source:", source_options)
.prompt()
.map_err(|_| "Cancelled.")?;
if source == "Git Repository" {
let git_remote = detect_git_remote();
let repo_url = Text::new("Repository URL:")
.with_default(git_remote.as_deref().unwrap_or(""))
.prompt()
.map_err(|_| "Cancelled.")?;
let branch = Text::new("Branch:")
.with_default("main")
.prompt()
.map_err(|_| "Cancelled.")?;
(Some(repo_url), Some(branch), None, None)
} else {
let reg_url = Text::new("Registry URL (e.g. registry.example.com):")
.prompt()
.map_err(|_| "Cancelled.")?;
let reg_image = Text::new("Image path (e.g. myorg/myimage:latest):")
.prompt()
.map_err(|_| "Cancelled.")?;
(None, None, Some(reg_url), Some(reg_image))
}
};
let is_registry = registry_url.is_some();
let fk_workspace = prompt_for_workspace(client.as_ref())?;
let fk_project = prompt_for_project(client.as_ref(), &fk_workspace)?;
let fk_region = prompt_for_region(client.as_ref(), &fk_workspace)?;
let fk_pod = prompt_for_pod(client.as_ref(), &fk_workspace)?;
let source_kind = if is_registry {
"registry"
} else {
"repository"
};
let fk_service_secret = prompt_for_token(client.as_ref(), &fk_workspace, source_kind);
let runtime = if is_registry {
"registry".to_string()
} else {
let runtime_options = vec!["node", "rust", "python", "go", "static"];
if runtime_options.contains(&detected.runtime.as_str()) {
detected.runtime.clone()
} else {
Select::new("Runtime:", runtime_options)
.prompt()
.map_err(|_| "Cancelled.")?
.to_string()
}
};
let (build_command, build_path, run_command) = if is_registry {
(None, None, None)
} else {
let build_command = Text::new("Build command (leave empty to skip):")
.with_default(detected.build_command.as_deref().unwrap_or(""))
.prompt()
.map_err(|_| "Cancelled.")?;
let build_command = if build_command.is_empty() {
None
} else {
Some(build_command)
};
let build_path = Text::new("Build output directory (e.g. dist, leave empty to skip):")
.with_default("")
.prompt()
.map_err(|_| "Cancelled.")?;
let build_path = if build_path.is_empty() {
None
} else {
Some(build_path)
};
let run_command = if deploy_type != "static" {
let run_command = Text::new("Run command:")
.with_default(detected.run_command.as_deref().unwrap_or(""))
.prompt()
.map_err(|_| "Cancelled.")?;
if run_command.is_empty() {
None
} else {
Some(run_command)
}
} else {
None
};
(build_command, build_path, run_command)
};
let health_check_path = if matches!(deploy_type.as_str(), "webservice" | "private-service") {
let path = Text::new("Health check path (leave empty to disable):")
.with_default("/health")
.prompt()
.map_err(|_| "Cancelled.")?;
if path.is_empty() {
None
} else {
Some(path)
}
} else {
None
};
let mut env: Vec<EnvVar> = Vec::new();
if Path::new(".env").exists() {
let import = Confirm::new("Found a .env file. Import variables from it?")
.with_default(true)
.prompt()
.map_err(|_| "Cancelled.")?;
if import {
env = load_dotenv_vars();
println!(" Imported {} variable(s) from .env", env.len());
}
}
let add_more = Confirm::new("Add environment variables manually?")
.with_default(false)
.prompt()
.map_err(|_| "Cancelled.")?;
if add_more {
loop {
let key = Text::new("Variable name (leave empty to stop):")
.prompt()
.map_err(|_| "Cancelled.")?;
if key.is_empty() {
break;
}
let value = Text::new(&format!("Value for {}:", key))
.prompt()
.map_err(|_| "Cancelled.")?;
env.push(EnvVar { key, value });
}
}
let config = PartiriConfig {
id: None,
deploy_tag: None,
fk_workspace,
fk_project,
service: ServiceConfig {
name,
deploy_type,
runtime,
root_path: ".".to_string(),
repository_url,
repository_branch,
registry_url,
registry_repository_url,
fk_service_secret,
build_path,
build_command,
pre_deploy_command: None,
run_command,
fk_region,
fk_pod,
health_check_path,
maintenance_mode: false,
active: true,
env,
},
};
let jsonc = config
.to_jsonc_string()
.map_err(|e| format!("Failed to generate config file: {e}"))?;
std::fs::write(CONFIG_FILE, &jsonc)
.map_err(|e| format!("Failed to write {CONFIG_FILE}: {e}"))?;
println!();
print_success(&format!("{} created successfully.", CONFIG_FILE.bold()));
println!("\n Next steps:");
println!(
" {} — register your service on Partiri",
"'partiri service create'".bold()
);
println!(" {} — deploy it", "'partiri service deploy'".bold());
Ok(())
}