Skip to main content

stackless_stripe_projects/
project.rs

1//! Stripe Projects orchestration: project anchor, per-instance environments,
2//! resource add/remove, env materialization, and spend reporting.
3
4use std::collections::BTreeMap;
5use std::path::Path;
6use std::time::Duration;
7
8use serde_json::Value;
9use stackless_core::def::StackDef;
10
11use crate::error::ProjectsError;
12use crate::responses::{
13    EnvListResponse, ServiceRef, ServicesListResponse, StatusResponse, preflight_checks_from_parts,
14};
15use crate::stripe::{CommandRunner, StripeProjects};
16
17/// Result of [`add_resource`]: the Stripe local name that was env-attached and
18/// the `projects add` payload (null on reuse).
19#[derive(Debug)]
20pub struct AddedResource {
21    pub name: String,
22    pub data: Value,
23}
24
25/// Shared flags for `init --preflight` (doctor prefixes `projects` / `--json`;
26/// [`run_init_preflight`] uses `stripe.json` which supplies those).
27pub const INIT_PREFLIGHT_FLAGS: &[&str] =
28    &["--preflight", "--skip-skills", "--accept-tos", "--yes"];
29
30/// The recorded Stripe Projects anchor from `[stack.projects.stripe].project`.
31pub fn recorded_project_id(def: &StackDef) -> Option<String> {
32    def.stack
33        .projects
34        .stripe
35        .as_ref()
36        .and_then(|stripe| stripe.project.clone())
37}
38
39pub async fn ensure_project<R: CommandRunner>(
40    stripe: &StripeProjects<R>,
41    def: &StackDef,
42    definition_dir: &Path,
43) -> Result<(), ProjectsError> {
44    let recorded = recorded_project_id(def);
45    let status = stripe.json(&["status"]).await?;
46    let linked = serde_json::from_value::<StatusResponse>(status.data)
47        .ok()
48        .and_then(|s| s.project_id().map(str::to_owned));
49
50    match (&recorded, &linked) {
51        (Some(want), Some(have)) if want == have => Ok(()),
52        (Some(want), _) => {
53            stripe
54                .run_ok(
55                    "pull",
56                    &["pull", want, "--skip-skills", "--yes"],
57                    &["--yes"],
58                )
59                .await?;
60            Ok(())
61        }
62        (None, Some(have)) => {
63            write_project_anchor(definition_dir, have)?;
64            Ok(())
65        }
66        (None, None) => {
67            run_init_preflight(stripe, def.stack.name.as_str()).await?;
68            stripe
69                .run_ok(
70                    "init",
71                    &[
72                        "init",
73                        def.stack.name.as_str(),
74                        "--skip-skills",
75                        "--accept-tos",
76                    ],
77                    &["--accept-tos", "--yes"],
78                )
79                .await?;
80            let status = stripe.json(&["status"]).await?;
81            let id = serde_json::from_value::<StatusResponse>(status.data)
82                .ok()
83                .and_then(|s| s.project_id().map(str::to_owned))
84                .ok_or_else(|| ProjectsError::ProjectAnchor {
85                    detail: "created project but status reported no id".into(),
86                })?;
87            write_project_anchor(definition_dir, &id)?;
88            Ok(())
89        }
90    }
91}
92
93pub fn write_project_anchor(definition_dir: &Path, project_id: &str) -> Result<(), ProjectsError> {
94    let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(definition_dir);
95    let _guard = stackless_core::lockfile::FileLock::acquire_with_wait(
96        &lock_path,
97        Duration::from_secs(30 * 60),
98    )
99    .map_err(|err| ProjectsError::LockHeld {
100        definition_dir: definition_dir.display().to_string(),
101        detail: err.to_string(),
102    })?;
103    let path = definition_dir.join("stackless.toml");
104    let text = std::fs::read_to_string(&path).map_err(|err| ProjectsError::ProjectAnchor {
105        detail: format!("cannot read {}: {err}", path.display()),
106    })?;
107    let mut doc =
108        text.parse::<toml_edit::DocumentMut>()
109            .map_err(|err| ProjectsError::ProjectAnchor {
110                detail: format!("cannot parse {}: {err}", path.display()),
111            })?;
112    let stack = doc["stack"].or_insert(toml_edit::table());
113    if let Some(stack_table) = stack.as_table_mut() {
114        stack_table.set_implicit(false);
115    }
116    let projects = doc["stack"]["projects"].or_insert(toml_edit::table());
117    if let Some(projects_table) = projects.as_table_mut() {
118        projects_table.set_implicit(false);
119    }
120    let stripe = doc["stack"]["projects"]["stripe"].or_insert(toml_edit::table());
121    if let Some(stripe_table) = stripe.as_table_mut() {
122        stripe_table.set_implicit(false);
123    }
124    doc["stack"]["projects"]["stripe"]["project"] = toml_edit::value(project_id);
125    std::fs::write(&path, doc.to_string()).map_err(|err| ProjectsError::ProjectAnchor {
126        detail: format!("cannot write {}: {err}", path.display()),
127    })?;
128    Ok(())
129}
130
131pub async fn ensure_environment<R: CommandRunner>(
132    stripe: &StripeProjects<R>,
133    instance: &str,
134) -> Result<(), ProjectsError> {
135    let list = stripe.json(&["env", "list"]).await?;
136    let exists = serde_json::from_value::<EnvListResponse>(list.data)
137        .map(|response| response.contains(instance))
138        .unwrap_or(false);
139    if exists {
140        stripe
141            .run_ok("env use", &["env", "use", instance], &["--yes"])
142            .await?;
143    } else {
144        let output = format!(".env.{instance}");
145        stripe
146            .run_ok(
147                "env create",
148                &["env", "create", instance, "--output", &output, "--yes"],
149                &["--yes"],
150            )
151            .await?;
152    }
153    Ok(())
154}
155
156async fn list_project_resources<R: CommandRunner>(
157    stripe: &StripeProjects<R>,
158) -> Result<ServicesListResponse, ProjectsError> {
159    let result = stripe.json(&["services", "list"]).await?;
160    if !result.ok {
161        return Ok(ServicesListResponse::default());
162    }
163    Ok(serde_json::from_value::<ServicesListResponse>(result.data).unwrap_or_default())
164}
165
166/// Whether a service or plan with this exact local name is already on the project.
167pub async fn resource_registered<R: CommandRunner>(
168    stripe: &StripeProjects<R>,
169    name: &str,
170) -> Result<bool, ProjectsError> {
171    Ok(list_project_resources(stripe).await?.contains(name))
172}
173
174/// Split a `provider/service` reference into `(provider, catalog_service_id)`.
175fn split_reference(reference: &str) -> (&str, &str) {
176    reference.split_once('/').unwrap_or(("", reference))
177}
178
179fn provider_matches(row: &ServiceRef, provider: &str) -> bool {
180    row.provider_name
181        .as_deref()
182        .is_some_and(|have| have.eq_ignore_ascii_case(provider))
183}
184
185/// Resolve an existing service/plan to reuse for `reference`.
186///
187/// Prefers an exact local `--name` match that is also provider-scoped and
188/// catalog-`service_id`-scoped. Otherwise takes the sole provider-scoped row
189/// whose catalog `service_id` equals the reference's service id. Ambiguous
190/// multi-matches fall through (no invented ranking). Rows without
191/// `provider_name` / `service_id` never match.
192fn resolve_reusable<'a>(
193    list: &'a ServicesListResponse,
194    name: &str,
195    reference: &str,
196) -> Option<&'a str> {
197    let (provider, catalog_id) = split_reference(reference);
198    if provider.is_empty() {
199        return None;
200    }
201    let same_catalog = |r: &&ServiceRef| r.service_id.as_deref() == Some(catalog_id);
202    let exact: Vec<&str> = list
203        .iter()
204        .filter(|r| provider_matches(r, provider))
205        .filter(same_catalog)
206        .filter_map(|r| r.name.as_deref().filter(|n| *n == name))
207        .collect();
208    if exact.len() == 1 {
209        return Some(exact[0]);
210    }
211    if !exact.is_empty() {
212        return None;
213    }
214    let by_id: Vec<&str> = list
215        .iter()
216        .filter(|r| provider_matches(r, provider))
217        .filter(same_catalog)
218        .filter_map(|r| r.name.as_deref())
219        .collect();
220    if by_id.len() == 1 {
221        Some(by_id[0])
222    } else {
223        None
224    }
225}
226
227/// Resolve an existing service/plan to reuse (exact name, else catalog `service_id`),
228/// always scoped to the provider in `reference`.
229pub async fn resolve_registered_resource<R: CommandRunner>(
230    stripe: &StripeProjects<R>,
231    name: &str,
232    reference: &str,
233) -> Result<Option<String>, ProjectsError> {
234    Ok(
235        resolve_reusable(&list_project_resources(stripe).await?, name, reference)
236            .map(str::to_owned),
237    )
238}
239
240async fn env_add_resource<R: CommandRunner>(
241    stripe: &StripeProjects<R>,
242    name: &str,
243) -> Result<(), ProjectsError> {
244    stripe
245        .run_ok(
246            &format!("env add {name}"),
247            &["env", "add", name, "--resource"],
248            &["--yes"],
249        )
250        .await?;
251    Ok(())
252}
253
254pub async fn add_resource<R: CommandRunner>(
255    stripe: &StripeProjects<R>,
256    reference: &str,
257    name: &str,
258    config: &Value,
259    paid: bool,
260) -> Result<AddedResource, ProjectsError> {
261    // Reuse orphan plans/services (including `plans[]`) and always env-attach.
262    // Returning early without `env add` left parent plans project-scoped after
263    // teardown and forced another `projects add` into Stripe 500s.
264    if let Some(existing) = resolve_registered_resource(stripe, name, reference).await? {
265        env_add_resource(stripe, &existing).await?;
266        return Ok(AddedResource {
267            name: existing,
268            data: Value::Null,
269        });
270    }
271    let config_str = config.to_string();
272    let mut args: Vec<&str> = vec![
273        "add",
274        reference,
275        "--name",
276        name,
277        "--config",
278        &config_str,
279        "--accept-tos",
280        "--yes",
281    ];
282    if paid {
283        args.push("--confirm-paid-service");
284    }
285    let plain_extra = if paid {
286        vec!["--accept-tos", "--yes", "--confirm-paid-service"]
287    } else {
288        vec!["--accept-tos", "--yes"]
289    };
290    let data = stripe
291        .run_ok(&format!("add {reference}"), &args, &plain_extra)
292        .await?;
293    env_add_resource(stripe, name).await?;
294    Ok(AddedResource {
295        name: name.to_owned(),
296        data,
297    })
298}
299
300pub async fn refreshed_env_value<R: CommandRunner>(
301    stripe: &StripeProjects<R>,
302    service_reference: &str,
303    key: &str,
304) -> Result<Option<String>, ProjectsError> {
305    let data = stripe
306        .run_ok(
307            "env",
308            &["env", "--service", service_reference, "--refresh"],
309            &["--yes"],
310        )
311        .await?;
312    Ok(find_env_value(&data, key))
313}
314
315pub async fn pull_env_value<R: CommandRunner>(
316    stripe: &StripeProjects<R>,
317    instance: &str,
318    key: &str,
319) -> Result<Option<String>, ProjectsError> {
320    Ok(pull_env_values(stripe, instance, &[key])
321        .await?
322        .into_iter()
323        .next()
324        .flatten())
325}
326
327/// Pull the instance's env once and read several keys from it, returning one
328/// `Option<String>` per key in input order. Values are read from on-disk vault
329/// files after `env --pull` — the plugin still redacts values in JSON at 0.23.0.
330pub async fn pull_env_values<R: CommandRunner>(
331    stripe: &StripeProjects<R>,
332    instance: &str,
333    keys: &[&str],
334) -> Result<Vec<Option<String>>, ProjectsError> {
335    refresh_vault(stripe).await?;
336    let map = vault_env_from_dir(stripe.dir(), Some(instance));
337    let values = keys.iter().map(|&key| map.get(key).cloned()).collect();
338    Ok(values)
339}
340
341pub fn find_env_value(value: &Value, key: &str) -> Option<String> {
342    match value {
343        Value::Object(map) => {
344            if let Some(found) = map.get(key).and_then(Value::as_str)
345                && !is_redacted(found)
346            {
347                return Some(found.to_owned());
348            }
349            let named_key = map
350                .get("key")
351                .or_else(|| map.get("name"))
352                .and_then(Value::as_str);
353            if named_key == Some(key)
354                && let Some(found) = map.get("value").and_then(Value::as_str)
355                && !is_redacted(found)
356            {
357                return Some(found.to_owned());
358            }
359            map.values().find_map(|child| find_env_value(child, key))
360        }
361        Value::Array(values) => values.iter().find_map(|child| find_env_value(child, key)),
362        _ => None,
363    }
364}
365
366fn is_redacted(value: &str) -> bool {
367    let lower = value.to_ascii_lowercase();
368    value.contains('•')
369        || value.contains('*')
370        || lower.contains("redacted")
371        || lower.contains("hidden")
372}
373
374pub fn unquote_env_value(value: &str) -> String {
375    let bytes = value.as_bytes();
376    if bytes.len() >= 2
377        && ((bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
378            || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"'))
379    {
380        value[1..value.len() - 1].to_owned()
381    } else {
382        value.to_owned()
383    }
384}
385
386pub async fn remove_resource<R: CommandRunner>(
387    stripe: &StripeProjects<R>,
388    resource: &str,
389) -> Result<(), ProjectsError> {
390    // Idempotent teardown: a resource that is no longer registered is already
391    // gone, and `stripe projects remove` would fail it with RESOURCE_NOT_FOUND.
392    // Skipping keeps `down` retryable (the engine re-runs destroy on survivors).
393    if !resource_registered(stripe, resource).await? {
394        return Ok(());
395    }
396    stripe
397        .run_ok(
398            &format!("remove {resource}"),
399            &["remove", resource, "--yes", "--force"],
400            &["--yes", "--force"],
401        )
402        .await?;
403    Ok(())
404}
405
406pub async fn delete_environment<R: CommandRunner>(
407    stripe: &StripeProjects<R>,
408    instance: &str,
409) -> Result<(), ProjectsError> {
410    stripe
411        .run_ok(
412            &format!("env delete {instance}"),
413            &["env", "delete", instance, "--yes"],
414            &["--yes"],
415        )
416        .await?;
417    Ok(())
418}
419
420pub async fn set_spend_cap<R: CommandRunner>(
421    stripe: &StripeProjects<R>,
422    limit_usd: u32,
423    provider: &str,
424) -> Result<(), ProjectsError> {
425    let limit = limit_usd.to_string();
426    stripe
427        .run_ok(
428            "billing update",
429            &[
430                "billing",
431                "update",
432                "--limit",
433                &limit,
434                "--provider",
435                provider,
436                "--yes",
437            ],
438            &["--yes"],
439        )
440        .await?;
441    Ok(())
442}
443
444pub async fn spend_summary<R: CommandRunner>(
445    stripe: &StripeProjects<R>,
446    provider: Option<&str>,
447) -> Option<String> {
448    let result = match provider {
449        None => stripe.json(&["spend"]).await.ok()?,
450        Some(p) => stripe.json(&["spend", p]).await.ok()?,
451    };
452    if !result.ok {
453        return None;
454    }
455    Some(result.data.to_string())
456}
457
458/// Run `init --preflight` before project creation so auth/eligibility blockers
459/// surface once instead of mid-init. Uses the same consent flags as real `init`.
460pub async fn run_init_preflight<R: CommandRunner>(
461    stripe: &StripeProjects<R>,
462    stack_name: &str,
463) -> Result<(), ProjectsError> {
464    let mut args = Vec::with_capacity(2 + INIT_PREFLIGHT_FLAGS.len());
465    args.push("init");
466    args.push(stack_name);
467    args.extend_from_slice(INIT_PREFLIGHT_FLAGS);
468    let result = stripe.json(&args).await?;
469    if result.ok {
470        return Ok(());
471    }
472    Err(preflight_failure("init", &result))
473}
474
475async fn refresh_vault<R: CommandRunner>(stripe: &StripeProjects<R>) -> Result<(), ProjectsError> {
476    stripe
477        .run_ok("env --pull", &["env", "--pull", "--refresh"], &["--yes"])
478        .await?;
479    Ok(())
480}
481
482const EMPTY_ENV_PULL_CODE: &str = "PROJECT_ENVIRONMENT_HAS_NO_RESOURCES";
483
484/// Select the target instance environment, then refresh its vault files.
485///
486/// An environment that exists but has no resources/variables yet
487/// (`PROJECT_ENVIRONMENT_HAS_NO_RESOURCES`) is a soft success: first `up` /
488/// post-`down` re-`up` must not fail before integrations can re-provision.
489/// Clears a stale `.env.<instance>` so prior credentials cannot leak.
490///
491/// Post-provision pulls via [`refresh_vault`] stay strict.
492pub async fn sync_vault_pull_for_instance<R: CommandRunner>(
493    stripe: &StripeProjects<R>,
494    instance: &str,
495) -> Result<(), ProjectsError> {
496    ensure_environment(stripe, instance).await?;
497    match refresh_vault(stripe).await {
498        Ok(()) => Ok(()),
499        Err(ProjectsError::Failed { detail, .. }) if detail.contains(EMPTY_ENV_PULL_CODE) => {
500            clear_stale_instance_env(stripe.dir(), instance);
501            Ok(())
502        }
503        Err(err) => Err(err),
504    }
505}
506
507fn clear_stale_instance_env(definition_dir: &Path, instance: &str) {
508    let path = definition_dir.join(format!(".env.{instance}"));
509    let _ = std::fs::remove_file(&path);
510}
511
512/// Whether `.projects/` exists under `definition_dir` (created by `init`).
513/// Vault pull before first `up` must skip until this exists.
514pub fn project_initialized_in_dir(definition_dir: &Path) -> bool {
515    definition_dir.join(".projects").is_dir()
516}
517
518/// Read env keys from pulled vault files. Scans `.env` then `.env.<instance>`
519/// (instance overrides base). Does not call the Stripe CLI.
520pub fn vault_env_from_dir(
521    definition_dir: &Path,
522    instance: Option<&str>,
523) -> BTreeMap<String, String> {
524    let mut out = BTreeMap::new();
525    let base = definition_dir.join(".env");
526    if let Ok(text) = std::fs::read_to_string(&base) {
527        merge_env_lines(&mut out, &text);
528    }
529    if let Some(instance) = instance {
530        let inst = definition_dir.join(format!(".env.{instance}"));
531        if let Ok(text) = std::fs::read_to_string(inst) {
532            merge_env_lines(&mut out, &text);
533        }
534    }
535    out
536}
537
538/// Parse `KEY=VALUE` lines into `out` (comments and blank lines skipped).
539pub fn merge_env_lines(out: &mut BTreeMap<String, String>, text: &str) {
540    for line in text.lines() {
541        let line = line.trim();
542        if line.is_empty() || line.starts_with('#') {
543            continue;
544        }
545        let Some((key, value)) = line.split_once('=') else {
546            continue;
547        };
548        out.insert(key.trim().to_owned(), unquote_env_value(value.trim()));
549    }
550}
551
552fn preflight_failure(command: &str, result: &crate::stripe::StripeResult) -> ProjectsError {
553    let checks =
554        preflight_checks_from_parts(&result.data, result.ok, result.error_details.as_ref());
555    let detail = if checks.iter().any(|c| !c.pass) {
556        checks
557            .iter()
558            .filter(|c| !c.pass)
559            .map(|c| {
560                let remedy = c.remedy.as_deref().unwrap_or("");
561                if remedy.is_empty() {
562                    c.label.clone()
563                } else {
564                    format!("{} — {remedy}", c.label)
565                }
566            })
567            .collect::<Vec<_>>()
568            .join("; ")
569    } else {
570        result
571            .error_message
572            .clone()
573            .unwrap_or_else(|| "preflight blocked".into())
574    };
575    ProjectsError::Failed {
576        command: command.to_owned(),
577        detail,
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::stripe::{CommandOutput, CommandRunner, StripeProjects};
585    use crate::test_support::{
586        ScriptedRunner, env_list, ok, ok_empty, plans, service_rows, services,
587    };
588    use async_trait::async_trait;
589    use serde_json::json;
590
591    #[test]
592    fn anchor_writeback_preserves_comments_and_adds_neutral_project() {
593        let dir = tempfile::tempdir().unwrap();
594        let path = dir.path().join("stackless.toml");
595        std::fs::write(
596            &path,
597            "# atto dogfood\n[stack]\nname = \"atto\"\n\n[stack.render]\nregion = \"oregon\"\n",
598        )
599        .unwrap();
600
601        write_project_anchor(dir.path(), "project_abc123").unwrap();
602
603        let after = std::fs::read_to_string(&path).unwrap();
604        assert!(after.contains("# atto dogfood"));
605        assert!(after.contains("project = \"project_abc123\""));
606
607        let doc: toml::Value = toml::from_str(&after).unwrap();
608        assert_eq!(
609            doc["stack"]["projects"]["stripe"]["project"].as_str(),
610            Some("project_abc123")
611        );
612    }
613
614    #[tokio::test]
615    async fn add_resource_propagates_env_add_failure() {
616        struct FailEnvAddRunner;
617
618        #[async_trait]
619        impl CommandRunner for FailEnvAddRunner {
620            async fn run(
621                &self,
622                args: &[String],
623                _cwd: &std::path::Path,
624            ) -> Result<CommandOutput, ProjectsError> {
625                if args.iter().any(|a| a == "list") {
626                    return Ok(CommandOutput {
627                        status: 0,
628                        stdout: r#"{"ok":true,"data":{"services":[]}}"#.into(),
629                        stderr: String::new(),
630                    });
631                }
632                if args.iter().any(|a| a == "add") && args.iter().any(|a| a == "--resource") {
633                    return Ok(CommandOutput {
634                        status: 0,
635                        stdout: r#"{"ok":false,"error":{"message":"member missing"}}"#.into(),
636                        stderr: String::new(),
637                    });
638                }
639                Ok(CommandOutput {
640                    status: 0,
641                    stdout: r#"{"ok":true,"data":{}}"#.into(),
642                    stderr: String::new(),
643                })
644            }
645        }
646
647        let stripe = StripeProjects::new(FailEnvAddRunner, std::env::temp_dir());
648        let err = add_resource(
649            &stripe,
650            "render/static-site",
651            "demo-web",
652            &serde_json::json!({}),
653            false,
654        )
655        .await
656        .unwrap_err();
657        assert!(matches!(err, ProjectsError::Failed { .. }));
658    }
659
660    #[test]
661    fn vault_env_from_dir_prefers_instance_file() {
662        let dir = tempfile::tempdir().unwrap();
663        std::fs::write(dir.path().join(".env"), "KEY=base\n").unwrap();
664        std::fs::write(dir.path().join(".env.demo"), "KEY=instance\n").unwrap();
665        let map = vault_env_from_dir(dir.path(), Some("demo"));
666        assert_eq!(map.get("KEY").map(String::as_str), Some("instance"));
667    }
668
669    #[tokio::test]
670    async fn add_resource_env_attaches_when_already_registered() {
671        let runner = ScriptedRunner::new(vec![
672            service_rows(&[("atto-cloud-web", "static-site", "render")]), // list
673            ok_empty(),                                                   // env add --resource
674        ]);
675        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
676        let added = add_resource(
677            &stripe,
678            "render/static-site",
679            "atto-cloud-web",
680            &serde_json::json!({}),
681            false,
682        )
683        .await
684        .unwrap();
685        assert_eq!(added.name, "atto-cloud-web");
686        let calls = runner.calls();
687        assert_eq!(calls.len(), 2);
688        assert!(calls[0].windows(2).any(|w| w == ["services", "list"]));
689        assert!(calls[1].iter().any(|a| a == "--resource"));
690        assert!(
691            !calls
692                .iter()
693                .any(|c| c.iter().any(|a| a == "render/static-site")),
694            "must not projects-add when already registered"
695        );
696    }
697
698    #[tokio::test]
699    async fn add_resource_reuses_plan_by_name_without_projects_add() {
700        let runner = ScriptedRunner::new(vec![
701            plans(&[("hobby", "hobby", "Clerk")]), // list: orphan plan
702            ok_empty(),                            // env add hobby
703        ]);
704        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
705        let added = add_resource(
706            &stripe,
707            "clerk/hobby",
708            "hobby",
709            &serde_json::json!({}),
710            false,
711        )
712        .await
713        .unwrap();
714        assert_eq!(added.name, "hobby");
715        let calls = runner.calls();
716        assert_eq!(calls.len(), 2);
717        assert!(
718            !calls.iter().any(|c| c.iter().any(|a| a == "clerk/hobby")),
719            "must not projects-add clerk/hobby when plan hobby exists"
720        );
721        assert_eq!(
722            calls[1],
723            vec!["env", "add", "hobby", "--resource", "--json"]
724        );
725    }
726
727    #[tokio::test]
728    async fn add_resource_reuses_plan_by_service_id_without_projects_add() {
729        let runner = ScriptedRunner::new(vec![
730            plans(&[("clerk-plan", "hobby", "Clerk")]), // list: renamed plan
731            ok_empty(),                                 // env add clerk-plan
732        ]);
733        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
734        let added = add_resource(
735            &stripe,
736            "clerk/hobby",
737            "hobby",
738            &serde_json::json!({}),
739            false,
740        )
741        .await
742        .unwrap();
743        assert_eq!(added.name, "clerk-plan");
744        let calls = runner.calls();
745        assert_eq!(calls.len(), 2);
746        assert!(
747            !calls.iter().any(|c| c.iter().any(|a| a == "clerk/hobby")),
748            "must not projects-add when a service_id=hobby plan exists"
749        );
750        assert_eq!(
751            calls[1],
752            vec!["env", "add", "clerk-plan", "--resource", "--json"]
753        );
754    }
755
756    #[tokio::test]
757    async fn add_resource_reuses_deployable_by_catalog_service_id() {
758        let runner = ScriptedRunner::new(vec![
759            ok(json!({
760                "services": [{
761                    "name": "clerk-auth",
762                    "service_id": "auth",
763                    "provider_name": "Clerk"
764                }],
765                "plans": [{
766                    "name": "hobby",
767                    "service_id": "hobby",
768                    "provider_name": "Clerk"
769                }]
770            })),
771            ok_empty(), // env add clerk-auth
772        ]);
773        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
774        let added = add_resource(
775            &stripe,
776            "clerk/auth",
777            "e2e-clerk",
778            &serde_json::json!({"app_name": "jinttai-e2e"}),
779            false,
780        )
781        .await
782        .unwrap();
783        assert_eq!(added.name, "clerk-auth");
784        let calls = runner.calls();
785        assert_eq!(calls.len(), 2);
786        assert!(
787            !calls.iter().any(|c| c.iter().any(|a| a == "clerk/auth")),
788            "must not projects-add clerk/auth when clerk-auth already exists"
789        );
790        assert_eq!(
791            calls[1],
792            vec!["env", "add", "clerk-auth", "--resource", "--json"]
793        );
794    }
795
796    #[tokio::test]
797    async fn add_resource_does_not_reuse_other_provider_same_service_id() {
798        let runner = ScriptedRunner::new(vec![
799            ok(json!({
800                "services": [{
801                    "name": "demo-db",
802                    "service_id": "postgres",
803                    "provider_name": "Neon"
804                }],
805                "plans": []
806            })),
807            ok(json!({ "variables": { "K": "v" } })), // projects add
808            ok_empty(),                               // env add
809        ]);
810        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
811        let added = add_resource(
812            &stripe,
813            "render/postgres",
814            "demo-pg",
815            &serde_json::json!({}),
816            false,
817        )
818        .await
819        .unwrap();
820        assert_eq!(added.name, "demo-pg");
821        let calls = runner.calls();
822        assert!(
823            calls
824                .iter()
825                .any(|c| c.iter().any(|a| a == "render/postgres")),
826            "must projects-add render/postgres instead of reusing Neon postgres"
827        );
828    }
829
830    #[tokio::test]
831    async fn add_resource_does_not_reuse_other_provider_same_plan_name() {
832        let runner = ScriptedRunner::new(vec![
833            plans(&[("hobby", "hobby", "Vercel")]),
834            ok(json!({ "variables": { "K": "v" } })), // projects add
835            ok_empty(),                               // env add
836        ]);
837        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
838        let added = add_resource(
839            &stripe,
840            "clerk/hobby",
841            "hobby",
842            &serde_json::json!({}),
843            false,
844        )
845        .await
846        .unwrap();
847        assert_eq!(added.name, "hobby");
848        let calls = runner.calls();
849        assert!(
850            calls.iter().any(|c| c.iter().any(|a| a == "clerk/hobby")),
851            "must not env-attach Vercel hobby when ensuring clerk/hobby"
852        );
853    }
854
855    #[tokio::test]
856    async fn add_resource_falls_through_on_ambiguous_service_id_matches() {
857        let runner = ScriptedRunner::new(vec![
858            plans(&[
859                ("clerk-plan", "hobby", "Clerk"),
860                ("hobby-2", "hobby", "Clerk"),
861            ]),
862            ok(json!({ "variables": { "K": "v" } })),
863            ok_empty(),
864        ]);
865        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
866        let added = add_resource(
867            &stripe,
868            "clerk/hobby",
869            "hobby",
870            &serde_json::json!({}),
871            false,
872        )
873        .await
874        .unwrap();
875        assert_eq!(added.name, "hobby");
876        let calls = runner.calls();
877        assert!(
878            calls.iter().any(|c| c.iter().any(|a| a == "clerk/hobby")),
879            "ambiguous service_id matches must not invent a ranking winner"
880        );
881    }
882
883    #[tokio::test]
884    async fn add_resource_still_adds_when_unregistered() {
885        let runner = ScriptedRunner::new(vec![
886            services(&[]),                            // list: empty
887            ok(json!({ "variables": { "K": "v" } })), // projects add
888            ok_empty(),                               // env add
889        ]);
890        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
891        let added = add_resource(
892            &stripe,
893            "clerk/hobby",
894            "hobby",
895            &serde_json::json!({}),
896            false,
897        )
898        .await
899        .unwrap();
900        assert_eq!(added.name, "hobby");
901        assert_eq!(added.data["variables"]["K"], "v");
902        let calls = runner.calls();
903        assert_eq!(calls.len(), 3);
904        assert!(calls[1].iter().any(|a| a == "clerk/hobby"));
905        assert_eq!(
906            calls[2],
907            vec!["env", "add", "hobby", "--resource", "--json"]
908        );
909    }
910
911    #[test]
912    fn resolve_reusable_requires_provider_and_sole_service_id_match() {
913        let list: ServicesListResponse = serde_json::from_value(json!({
914            "services": [],
915            "plans": [
916                {"name": "clerk-plan", "service_id": "hobby", "provider_name": "Clerk"},
917                {"name": "hobby-2", "service_id": "hobby", "provider_name": "Clerk"}
918            ]
919        }))
920        .unwrap();
921        assert_eq!(resolve_reusable(&list, "hobby", "clerk/hobby"), None);
922        let sole: ServicesListResponse = serde_json::from_value(json!({
923            "plans": [
924                {"name": "clerk-plan", "service_id": "hobby", "provider_name": "Clerk"}
925            ]
926        }))
927        .unwrap();
928        assert_eq!(
929            resolve_reusable(&sole, "hobby", "clerk/hobby"),
930            Some("clerk-plan")
931        );
932    }
933
934    #[test]
935    fn resolve_reusable_exact_name_requires_matching_service_id() {
936        let list: ServicesListResponse = serde_json::from_value(json!({
937            "services": [{
938                "name": "demo-web",
939                "service_id": "web-service",
940                "provider_name": "Render"
941            }],
942            "plans": []
943        }))
944        .unwrap();
945        assert_eq!(
946            resolve_reusable(&list, "demo-web", "render/static-site"),
947            None,
948            "same local name + provider must not reuse a different catalog service"
949        );
950        assert_eq!(
951            resolve_reusable(&list, "demo-web", "render/web-service"),
952            Some("demo-web")
953        );
954    }
955
956    #[tokio::test]
957    async fn sync_vault_pull_soft_succeeds_on_empty_environment() {
958        let dir = tempfile::tempdir().unwrap();
959        let stale = dir.path().join(".env.demo");
960        std::fs::write(&stale, "CLERK_AUTH_ENVIRONMENTS=stale\n").unwrap();
961        let runner = ScriptedRunner::new(vec![
962            env_list(&["demo"]),
963            ok_empty(), // env use
964            CommandOutput {
965                status: 0,
966                stdout: json!({
967                    "ok": false,
968                    "error": {
969                        "code": "PROJECT_ENVIRONMENT_HAS_NO_RESOURCES",
970                        "message": "environment has no resources"
971                    }
972                })
973                .to_string(),
974                stderr: String::new(),
975            },
976        ]);
977        let stripe = StripeProjects::new(&runner, dir.path());
978        sync_vault_pull_for_instance(&stripe, "demo").await.unwrap();
979        assert!(!stale.exists(), "stale instance env file must be cleared");
980    }
981
982    #[tokio::test]
983    async fn spend_summary_none_invokes_bare_spend() {
984        let runner = ScriptedRunner::new(vec![ok(json!({ "total_usd": 12 }))]);
985        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
986        let summary = spend_summary(&stripe, None).await.unwrap();
987        assert!(summary.contains("total_usd"));
988        assert_eq!(
989            runner.calls(),
990            vec![vec!["spend".to_owned(), "--json".to_owned()]]
991        );
992    }
993
994    #[tokio::test]
995    async fn spend_summary_some_invokes_spend_with_provider() {
996        let runner = ScriptedRunner::new(vec![ok(json!({ "total_usd": 3 }))]);
997        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
998        let summary = spend_summary(&stripe, Some("vercel")).await.unwrap();
999        assert!(summary.contains("total_usd"));
1000        assert_eq!(
1001            runner.calls(),
1002            vec![vec![
1003                "spend".to_owned(),
1004                "vercel".to_owned(),
1005                "--json".to_owned(),
1006            ]]
1007        );
1008    }
1009
1010    #[tokio::test]
1011    async fn sync_vault_pull_still_fails_on_other_errors() {
1012        let dir = tempfile::tempdir().unwrap();
1013        let runner = ScriptedRunner::new(vec![
1014            env_list(&["demo"]),
1015            ok_empty(),
1016            CommandOutput {
1017                status: 0,
1018                stdout: json!({
1019                    "ok": false,
1020                    "error": {
1021                        "code": "SOME_OTHER_FAILURE",
1022                        "message": "boom"
1023                    }
1024                })
1025                .to_string(),
1026                stderr: String::new(),
1027            },
1028        ]);
1029        let stripe = StripeProjects::new(&runner, dir.path());
1030        let err = sync_vault_pull_for_instance(&stripe, "demo")
1031            .await
1032            .unwrap_err();
1033        assert!(matches!(err, ProjectsError::Failed { .. }));
1034        assert!(err.to_string().contains("SOME_OTHER_FAILURE"));
1035    }
1036}