Skip to main content

create_commonpub/
prompts.rs

1use dialoguer::{theme::ColorfulTheme, Confirm, Input, FuzzySelect, MultiSelect};
2
3#[derive(Debug, Clone)]
4pub struct InstanceConfig {
5    pub name: String,
6    pub domain: String,
7    pub description: String,
8    pub database_url: String,
9    pub redis_url: String,
10    pub theme: String,
11    // Feature flags (aligned with @commonpub/config FeatureFlags)
12    pub feature_content: bool,
13    pub feature_social: bool,
14    pub feature_hubs: bool,
15    pub feature_docs: bool,
16    pub feature_video: bool,
17    pub feature_contests: bool,
18    pub feature_learning: bool,
19    pub feature_explainers: bool,
20    pub feature_federation: bool,
21    pub feature_admin: bool,
22    // Contest permissions: "open", "staff", or "admin"
23    pub contest_creation: String,
24    // Content types to enable
25    pub content_types: Vec<String>,
26    // Auth methods
27    pub auth_email_password: bool,
28    pub auth_magic_link: bool,
29    pub auth_passkeys: bool,
30    pub auth_github: bool,
31    pub auth_google: bool,
32    // Infra
33    pub use_docker: bool,
34}
35
36impl InstanceConfig {
37    pub fn with_defaults(name: &str) -> Self {
38        Self {
39            name: sanitize_value(name),
40            domain: format!("{}.localhost", sanitize_value(name)),
41            description: format!("A CommonPub community: {}", sanitize_value(name)),
42            database_url: "postgresql://commonpub:commonpub_dev@localhost:5432/commonpub".to_string(),
43            redis_url: "redis://localhost:6379".to_string(),
44            theme: "base".to_string(),
45            feature_content: true,
46            feature_social: true,
47            feature_hubs: true,
48            feature_docs: true,
49            feature_video: true,
50            feature_contests: false,
51            feature_learning: true,
52            feature_explainers: true,
53            feature_federation: false,
54            feature_admin: false,
55            contest_creation: "admin".to_string(),
56            content_types: vec!["project".to_string(), "article".to_string(), "blog".to_string(), "explainer".to_string()],
57            auth_email_password: true,
58            auth_magic_link: false,
59            auth_passkeys: false,
60            auth_github: false,
61            auth_google: false,
62            use_docker: true,
63        }
64    }
65}
66
67/// Remove control characters and newlines from user input to prevent injection
68pub fn sanitize_value(input: &str) -> String {
69    input
70        .chars()
71        .filter(|c| !c.is_control())
72        .collect::<String>()
73        .replace('\'', "")
74}
75
76pub fn prompt_config(name: &str) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
77    let theme = ColorfulTheme::default();
78
79    println!("\n  ┌─ CommonPub Setup ─────────────────────┐");
80    println!("  │  Let's configure your instance.        │");
81    println!("  └────────────────────────────────────────┘\n");
82
83    // ── Instance identity ──────────────────────────────────
84
85    let instance_name: String = Input::with_theme(&theme)
86        .with_prompt("Instance name")
87        .default(name.to_string())
88        .interact_text()?;
89    let instance_name = sanitize_value(&instance_name);
90
91    let domain: String = Input::with_theme(&theme)
92        .with_prompt("Domain")
93        .default(format!("{}.localhost", name))
94        .interact_text()?;
95    let domain = sanitize_value(&domain);
96
97    let description: String = Input::with_theme(&theme)
98        .with_prompt("Description")
99        .default(format!("A CommonPub community: {}", name))
100        .interact_text()?;
101    let description = sanitize_value(&description);
102
103    // ── Theme ──────────────────────────────────────────────
104
105    let themes = vec!["base", "deepwood", "hackbuild", "deveco"];
106    let theme_idx = FuzzySelect::with_theme(&theme)
107        .with_prompt("Theme")
108        .items(&themes)
109        .default(0)
110        .interact()?;
111
112    // ── Infrastructure ─────────────────────────────────────
113
114    let use_docker = Confirm::with_theme(&theme)
115        .with_prompt("Include Docker Compose? (Postgres, Redis, Meilisearch)")
116        .default(true)
117        .interact()?;
118
119    let database_url: String = if use_docker {
120        "postgresql://commonpub:commonpub_dev@localhost:5432/commonpub".to_string()
121    } else {
122        Input::with_theme(&theme)
123            .with_prompt("Database URL")
124            .default("postgresql://commonpub:commonpub_dev@localhost:5432/commonpub".to_string())
125            .interact_text()?
126    };
127
128    let redis_url: String = if use_docker {
129        "redis://localhost:6379".to_string()
130    } else {
131        Input::with_theme(&theme)
132            .with_prompt("Redis URL")
133            .default("redis://localhost:6379".to_string())
134            .interact_text()?
135    };
136
137    // ── Features ───────────────────────────────────────────
138
139    println!("\n  Features — select what to enable:");
140
141    let feature_items = vec![
142        ("Content system (CRUD, publishing, slugs)", true),
143        ("Social (likes, comments, bookmarks)", true),
144        ("Hubs (communities, feeds, moderation)", true),
145        ("Docs (CodeMirror editor, versioning)", true),
146        ("Video content type", true),
147        ("Contests", false),
148        ("Learning paths (enrollment, progress)", true),
149        ("Explainers (interactive modules)", true),
150        ("Federation (ActivityPub)", false),
151        ("Admin panel (user mgmt, reports)", false),
152    ];
153    let feature_labels: Vec<&str> = feature_items.iter().map(|(l, _)| *l).collect();
154    let feature_defaults: Vec<bool> = feature_items.iter().map(|(_, d)| *d).collect();
155
156    let selected = MultiSelect::with_theme(&theme)
157        .with_prompt("Features")
158        .items(&feature_labels)
159        .defaults(&feature_defaults)
160        .interact()?;
161
162    let feature_content = selected.contains(&0);
163    let feature_social = selected.contains(&1);
164    let feature_hubs = selected.contains(&2);
165    let feature_docs = selected.contains(&3);
166    let feature_video = selected.contains(&4);
167    let feature_contests = selected.contains(&5);
168    let feature_learning = selected.contains(&6);
169    let feature_explainers = selected.contains(&7);
170    let feature_federation = selected.contains(&8);
171    let feature_admin = selected.contains(&9);
172
173    // ── Content types ─────────────────────────────────────
174
175    let content_types = if feature_content {
176        println!("\n  Content types — select what content can be created:");
177
178        let ct_items = vec![
179            ("Projects", true),
180            ("Articles", true),
181            ("Blogs", true),
182            ("Explainers", true),
183        ];
184        let ct_labels: Vec<&str> = ct_items.iter().map(|(l, _)| *l).collect();
185        let ct_defaults: Vec<bool> = ct_items.iter().map(|(_, d)| *d).collect();
186
187        let ct_selected = MultiSelect::with_theme(&theme)
188            .with_prompt("Content types")
189            .items(&ct_labels)
190            .defaults(&ct_defaults)
191            .interact()?;
192
193        let type_names = vec!["project", "article", "blog", "explainer"];
194        ct_selected.iter().map(|&i| type_names[i].to_string()).collect()
195    } else {
196        vec![]
197    };
198
199    // ── Contest permissions ───────────────────────────────
200
201    let contest_creation = if feature_contests {
202        let options = vec!["admin — only admins", "staff — staff and admins", "open — any user"];
203        let idx = FuzzySelect::with_theme(&theme)
204            .with_prompt("Who can create contests?")
205            .items(&options)
206            .default(0)
207            .interact()?;
208        match idx {
209            0 => "admin",
210            1 => "staff",
211            _ => "open",
212        }.to_string()
213    } else {
214        "admin".to_string()
215    };
216
217    // ── Auth methods ───────────────────────────────────────
218
219    println!("\n  Authentication — select sign-in methods:");
220
221    let auth_items = vec![
222        ("Email / password", true),
223        ("Magic link (passwordless email)", false),
224        ("Passkeys (WebAuthn)", false),
225        ("GitHub OAuth", false),
226        ("Google OAuth", false),
227    ];
228    let auth_labels: Vec<&str> = auth_items.iter().map(|(l, _)| *l).collect();
229    let auth_defaults: Vec<bool> = auth_items.iter().map(|(_, d)| *d).collect();
230
231    let auth_selected = MultiSelect::with_theme(&theme)
232        .with_prompt("Auth methods")
233        .items(&auth_labels)
234        .defaults(&auth_defaults)
235        .interact()?;
236
237    let auth_email_password = auth_selected.contains(&0);
238    let auth_magic_link = auth_selected.contains(&1);
239    let auth_passkeys = auth_selected.contains(&2);
240    let auth_github = auth_selected.contains(&3);
241    let auth_google = auth_selected.contains(&4);
242
243    Ok(InstanceConfig {
244        name: instance_name,
245        domain,
246        description,
247        database_url,
248        redis_url,
249        theme: themes[theme_idx].to_string(),
250        feature_content,
251        feature_social,
252        feature_hubs,
253        feature_docs,
254        feature_video,
255        feature_contests,
256        feature_learning,
257        feature_explainers,
258        feature_federation,
259        feature_admin,
260        contest_creation,
261        content_types,
262        auth_email_password,
263        auth_magic_link,
264        auth_passkeys,
265        auth_github,
266        auth_google,
267        use_docker,
268    })
269}