use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use offline_registry::{normalize_platform, Registry};
use serde_json::json;
use crate::commands::post_generate::{self, PostGenerateMode};
use crate::commands::react_native_shell;
use crate::config::storage::CliConfig;
use crate::output;
#[derive(Args)]
pub struct CreateArgs {
pub target: Option<String>,
#[arg(long, conflicts_with = "target")]
pub template: Option<String>,
#[arg(long, conflicts_with = "target")]
pub prompt: Option<String>,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub platform: Option<String>,
#[arg(long)]
pub dir: Option<PathBuf>,
#[arg(long = "var", value_parser = parse_key_val)]
pub vars: Vec<(String, String)>,
#[arg(long, short = 'y')]
pub yes: bool,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub skip_native_shell: bool,
#[arg(long, conflicts_with = "no_mesh")]
pub mesh: bool,
#[arg(long, conflicts_with = "mesh")]
pub no_mesh: bool,
#[arg(long, conflicts_with = "no_pol")]
pub pol: bool,
#[arg(long, conflicts_with = "pol")]
pub no_pol: bool,
#[arg(long)]
pub skip_setup: bool,
}
fn parse_key_val(s: &str) -> Result<(String, String), String> {
s.split_once('=')
.map(|(k, v)| (k.trim().to_string(), v.to_string()))
.ok_or_else(|| format!("expected key=value, got `{s}`"))
}
pub async fn run(args: CreateArgs) -> Result<()> {
let non_interactive = args.yes || args.json;
let registry = Registry::load_default()?;
let platform = args
.platform
.as_deref()
.map(|p| {
normalize_platform(p).ok_or_else(|| {
anyhow::anyhow!("unknown platform `{p}` (nextjs | react-vite | react-native)")
})
})
.transpose()?;
let (template_name, plan, workflow_name) = if let Some(prompt) = &args.prompt {
let plan = registry.plan(prompt, platform.as_deref())?;
let template = plan.template.clone().with_context(|| {
format!(
"workflow `{}` has no starter template for platform `{}` yet",
plan.workflow, plan.platform
)
})?;
(template, Some(plan), None)
} else if let Some(target) = args.target.as_deref().or(args.template.as_deref()) {
resolve_target(®istry, target, platform.as_deref())?
} else if non_interactive {
let platform = platform.as_deref().ok_or_else(|| {
anyhow::anyhow!("--platform is required with --yes/--json when no target")
})?;
let template = template_for_platform(platform)?;
(template.to_string(), None, None)
} else {
let framework = pick_framework_interactively()?;
let template = if framework == "react-native" {
pick_react_native_starter_interactively(®istry)?
} else {
template_for_platform(&framework)?.to_string()
};
(template, None, None)
};
let template = registry
.template(&template_name)
.expect("resolved template exists")
.clone();
let mesh_enabled = resolve_mesh_enabled(&args, &template)?;
let pol_enabled = resolve_pol_enabled(&args, &template)?;
let needs_login = template.requires_login;
if needs_login {
crate::auth::login::ensure_logged_in(!non_interactive).await?;
} else if !args.json && !CliConfig::load()?.has_api_credentials() {
output::note(&format!(
"`{}` runs without hosted services, so no login is needed. \
Run `offline login` later if you add Offline ID or hosted telemetry.",
template.name
));
}
let name = match (&args.name, &args.dir) {
(Some(n), _) => n.clone(),
(None, Some(dir)) => dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "offline-app".to_string()),
(None, None) if non_interactive => bail!("--name is required with --yes/--json"),
(None, None) => dialoguer::Input::new()
.with_prompt("Project name")
.default("my-offline-app".to_string())
.interact_text()?,
};
let name = slugify(&name);
let target_dir = args.dir.clone().unwrap_or_else(|| PathBuf::from(&name));
if needs_login {
let mut cli_config = CliConfig::load()?;
crate::commands::setup::setup_for_create(
&mut cli_config,
&name,
mesh_enabled,
pol_enabled,
!non_interactive,
)
.await?;
}
let config_app_id = cli_config_app_id();
let mut vars: BTreeMap<String, String> = args.vars.iter().cloned().collect();
if let Some(legacy) = vars.remove("project_id") {
vars.entry("app_id".to_string()).or_insert(legacy);
}
vars.insert(
"enable_mesh".to_string(),
if mesh_enabled { "true" } else { "false" }.to_string(),
);
vars.insert(
"enable_pol".to_string(),
if pol_enabled { "true" } else { "false" }.to_string(),
);
if let Some(wf) = workflow_name {
vars.insert("workflow".to_string(), wf);
} else if let Some(plan) = &plan {
vars.insert("workflow".to_string(), plan.workflow.clone());
}
for v in &template.variables {
if vars.contains_key(&v.name) {
continue;
}
if !mesh_enabled && mesh_only_variable(&v.name) {
continue;
}
let fallback = variable_default(v, &name, config_app_id.as_deref());
match fallback {
Some(value) if non_interactive => {
if mesh_enabled || !mesh_only_variable(&v.name) {
vars.insert(v.name.clone(), value);
}
}
None if non_interactive
&& v.required
&& (mesh_enabled || !mesh_only_variable(&v.name)) =>
{
bail!(
"missing required variable `{}` (use --var {}=...)",
v.name,
v.name
)
}
_ if non_interactive => {}
fallback => {
let value = prompt_template_variable(v, fallback, config_app_id.as_deref())?;
vars.insert(v.name.clone(), value);
}
}
}
if !non_interactive
&& !confirm_project_summary(&name, &target_dir, &template, mesh_enabled, pol_enabled)?
{
output::note("Cancelled.");
return Ok(());
}
if template.platform == "react-native" {
vars.insert(
"rn_module_name".to_string(),
react_native_shell::rn_module_name(&name),
);
}
let generated =
offline_generator::generate(®istry, &template.name, &target_dir, &name, &vars)?;
let mut extra_files = Vec::new();
let mut native_shells_added = false;
let mut native_shell_warning: Option<String> = None;
if template.platform == "react-native" && !args.skip_native_shell {
let module = react_native_shell::rn_module_name(&name);
match react_native_shell::add_native_shells(&target_dir, &module) {
Ok(()) => {
native_shells_added = true;
extra_files.push("android/".to_string());
extra_files.push("ios/".to_string());
}
Err(err) => {
native_shell_warning = Some(format!("{err:#}"));
}
}
}
if template.platform == "react-native" && target_dir.join("android").is_dir() {
let module = react_native_shell::rn_module_name(&name);
if let Err(err) = react_native_shell::patch_network_binding(&target_dir, &module) {
output::warn(&format!(
"Android Wi‑Fi network binding not applied: {err:#}"
));
}
}
if mesh_enabled && target_dir.join("ios").is_dir() {
let module = react_native_shell::rn_module_name(&name);
if let Err(err) = react_native_shell::patch_mesh_native_permissions(&target_dir, &module) {
output::warn(&format!("Mesh native setup not applied: {err:#}"));
output::warn(
"Ensure ios/Podfile uses `use_native_modules!` (MeshSdk autolinks from \
@offline-protocol/mesh-sdk), then run `cd ios && pod install`.",
);
} else if !args.json {
output::note(
"Mesh iOS native module autolinks via use_native_modules!; run `cd ios && pod install` before building.",
);
}
}
if let Some(plan) = &plan {
let arch = offline_registry::architecture_markdown(plan);
let impl_plan = offline_registry::implementation_plan_markdown(plan);
std::fs::write(target_dir.join("architecture.md"), arch)?;
std::fs::write(target_dir.join("implementation-plan.md"), impl_plan)?;
extra_files.push("architecture.md".to_string());
extra_files.push("implementation-plan.md".to_string());
}
if args.json {
let setup = if args.skip_setup {
None
} else {
Some(run_project_setup(
&target_dir,
&generated.post_generate,
PostGenerateMode::InstallOnly,
))
};
return output::json(&json!({
"project": name,
"dir": target_dir,
"template": generated.template,
"workflow": plan.as_ref().map(|p| p.workflow.clone()),
"filesWritten": ([generated.files_written.clone(), extra_files].concat()),
"postGenerate": generated.post_generate,
"notes": generated.notes,
"nativeShellsAdded": native_shells_added,
"nativeShellWarning": native_shell_warning,
"meshEnabled": mesh_enabled,
"polEnabled": pol_enabled,
"setup": setup,
}));
}
output::success(&format!(
"Created {} from `{}` ({} files)",
target_dir.display(),
generated.template,
generated.files_written.len() + extra_files.len()
));
if let Some(plan) = &plan {
output::note(&format!(
"workflow: {}, platform: {}, architecture.md and implementation-plan.md written",
plan.workflow, plan.platform
));
}
if native_shells_added {
output::note("Native android/ and ios/ folders added automatically.");
} else if let Some(warn) = &native_shell_warning {
output::warn(&format!("Native shells not added: {warn}"));
output::warn("See README for manual `react-native init` steps.");
}
if !generated.notes.is_empty() {
output::heading("Notes");
for n in &generated.notes {
output::warn(n);
}
}
if args.skip_setup {
output::heading("Next steps");
println!(" cd {}", target_dir.display());
for step in &generated.post_generate {
println!(" {step}");
}
} else {
run_project_setup(
&target_dir,
&generated.post_generate,
PostGenerateMode::Full,
);
}
Ok(())
}
fn run_project_setup(
target_dir: &Path,
steps: &[String],
mode: PostGenerateMode,
) -> serde_json::Value {
let planned = post_generate::planned_steps(steps, mode);
if planned.is_empty() {
return json!({ "ran": [], "skipped": [], "failed": null });
}
output::heading("Setting up project");
for step in &planned {
output::note(step);
}
if mode == PostGenerateMode::Full && template_has_rn_start(steps) {
output::note(
"After Metro starts, open another terminal and run `npm run android` or `npm run ios`.",
);
}
let report = post_generate::run(target_dir, steps, mode);
if let Some(err) = &report.failed {
output::warn(&format!("Setup stopped: {err}"));
output::note("Finish manually from the project directory.");
for step in &report.skipped {
println!(" {step}");
}
}
json!({
"ran": report.ran,
"skipped": report.skipped,
"failed": report.failed,
})
}
fn template_has_rn_start(steps: &[String]) -> bool {
steps
.iter()
.any(|s| post_generate::clean_command(s) == "npm start")
}
fn resolve_target(
registry: &Registry,
target: &str,
platform: Option<&str>,
) -> Result<(String, Option<offline_registry::Plan>, Option<String>)> {
if registry.template(target).is_some() {
return Ok((target.to_string(), None, None));
}
if let Some(wf) = registry.workflow(target) {
let platform = match platform {
Some(p) => p.to_string(),
None if wf.templates.len() == 1 => {
wf.templates.keys().next().expect("len checked").clone()
}
None => {
let platforms: Vec<&String> = wf.templates.keys().collect();
let pick = dialoguer::Select::new()
.with_prompt("Platform")
.items(&platforms)
.default(0)
.interact()?;
platforms[pick].clone()
}
};
let template = wf.templates.get(&platform).with_context(|| {
format!(
"workflow `{target}` has no template for `{platform}` (available: {})",
wf.templates.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?;
return Ok((template.clone(), None, Some(target.to_string())));
}
let suggestions = registry.search_workflows(target);
let hint = suggestions
.first()
.map(|h| format!(". Did you mean `{}`?", h.name))
.unwrap_or_default();
let example_hint = if registry.template(target).is_none() {
format!(
" Example apps: {}.",
offline_registry::EXAMPLE_APP_TEMPLATE_IDS.join(", ")
)
} else {
String::new()
};
bail!(
"`{target}` is not a workflow or template{hint} \
(see `offline registry list templates` or `offline registry list workflows`){example_hint}"
)
}
const FRAMEWORKS: &[(&str, &str)] = &[
("nextjs", "Next.js"),
("react-vite", "React (Vite)"),
("react-native", "React Native"),
];
fn pick_framework_interactively() -> Result<String> {
let labels: Vec<&str> = FRAMEWORKS.iter().map(|(_, label)| *label).collect();
let pick = dialoguer::Select::new()
.with_prompt("Which framework?")
.items(&labels)
.default(0)
.interact()?;
Ok(FRAMEWORKS[pick].0.to_string())
}
fn pick_react_native_starter_interactively(registry: &Registry) -> Result<String> {
let mut options: Vec<(String, String)> = vec![(
"react-native-social".to_string(),
"Offline ID social starter (default)".to_string(),
)];
for id in offline_registry::EXAMPLE_APP_TEMPLATE_IDS {
let meta = registry
.template(id)
.unwrap_or_else(|| panic!("example template `{id}` missing"));
options.push((id.to_string(), meta.title.clone()));
}
let labels: Vec<&str> = options.iter().map(|(_, label)| label.as_str()).collect();
let pick = dialoguer::Select::new()
.with_prompt("Which React Native starter?")
.items(&labels)
.default(0)
.interact()?;
Ok(options[pick].0.clone())
}
fn template_for_platform(platform: &str) -> Result<&'static str> {
match platform {
"nextjs" => Ok("nextjs-social"),
"react-vite" => Ok("react-vite-social"),
"react-native" => Ok("react-native-social"),
other => bail!("no starter template for platform `{other}`"),
}
}
fn mesh_only_variable(name: &str) -> bool {
matches!(name, "mesh_app_id" | "relay_server_url")
}
fn resolve_mesh_enabled(
args: &CreateArgs,
template: &offline_registry::TemplateMeta,
) -> Result<bool> {
if template.platform != "react-native" {
if args.mesh || args.no_mesh {
bail!("--mesh and --no-mesh only apply to React Native projects");
}
return Ok(false);
}
if template.requires_mesh {
if args.no_mesh {
let message = format!(
"`{}` is built on the Mesh SDK throughout; ignoring --no-mesh.",
template.name
);
if args.json {
eprintln!("warning: {message}");
} else {
output::warn(&message);
}
}
return Ok(true);
}
if let Some((_, value)) = args.vars.iter().find(|(k, _)| k == "enable_mesh") {
return Ok(value == "true");
}
if args.mesh {
return Ok(true);
}
if args.no_mesh {
return Ok(false);
}
if args.yes || args.json {
return Ok(false);
}
prompt_mesh_interactively()
}
fn prompt_mesh_interactively() -> Result<bool> {
dialoguer::Confirm::new()
.with_prompt(
"Enable mesh capabilities?\n \
- Discover and connect to nearby peers over Bluetooth LE\n \
- Send encrypted messages over local mesh or internet relay",
)
.default(false)
.interact()
.map_err(Into::into)
}
fn resolve_pol_enabled(
args: &CreateArgs,
template: &offline_registry::TemplateMeta,
) -> Result<bool> {
if template.platform != "react-native" {
if args.pol || args.no_pol {
bail!("--pol and --no-pol only apply to React Native projects");
}
return Ok(false);
}
if let Some((_, value)) = args.vars.iter().find(|(k, _)| k == "enable_pol") {
return Ok(value == "true");
}
if args.pol {
return Ok(true);
}
if args.no_pol {
return Ok(false);
}
if args.yes || args.json {
return Ok(false);
}
prompt_pol_interactively()
}
fn prompt_pol_interactively() -> Result<bool> {
dialoguer::Confirm::new()
.with_prompt(
"Enable Proof of Location (@offline-protocol/pol)?\n \
- Separate from OfflineID; uses the same app id and the JWT from useAuth()\n \
- location.verify() and location.commit() on the home screen",
)
.default(false)
.interact()
.map_err(Into::into)
}
fn confirm_project_summary(
name: &str,
target_dir: &Path,
template: &offline_registry::TemplateMeta,
mesh_enabled: bool,
pol_enabled: bool,
) -> Result<bool> {
output::heading("Project summary");
println!(" Framework: {}", template.platform);
println!(" Template: {}", template.name);
println!(" Name: {name}");
println!(" Directory: {}", target_dir.display());
if template.platform == "react-native" {
println!(
" Mesh: {}",
if mesh_enabled { "enabled" } else { "disabled" }
);
if !template.example_app {
println!(
" PoL: {}",
if pol_enabled { "enabled" } else { "disabled" }
);
}
}
println!();
if template.example_app {
println!(" {}", template.description);
println!();
println!(" Includes:");
println!(" - Vendored packages/mesh and packages/ui from offline-app-examples");
println!(" - Native android/ and ios/ from the example app");
println!(" - @offline-protocol/mesh-sdk (AGPL-3.0-only, or a commercial license; review before shipping)");
} else {
let sdk = match template.platform.as_str() {
"react-native" => "id-react-native",
_ => "id-react",
};
println!(" Includes:");
println!(" - OfflineID login ({sdk} SDK)");
println!(" - Member directory with username search");
println!(" - Add friends via connect API");
if template.platform == "react-native" {
println!(" - Native android/ + ios/ projects (auto-generated)");
}
if mesh_enabled {
println!(" - Mesh peer discovery (Bluetooth LE)");
println!(" - Encrypted 1:1 messaging with internet relay fallback");
println!(
" - mesh-sdk (AGPL-3.0-only, or a commercial license; review before shipping)"
);
}
if pol_enabled {
println!(
" - Proof of Location (@offline-protocol/pol): PolProvider and verify/commit UI"
);
println!(" - Enable PoL for your app id in the developer portal before testing");
}
println!(" - Profile view and profile update page");
}
println!();
dialoguer::Confirm::new()
.with_prompt("Create this project?")
.default(true)
.interact()
.map_err(Into::into)
}
fn slugify(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
slug.trim_matches('-').replace("--", "-")
}
fn cli_config_app_id() -> Option<String> {
CliConfig::load()
.ok()
.and_then(|c| c.effective_app_id())
.filter(|id| !id.trim().is_empty())
}
fn variable_default(
variable: &offline_registry::TemplateVariable,
project_slug: &str,
config_app_id: Option<&str>,
) -> Option<String> {
if variable.name == "app_id" {
return config_app_id
.map(str::to_string)
.or_else(|| variable.default.clone())
.or_else(|| variable.required.then(|| project_slug.to_string()));
}
if variable.name == "app_id" && variable.default.as_deref().is_some_and(|d| !d.is_empty()) {
return variable.default.clone();
}
if variable.name == "mesh_app_id" {
return Some(project_slug.to_string());
}
if variable.name == "relay_server_url" {
return variable
.default
.clone()
.or_else(|| Some("wss://relay.offlineprotocol.com".to_string()));
}
variable.default.clone()
}
fn prompt_template_variable(
variable: &offline_registry::TemplateVariable,
fallback: Option<String>,
config_app_id: Option<&str>,
) -> Result<String> {
if variable.name == "app_id" {
if let Some(config_id) = config_app_id {
let use_config = dialoguer::Confirm::new()
.with_prompt(format!(
"Use application ID from your CLI login ({config_id})?"
))
.default(true)
.interact()?;
if use_config {
return Ok(config_id.to_string());
}
return dialoguer::Input::new()
.with_prompt("Enter a custom Offline application ID (leave blank to fill in later)")
.default(String::new())
.allow_empty(!variable.required)
.interact_text()
.map_err(Into::into);
}
}
dialoguer::Input::new()
.with_prompt(&variable.prompt)
.default(fallback.unwrap_or_default())
.allow_empty(!variable.required)
.interact_text()
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
use offline_registry::TemplateVariable;
fn portal_app_id_var() -> TemplateVariable {
TemplateVariable {
name: "app_id".into(),
prompt: "Portal application id".into(),
default: Some(String::new()),
required: false,
}
}
#[test]
fn variable_default_prefers_cli_config_for_app_id() {
let var = portal_app_id_var();
assert_eq!(
variable_default(&var, "my-app", Some("app_from_config")).as_deref(),
Some("app_from_config")
);
}
#[test]
fn variable_default_falls_back_to_template_default_for_app_id() {
let var = portal_app_id_var();
assert_eq!(variable_default(&var, "my-app", None).as_deref(), Some(""));
}
#[test]
fn required_mesh_app_id_defaults_to_slug_without_login() {
let var = TemplateVariable {
name: "app_id".into(),
prompt: "Application identifier for the mesh".into(),
default: None,
required: true,
};
assert_eq!(
variable_default(&var, "pulse", None).as_deref(),
Some("pulse")
);
assert_eq!(
variable_default(&var, "pulse", Some("app_cfg")).as_deref(),
Some("app_cfg")
);
}
#[test]
fn only_local_mesh_templates_skip_login() {
let registry = Registry::load_default().unwrap();
for name in ["react-native-mesh-app", "react-native-mesh-custom-app"] {
assert!(!registry.template(name).unwrap().requires_login, "{name}");
}
for name in [
"react-native-social",
"react-native-id-only",
"nextjs-social",
"react-vite-social",
] {
assert!(registry.template(name).unwrap().requires_login, "{name}");
}
}
#[test]
fn mesh_only_variable_names() {
assert!(mesh_only_variable("mesh_app_id"));
assert!(mesh_only_variable("relay_server_url"));
assert!(!mesh_only_variable("app_id"));
}
#[test]
fn example_app_templates_are_registered() {
let registry = Registry::load_default().unwrap();
for id in offline_registry::EXAMPLE_APP_TEMPLATE_IDS {
let t = registry.template(id).expect(id);
assert!(t.example_app, "{id}");
assert!(t.requires_mesh, "{id}");
assert!(!t.requires_login, "{id}");
}
}
#[test]
fn resolve_target_accepts_example_template_names() {
let registry = Registry::load_default().unwrap();
for id in offline_registry::EXAMPLE_APP_TEMPLATE_IDS {
let (name, _, _) = resolve_target(®istry, id, None).unwrap();
assert_eq!(name, *id);
}
}
#[test]
fn unknown_template_name_includes_example_hint() {
let registry = Registry::load_default().unwrap();
let err = resolve_target(®istry, "not-a-real-template", None).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("orderup"));
}
#[test]
fn variable_default_uses_slug_for_mesh_app_id() {
let var = TemplateVariable {
name: "mesh_app_id".into(),
prompt: "mesh app id".into(),
default: None,
required: true,
};
assert_eq!(
variable_default(&var, "my-offline-app", None).as_deref(),
Some("my-offline-app")
);
}
}