greentic-setup 0.4.28

End-to-end bundle setup engine for the Greentic platform — pack discovery, QA-driven configuration, secrets persistence, and bundle lifecycle management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! Shared Questions Support for multi-provider setup.
//!
//! When setting up multiple providers, some questions (like `public_base_url`)
//! appear in all providers. Instead of asking the same question repeatedly,
//! we identify shared questions and prompt for them once upfront.

use anyhow::Result;
use qa_spec::{FormSpec, QuestionSpec};
use serde_json::{Map as JsonMap, Value};
use std::collections::{HashMap, HashSet};

use crate::qa::prompts::ask_form_spec_question;
use crate::setup_to_formspec;

/// Well-known question IDs that are commonly shared across providers.
///
/// These questions will be prompted once at the beginning of a multi-provider
/// setup wizard, and their answers will be applied to all providers.
pub const SHARED_QUESTION_IDS: &[&str] = &[
    "public_base_url",
    // NOTE: api_base_url is NOT shared - each provider has different API endpoints
    // (e.g., slack.com, telegram.org, webexapis.com)
];

/// Questions hidden from interactive prompts (both terminal and web UI) because
/// they are auto-injected by the operator at runtime (e.g. tunnel URL
/// auto-detection via ngrok/cloudflared). The questions are still accepted if
/// supplied via `--answers` file or prefill.
pub const HIDDEN_FROM_PROMPTS: &[&str] = &["public_base_url"];

/// Information about a provider and its FormSpec for multi-provider setup.
#[derive(Clone)]
pub struct ProviderFormSpec {
    /// Provider identifier (e.g., "messaging-telegram")
    pub provider_id: String,
    /// The FormSpec for this provider
    pub form_spec: FormSpec,
}

/// Result of collecting shared questions across multiple providers.
#[derive(Clone, Default)]
pub struct SharedQuestionsResult {
    /// Questions that appear in multiple providers (deduplicated).
    /// Each question is taken from the first provider that defines it.
    pub shared_questions: Vec<QuestionSpec>,
    /// Provider IDs that contain each shared question ID.
    pub question_providers: HashMap<String, Vec<String>>,
}

/// Collect questions that are shared across multiple providers.
///
/// A question is considered "shared" if:
/// 1. Its ID is in `SHARED_QUESTION_IDS`, OR
/// 2. It appears in 2+ providers with the same ID
///
/// Returns deduplicated questions (taking the first occurrence) along with
/// which providers contain each question.
pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
    if providers.len() <= 1 {
        return SharedQuestionsResult::default();
    }

    // Count occurrences of each question ID across providers.
    // Store counts only (not provider IDs) to avoid excessive cloning/allocation
    // for questions that are not ultimately shared.
    let mut question_count: HashMap<String, usize> = HashMap::new();
    let mut first_question: HashMap<String, QuestionSpec> = HashMap::new();

    for provider in providers {
        for question in &provider.form_spec.questions {
            if question.id.is_empty() {
                continue;
            }
            *question_count.entry(question.id.clone()).or_insert(0) += 1;

            // Keep the first occurrence of each question
            first_question
                .entry(question.id.clone())
                .or_insert_with(|| question.clone());
        }
    }

    // Find shared questions (must appear in 2+ providers to be truly shared)
    // SHARED_QUESTION_IDS are hints for what questions are commonly shared,
    // but we only share them if they actually appear in multiple providers.
    //
    // IMPORTANT: Exclude secrets and provider-specific fields from sharing.
    // Each provider needs unique values for these fields.
    let mut shared_questions = Vec::new();
    let mut question_providers = HashMap::new();

    fn is_never_shared(question_id: &str) -> bool {
        matches!(
            question_id,
            "api_base_url"
                | "bot_token"
                | "access_token"
                | "token"
                | "app_id"
                | "app_secret"
                | "client_id"
                | "client_secret"
                | "webhook_secret"
                | "signing_secret"
        )
    }

    let mut shared_ids = HashSet::new();
    for (question_id, count) in &question_count {
        // Only share questions that actually appear in 2+ providers
        if *count >= 2
            && let Some(question) = first_question.get(question_id)
        {
            // Skip secrets - they should never be shared across providers
            if question.secret {
                continue;
            }

            // Skip provider-specific fields that happen to have the same ID
            if is_never_shared(question_id) {
                continue;
            }

            shared_questions.push(question.clone());
            question_providers.insert(question_id.clone(), Vec::new());
            shared_ids.insert(question_id.clone());
        }
    }

    if !shared_ids.is_empty() {
        for provider in providers {
            for question in &provider.form_spec.questions {
                if shared_ids.contains(&question.id)
                    && let Some(provider_ids) = question_providers.get_mut(&question.id)
                {
                    provider_ids.push(provider.provider_id.clone());
                }
            }
        }
    }

    // Sort by question ID for deterministic ordering
    shared_questions.sort_by(|a, b| a.id.cmp(&b.id));

    SharedQuestionsResult {
        shared_questions,
        question_providers,
    }
}

/// Prompt for shared questions that apply to multiple providers.
///
/// Takes existing answers from loaded setup file and only prompts for
/// questions that don't already have a valid (non-empty) value.
pub fn prompt_shared_questions(
    shared: &SharedQuestionsResult,
    advanced: bool,
    existing_answers: &Value,
) -> Result<Value> {
    if shared.shared_questions.is_empty() {
        return Ok(Value::Object(JsonMap::new()));
    }

    let existing_map = existing_answers.as_object();

    // Check if all shared questions already have valid answers
    let questions_needing_prompt: Vec<_> = shared
        .shared_questions
        .iter()
        .filter(|q| {
            // Skip questions hidden from interactive prompts (auto-injected by operator)
            if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
                return false;
            }
            // Skip optional questions in normal mode
            if !advanced && !q.required {
                return false;
            }
            // Check if this question already has a non-empty value
            if let Some(map) = existing_map
                && let Some(value) = map.get(&q.id)
            {
                // Skip if value is non-null and non-empty string
                if !value.is_null() {
                    if let Some(s) = value.as_str() {
                        return s.is_empty(); // Need prompt if empty string
                    }
                    return false; // Has value, skip
                }
            }
            true // Need prompt
        })
        .collect();

    // If no questions need prompting, return existing answers
    if questions_needing_prompt.is_empty() {
        let mut answers = JsonMap::new();
        if let Some(map) = existing_map {
            for question in &shared.shared_questions {
                if let Some(value) = map.get(&question.id) {
                    answers.insert(question.id.clone(), value.clone());
                }
            }
        }
        return Ok(Value::Object(answers));
    }

    println!("\n── Shared Configuration ──");
    println!("The following settings apply to all providers:\n");

    let mut answers = JsonMap::new();

    // Copy existing values first
    if let Some(map) = existing_map {
        for question in &shared.shared_questions {
            if let Some(value) = map.get(&question.id)
                && !value.is_null()
                && !(value.is_string() && value.as_str() == Some(""))
            {
                answers.insert(question.id.clone(), value.clone());
            }
        }
    }

    for question in &shared.shared_questions {
        // Skip questions hidden from interactive prompts (auto-injected by operator)
        if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
            continue;
        }

        // Skip if we already have a valid answer
        if answers.contains_key(&question.id) {
            continue;
        }

        // Skip optional questions in normal mode
        if !advanced && !question.required {
            continue;
        }

        // Show which providers use this question
        if let Some(provider_ids) = shared.question_providers.get(&question.id) {
            let providers_str = provider_ids
                .iter()
                .map(|id| setup_to_formspec::strip_domain_prefix(id))
                .collect::<Vec<_>>()
                .join(", ");
            println!("  Used by: {providers_str}");
        }

        if let Some(value) = ask_form_spec_question(question)? {
            answers.insert(question.id.clone(), value);
        }
    }

    println!();
    Ok(Value::Object(answers))
}

/// Merge shared answers with provider-specific answers.
///
/// Shared answers take precedence for non-empty values, but provider-specific
/// answers can override if the shared value is empty.
pub fn merge_shared_with_provider_answers(
    shared: &Value,
    provider_specific: Option<&Value>,
) -> Value {
    let mut merged = JsonMap::new();

    // Start with shared answers
    if let Some(shared_map) = shared.as_object() {
        for (key, value) in shared_map {
            // Only include non-empty values
            if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
                merged.insert(key.clone(), value.clone());
            }
        }
    }

    // Add provider-specific answers (don't override non-empty shared values)
    if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
        for (key, value) in provider_map {
            // Only add if not already present with a non-empty value
            if !merged.contains_key(key) {
                merged.insert(key.clone(), value.clone());
            }
        }
    }

    Value::Object(merged)
}

/// Build FormSpecs for multiple providers from their pack paths.
///
/// Convenience function to prepare input for `collect_shared_questions`.
pub fn build_provider_form_specs(
    providers: &[(std::path::PathBuf, String)], // (pack_path, provider_id)
) -> Vec<ProviderFormSpec> {
    providers
        .iter()
        .filter_map(|(pack_path, provider_id)| {
            setup_to_formspec::pack_to_form_spec(pack_path, provider_id).map(|form_spec| {
                ProviderFormSpec {
                    provider_id: provider_id.clone(),
                    form_spec,
                }
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use qa_spec::QuestionType;

    fn make_provider_form_spec(provider_id: &str, question_ids: &[&str]) -> ProviderFormSpec {
        let questions = question_ids
            .iter()
            .map(|id| QuestionSpec {
                id: id.to_string(),
                kind: QuestionType::String,
                title: format!("{} Question", id),
                title_i18n: None,
                description: None,
                description_i18n: None,
                required: true,
                choices: None,
                default_value: None,
                secret: false,
                visible_if: None,
                constraint: None,
                list: None,
                computed: None,
                policy: Default::default(),
                computed_overridable: false,
            })
            .collect();

        ProviderFormSpec {
            provider_id: provider_id.to_string(),
            form_spec: FormSpec {
                id: format!("{}-setup", provider_id),
                title: format!("{} Setup", provider_id),
                version: "1.0.0".into(),
                description: None,
                presentation: None,
                progress_policy: None,
                secrets_policy: None,
                store: vec![],
                validations: vec![],
                includes: vec![],
                questions,
            },
        }
    }

    #[test]
    fn collect_shared_questions_finds_common_questions() {
        let providers = vec![
            make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
            make_provider_form_spec("messaging-slack", &["public_base_url", "slack_token"]),
            make_provider_form_spec("messaging-teams", &["public_base_url", "teams_app_id"]),
        ];

        let result = collect_shared_questions(&providers);

        // public_base_url appears in all 3 providers
        assert_eq!(result.shared_questions.len(), 1);
        assert_eq!(result.shared_questions[0].id, "public_base_url");

        // Check provider mapping
        let providers_for_url = result.question_providers.get("public_base_url").unwrap();
        assert_eq!(providers_for_url.len(), 3);
        assert!(providers_for_url.contains(&"messaging-telegram".to_string()));
        assert!(providers_for_url.contains(&"messaging-slack".to_string()));
        assert!(providers_for_url.contains(&"messaging-teams".to_string()));
    }

    #[test]
    fn collect_shared_questions_excludes_single_provider_questions() {
        let providers = vec![
            make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
            make_provider_form_spec("messaging-slack", &["slack_token"]), // no public_base_url
        ];

        let result = collect_shared_questions(&providers);
        assert!(result.shared_questions.is_empty());
    }

    #[test]
    fn collect_shared_questions_returns_empty_for_single_provider() {
        let providers = vec![make_provider_form_spec(
            "messaging-telegram",
            &["public_base_url", "bot_token"],
        )];

        let result = collect_shared_questions(&providers);
        assert!(result.shared_questions.is_empty());
    }

    #[test]
    fn collect_shared_questions_finds_non_wellknown_duplicates() {
        let providers = vec![
            make_provider_form_spec("provider-a", &["custom_field", "field_a"]),
            make_provider_form_spec("provider-b", &["custom_field", "field_b"]),
        ];

        let result = collect_shared_questions(&providers);
        assert_eq!(result.shared_questions.len(), 1);
        assert_eq!(result.shared_questions[0].id, "custom_field");
    }

    #[test]
    fn collect_shared_questions_deduplicates() {
        let providers = vec![
            make_provider_form_spec("provider-a", &["public_base_url"]),
            make_provider_form_spec("provider-b", &["public_base_url"]),
            make_provider_form_spec("provider-c", &["public_base_url"]),
        ];

        let result = collect_shared_questions(&providers);
        assert_eq!(result.shared_questions.len(), 1);
    }
}