Skip to main content

stackless_stripe_projects/
stripe.rs

1//! The Stripe Projects CLI driver (ARCHITECTURE.md §4).
2//!
3//! Drives the `stripe projects` plugin non-interactively. The driver is
4//! generic over a [`CommandRunner`] so tests inject canned CLI envelopes.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use async_trait::async_trait;
11use serde::Deserialize;
12
13use crate::error::ProjectsError;
14
15const STRIPE_LOCK_BUDGET: Duration = Duration::from_secs(30 * 60);
16
17/// Wire-format category filters matching [`crate::catalog::Category`] (excludes
18/// `Unknown`). Used when unfiltered `catalog --json` returns an empty pipe.
19const CATALOG_CATEGORY_FILTERS: &[&str] = &[
20    "ai",
21    "analytics",
22    "auth",
23    "browser",
24    "cache",
25    "cdn",
26    "ci",
27    "communications",
28    "compute",
29    "database",
30    "domains",
31    "ecommerce",
32    "email",
33    "feature_flags",
34    "messaging",
35    "notification",
36    "observability",
37    "payments",
38    "queue",
39    "sandbox",
40    "search",
41    "storage",
42];
43
44fn json_object_slice(stdout: &str) -> Option<&str> {
45    stdout.find('{').map(|start| &stdout[start..])
46}
47
48fn envelope_service_count(json: &str) -> usize {
49    serde_json::from_str::<serde_json::Value>(json)
50        .ok()
51        .map(|value| services_in_envelope(&value))
52        .unwrap_or(0)
53}
54
55fn services_in_envelope(envelope: &serde_json::Value) -> usize {
56    envelope
57        .pointer("/data/services")
58        .and_then(serde_json::Value::as_array)
59        .map(Vec::len)
60        .unwrap_or(0)
61}
62
63#[derive(Debug, Clone)]
64pub struct CommandOutput {
65    pub status: i32,
66    pub stdout: String,
67    pub stderr: String,
68}
69
70#[async_trait]
71pub trait CommandRunner: Send + Sync {
72    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError>;
73}
74
75#[async_trait]
76impl<T: CommandRunner + ?Sized> CommandRunner for &T {
77    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
78        (**self).run(args, cwd).await
79    }
80}
81
82#[derive(Debug, Default)]
83pub struct TokioRunner;
84
85#[async_trait]
86impl CommandRunner for TokioRunner {
87    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
88        let args = args.to_vec();
89        let cwd: PathBuf = cwd.to_path_buf();
90        tokio::task::spawn_blocking(move || run_stripe_locked(&args, &cwd))
91            .await
92            .map_err(|err| ProjectsError::Unavailable {
93                detail: format!("stripe task panicked: {err}"),
94            })?
95    }
96}
97
98fn run_stripe_locked(args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
99    let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(cwd);
100    let _guard =
101        stackless_core::lockfile::FileLock::acquire_with_wait(&lock_path, STRIPE_LOCK_BUDGET)
102            .map_err(|err| ProjectsError::LockHeld {
103                definition_dir: cwd.display().to_string(),
104                detail: err.to_string(),
105            })?;
106    let output = std::process::Command::new("stripe")
107        .arg("projects")
108        .args(args)
109        .current_dir(cwd)
110        .output()
111        .map_err(|err| ProjectsError::Unavailable {
112            detail: format!("could not run `stripe`: {err}"),
113        })?;
114    Ok(CommandOutput {
115        status: output.status.code().unwrap_or(-1),
116        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
117        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
118    })
119}
120
121#[derive(Debug, Deserialize)]
122struct Envelope {
123    ok: bool,
124    #[serde(default)]
125    error: Option<EnvelopeError>,
126    #[serde(default)]
127    data: Option<serde_json::Value>,
128    #[serde(default)]
129    meta: Option<EnvelopeMeta>,
130}
131
132#[derive(Debug, Deserialize)]
133struct EnvelopeError {
134    #[serde(default)]
135    code: Option<String>,
136    #[serde(default)]
137    message: Option<String>,
138    #[serde(default)]
139    details: Option<serde_json::Value>,
140}
141
142#[derive(Debug, Deserialize)]
143struct EnvelopeMeta {
144    #[serde(default)]
145    authenticated: Option<bool>,
146}
147
148#[derive(Debug, Clone)]
149pub struct StripeResult {
150    pub ok: bool,
151    pub error_code: Option<String>,
152    pub error_message: Option<String>,
153    pub error_details: Option<serde_json::Value>,
154    pub authenticated: bool,
155    pub data: serde_json::Value,
156}
157
158const PLAIN_FALLBACK_CODES: &[&str] = &[
159    "JSON_REQUIRES_CONFIRMATION",
160    "JSON_REQUIRES_AUTH",
161    "DIRECTORY_SELECTION_REQUIRED",
162];
163
164pub struct StripeProjects<R: CommandRunner> {
165    runner: R,
166    dir: PathBuf,
167}
168
169impl<R: CommandRunner> std::fmt::Debug for StripeProjects<R> {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("StripeProjects")
172            .field("dir", &self.dir)
173            .finish_non_exhaustive()
174    }
175}
176
177impl<R: CommandRunner> StripeProjects<R> {
178    pub fn new(runner: R, dir: impl Into<PathBuf>) -> Self {
179        Self {
180            runner,
181            dir: dir.into(),
182        }
183    }
184
185    pub fn dir(&self) -> &Path {
186        &self.dir
187    }
188
189    /// Borrow as a `StripeProjects<&dyn CommandRunner>` so callers holding a
190    /// `dyn` dispatch target can run commands without being generic over `R`
191    /// (`impl CommandRunner for &T` makes the erased runner a valid runner).
192    pub fn as_dyn(&self) -> StripeProjects<&'_ dyn CommandRunner> {
193        StripeProjects {
194            runner: &self.runner as &dyn CommandRunner,
195            dir: self.dir.clone(),
196        }
197    }
198
199    #[cfg(test)]
200    fn runner(&self) -> &R {
201        &self.runner
202    }
203
204    pub async fn json(&self, args: &[&str]) -> Result<StripeResult, ProjectsError> {
205        let mut argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
206        argv.push("--json".into());
207        let out = self.runner.run(&argv, &self.dir).await?;
208        let Some(start) = out.stdout.find('{') else {
209            let stderr = out.stderr.trim();
210            return Err(ProjectsError::Unavailable {
211                detail: format!(
212                    "`stripe projects {}` exited without delivering a JSON envelope{}",
213                    args.join(" "),
214                    if stderr.is_empty() {
215                        String::new()
216                    } else {
217                        format!(" (stderr: {stderr})")
218                    }
219                ),
220            });
221        };
222        let envelope: Envelope = serde_json::from_str(&out.stdout[start..]).map_err(|err| {
223            ProjectsError::Unavailable {
224                detail: format!(
225                    "`stripe projects {}` exited without delivering a parseable JSON envelope: {err}",
226                    args.join(" ")
227                ),
228            }
229        })?;
230        Ok(StripeResult {
231            ok: envelope.ok,
232            error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
233            error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
234            error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
235            authenticated: envelope
236                .meta
237                .as_ref()
238                .and_then(|m| m.authenticated)
239                .unwrap_or(true),
240            data: envelope.data.unwrap_or(serde_json::Value::Null),
241        })
242    }
243
244    /// Unfiltered catalog (`stripe projects catalog --json`).
245    ///
246    /// For live drift / full-model tooling only. Provisioning must use
247    /// [`Self::catalog_for_reference`].
248    pub async fn catalog(&self) -> Result<crate::catalog::Catalog, ProjectsError> {
249        let json = self.catalog_envelope_json().await?;
250        crate::catalog::Catalog::from_json_envelope(&json).map_err(|err| {
251            ProjectsError::Unavailable {
252                detail: format!("`stripe projects catalog` returned an unmodeled catalog: {err}"),
253            }
254        })
255    }
256
257    /// Full `{ok, data, …}` envelope text for `catalog --json`.
258    ///
259    /// Plugin 0.29.0 (and possibly later) often emits an empty stdout for the
260    /// unfiltered catalog when stdout is a pipe. Filtered
261    /// `catalog <category> --json` still works, so we fall back to merging every
262    /// known [`crate::catalog::Category`] filter when the unfiltered call is
263    /// empty or non-JSON. Auth / `ok: false` envelopes are classified and
264    /// returned as faults — they must not trigger the empty-pipe fallback.
265    pub async fn catalog_envelope_json(&self) -> Result<String, ProjectsError> {
266        let raw = self.plain(&["catalog", "--json"]).await?;
267        let Some(json) = json_object_slice(&raw.stdout) else {
268            return self.catalog_envelope_json_by_categories().await;
269        };
270        if let Some(fault) = self.catalog_envelope_fault(json) {
271            return Err(fault);
272        }
273        if envelope_service_count(json) > 0 {
274            return Ok(json.to_owned());
275        }
276        // Authenticated ok:true with an empty services list is a real empty
277        // catalog, not the empty-pipe bug. Return it as-is.
278        Ok(json.to_owned())
279    }
280
281    /// Classify a parsed catalog envelope that is not a successful service list.
282    /// Returns `None` when the JSON is `ok: true` (caller decides empty vs full).
283    fn catalog_envelope_fault(&self, json: &str) -> Option<ProjectsError> {
284        let envelope: Envelope = serde_json::from_str(json).ok()?;
285        if envelope.ok {
286            return None;
287        }
288        let result = StripeResult {
289            ok: envelope.ok,
290            error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
291            error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
292            error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
293            authenticated: envelope
294                .meta
295                .as_ref()
296                .and_then(|m| m.authenticated)
297                .unwrap_or(true),
298            data: envelope.data.unwrap_or(serde_json::Value::Null),
299        };
300        Some(self.classify_failure("catalog", &result))
301    }
302
303    async fn catalog_envelope_json_by_categories(&self) -> Result<String, ProjectsError> {
304        let mut services: BTreeMap<String, serde_json::Value> = BTreeMap::new();
305        let mut template: Option<serde_json::Value> = None;
306        for filter in CATALOG_CATEGORY_FILTERS {
307            let raw = self.plain(&["catalog", filter, "--json"]).await?;
308            let Some(json) = json_object_slice(&raw.stdout) else {
309                continue;
310            };
311            if let Some(fault) = self.catalog_envelope_fault(json) {
312                return Err(fault);
313            }
314            let mut envelope: serde_json::Value =
315                serde_json::from_str(json).map_err(|err| ProjectsError::Unavailable {
316                    detail: format!(
317                        "`stripe projects catalog {filter} --json` emitted malformed JSON: {err}"
318                    ),
319                })?;
320            if let Some(list) = envelope
321                .pointer_mut("/data/services")
322                .and_then(serde_json::Value::as_array_mut)
323            {
324                for service in list.drain(..) {
325                    let id = service
326                        .get("id")
327                        .and_then(serde_json::Value::as_str)
328                        .unwrap_or_default()
329                        .to_owned();
330                    if !id.is_empty() {
331                        services.insert(id, service);
332                    }
333                }
334            }
335            if template.is_none() {
336                template = Some(envelope);
337            }
338        }
339        let mut envelope = template.ok_or_else(|| ProjectsError::Unavailable {
340            detail: "`stripe projects catalog --json` produced no JSON, and every category filter was empty"
341                .into(),
342        })?;
343        if let Some(data) = envelope
344            .get_mut("data")
345            .and_then(serde_json::Value::as_object_mut)
346        {
347            data.insert("provider".into(), serde_json::Value::Null);
348            data.insert("category_filter".into(), serde_json::Value::Null);
349            data.insert("provider_filter".into(), serde_json::Value::Null);
350            data.insert(
351                "services".into(),
352                serde_json::Value::Array(services.into_values().collect()),
353            );
354        }
355        if services_in_envelope(&envelope) == 0 {
356            return Err(ProjectsError::Unavailable {
357                detail: "`stripe projects catalog` returned no services via unfiltered or category filters"
358                    .into(),
359            });
360        }
361        Ok(envelope.to_string())
362    }
363
364    /// Provider-scoped catalog for a `provider/service` reference.
365    ///
366    /// Runs `stripe projects catalog <provider> --json` once. The CLI filter
367    /// accepts a category or provider name; we pass the provider slug from the
368    /// reference (everything before the first `/`).
369    pub async fn catalog_for_reference(
370        &self,
371        reference: &str,
372    ) -> Result<crate::catalog::Catalog, ProjectsError> {
373        let provider = provider_from_reference(reference);
374        let data = self.run_ok("catalog", &["catalog", provider], &[]).await?;
375        let catalog: crate::catalog::Catalog =
376            serde_json::from_value(data).map_err(|err| ProjectsError::Unavailable {
377                detail: format!(
378                    "`stripe projects catalog {provider}` returned an unmodeled catalog: {err}"
379                ),
380            })?;
381        if catalog.lookup(reference).is_none() {
382            return Err(ProjectsError::CatalogMissing {
383                reference: reference.to_owned(),
384            });
385        }
386        Ok(catalog)
387    }
388
389    /// Provider-scoped catalog for a [`crate::catalog::verify::CatalogService`].
390    pub async fn catalog_for<C: crate::catalog::verify::CatalogService>(
391        &self,
392    ) -> Result<crate::catalog::Catalog, ProjectsError> {
393        self.catalog_for_reference(C::REFERENCE).await
394    }
395
396    pub async fn plain(&self, args: &[&str]) -> Result<CommandOutput, ProjectsError> {
397        let argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
398        self.runner.run(&argv, &self.dir).await
399    }
400
401    pub fn classify_failure(&self, command: &str, result: &StripeResult) -> ProjectsError {
402        let message = result
403            .error_message
404            .clone()
405            .unwrap_or_else(|| "unknown error".into());
406        let code = result.error_code.as_deref().unwrap_or("");
407        let auth_like = !result.authenticated
408            || code == "JSON_REQUIRES_AUTH"
409            || message.to_ascii_lowercase().contains("not authenticated")
410            || message.to_ascii_lowercase().contains("log in");
411        if auth_like {
412            ProjectsError::Auth { detail: message }
413        } else {
414            ProjectsError::Failed {
415                command: command.to_owned(),
416                detail: format!(
417                    "{message}{}",
418                    if code.is_empty() {
419                        String::new()
420                    } else {
421                        format!(" ({code})")
422                    }
423                ),
424            }
425        }
426    }
427
428    pub async fn run_ok(
429        &self,
430        command: &str,
431        args: &[&str],
432        plain_extra: &[&str],
433    ) -> Result<serde_json::Value, ProjectsError> {
434        let result = self.json(args).await?;
435        if result.ok {
436            return Ok(result.data);
437        }
438        let code = result.error_code.as_deref().unwrap_or("");
439        let message = result.error_message.clone().unwrap_or_default();
440        let live_mode = message.to_ascii_lowercase().contains("live mode");
441        if PLAIN_FALLBACK_CODES.contains(&code) || live_mode {
442            let mut plain_args: Vec<&str> = args.to_vec();
443            plain_args.extend_from_slice(plain_extra);
444            let out = self.plain(&plain_args).await?;
445            if out.status != 0 || out.stdout.contains('✗') || out.stderr.contains('✗') {
446                return Err(ProjectsError::Failed {
447                    command: command.to_owned(),
448                    detail: merge_output(&out),
449                });
450            }
451            return Ok(serde_json::Value::Null);
452        }
453        Err(self.classify_failure(command, &result))
454    }
455}
456
457fn merge_output(out: &CommandOutput) -> String {
458    let merged = format!("{}{}", out.stdout.trim(), out.stderr.trim());
459    merged.trim().to_owned()
460}
461
462/// Provider filter token for `stripe projects catalog <provider>`.
463pub fn provider_from_reference(reference: &str) -> &str {
464    reference
465        .split_once('/')
466        .map(|(p, _)| p)
467        .unwrap_or(reference)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use stackless_core::fault::{Fault, codes};
474    use std::sync::Mutex;
475
476    struct ScriptRunner {
477        outputs: Mutex<std::collections::VecDeque<CommandOutput>>,
478        calls: Mutex<Vec<Vec<String>>>,
479    }
480
481    impl ScriptRunner {
482        fn new(outputs: Vec<CommandOutput>) -> Self {
483            Self {
484                outputs: Mutex::new(outputs.into()),
485                calls: Mutex::new(Vec::new()),
486            }
487        }
488
489        fn calls(&self) -> Vec<Vec<String>> {
490            self.calls.lock().unwrap().clone()
491        }
492    }
493
494    #[async_trait]
495    impl CommandRunner for ScriptRunner {
496        async fn run(&self, args: &[String], _cwd: &Path) -> Result<CommandOutput, ProjectsError> {
497            self.calls.lock().unwrap().push(args.to_vec());
498            self.outputs
499                .lock()
500                .unwrap()
501                .pop_front()
502                .ok_or_else(|| ProjectsError::Unavailable {
503                    detail: "ScriptRunner exhausted".into(),
504                })
505        }
506    }
507
508    fn out(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
509        CommandOutput {
510            status,
511            stdout: stdout.to_owned(),
512            stderr: stderr.to_owned(),
513        }
514    }
515
516    fn driver(outputs: Vec<CommandOutput>) -> StripeProjects<ScriptRunner> {
517        StripeProjects::new(ScriptRunner::new(outputs), std::env::temp_dir())
518    }
519
520    #[tokio::test]
521    async fn parses_ok_envelope() {
522        let d = driver(vec![out(
523            0,
524            r#"{"ok":true,"command":"status","version":"0.19.0","data":{"project":{"id":"proj_1"}}}"#,
525            "",
526        )]);
527        let result = d.json(&["status"]).await.unwrap();
528        assert!(result.ok);
529        assert_eq!(result.data["project"]["id"], "proj_1");
530    }
531
532    #[tokio::test]
533    async fn no_json_is_unavailable() {
534        let d = driver(vec![out(127, "", "command not found: stripe")]);
535        let err = d.json(&["status"]).await.unwrap_err();
536        assert_eq!(err.code(), codes::STRIPE_PROJECTS_UNAVAILABLE);
537        assert!(
538            err.to_string()
539                .contains("exited without delivering a JSON envelope"),
540            "detail should name the missing envelope, got: {err}"
541        );
542    }
543
544    #[test]
545    fn provider_from_reference_splits_on_first_slash() {
546        assert_eq!(provider_from_reference("clerk/auth"), "clerk");
547        assert_eq!(
548            provider_from_reference("wordpress.com/site"),
549            "wordpress.com"
550        );
551        assert_eq!(
552            provider_from_reference("cloudflare/r2:bucket"),
553            "cloudflare"
554        );
555        assert_eq!(
556            provider_from_reference("laravel_cloud/mysql"),
557            "laravel_cloud"
558        );
559        assert_eq!(provider_from_reference("bare"), "bare");
560    }
561
562    fn clerk_auth_catalog_envelope() -> String {
563        // Filtered live responses may set `provider` / `provider_filter` to an
564        // object rather than a string (observed on vercel/render smokes).
565        serde_json::json!({
566            "ok": true,
567            "command": "catalog",
568            "data": {
569                "last_updated": "1970-01-01T00:00:00.000Z",
570                "provider": { "id": "prvdr_clerk", "name": "Clerk" },
571                "provider_filter": { "name": "clerk" },
572                "source": "cache",
573                "services": [{
574                    "id": "clerk_auth",
575                    "object": "service",
576                    "provider_id": "clerk",
577                    "provider_name": "Clerk",
578                    "service_id": "auth",
579                    "kind": "saas",
580                    "scope": "account",
581                    "availability": "available",
582                    "development": true,
583                    "livemode": true,
584                    "pricing": { "type": "free" }
585                }]
586            }
587        })
588        .to_string()
589    }
590
591    #[tokio::test]
592    async fn catalog_for_reference_passes_provider_filter() {
593        let d = driver(vec![out(0, &clerk_auth_catalog_envelope(), "")]);
594        let catalog = d
595            .catalog_for_reference("clerk/auth")
596            .await
597            .expect("scoped catalog");
598        assert!(catalog.lookup("clerk/auth").is_some());
599        assert_eq!(
600            d.runner().calls(),
601            vec![vec![
602                "catalog".to_owned(),
603                "clerk".to_owned(),
604                "--json".to_owned()
605            ]]
606        );
607    }
608
609    #[tokio::test]
610    async fn catalog_for_reference_missing_service_is_catalog_missing() {
611        let empty = serde_json::json!({
612            "ok": true,
613            "command": "catalog",
614            "data": {
615                "last_updated": "1970-01-01T00:00:00.000Z",
616                "provider_filter": "clerk",
617                "services": []
618            }
619        })
620        .to_string();
621        let d = driver(vec![out(0, &empty, "")]);
622        let err = d.catalog_for_reference("clerk/auth").await.unwrap_err();
623        assert_eq!(err.code(), codes::STRIPE_PROJECTS_CATALOG_MISSING);
624    }
625
626    #[tokio::test]
627    async fn catalog_envelope_falls_back_to_category_filters() {
628        let service = r#"{"id":"prvsvc_1","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_1","provider_name":"Neon","service_id":"postgres","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"}}"#;
629        let filtered = format!(
630            r#"{{"ok":true,"command":"projects catalog","version":"0.1","data":{{"last_updated":"t","provider":null,"category_filter":"database","provider_filter":null,"services":[{service}],"source":null}}}}"#
631        );
632        // Unfiltered empty, then one hit on the database filter; remaining
633        // category probes return empty objects so the merge still completes.
634        let mut outputs = vec![out(0, "", "")];
635        for filter in CATALOG_CATEGORY_FILTERS {
636            if *filter == "database" {
637                outputs.push(out(0, &filtered, ""));
638            } else {
639                outputs.push(out(
640                    0,
641                    r#"{"ok":true,"command":"projects catalog","version":"0.1","data":{"last_updated":"t","provider":null,"category_filter":null,"provider_filter":null,"services":[],"source":null}}"#,
642                    "",
643                ));
644            }
645        }
646        let d = driver(outputs);
647        let json = d.catalog_envelope_json().await.unwrap();
648        let catalog = crate::catalog::Catalog::from_json_envelope(&json).unwrap();
649        assert_eq!(catalog.services.len(), 1);
650        assert_eq!(catalog.services[0].reference(), "neon/postgres");
651        assert!(catalog.category_filter.is_none());
652    }
653
654    #[tokio::test]
655    async fn unauthenticated_envelope_is_auth_fault() {
656        let d = driver(vec![out(
657            0,
658            r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
659            "",
660        )]);
661        let err = d.run_ok("status", &["status"], &[]).await.unwrap_err();
662        assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
663    }
664
665    #[tokio::test]
666    async fn unauthenticated_catalog_envelope_is_auth_fault_not_empty_pipe_fallback() {
667        let d = driver(vec![out(
668            0,
669            r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
670            "",
671        )]);
672        let err = d.catalog_envelope_json().await.unwrap_err();
673        assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
674        // Only the unfiltered call — must not probe category filters.
675        assert_eq!(d.runner().calls().len(), 1);
676        assert_eq!(
677            d.runner().calls()[0],
678            vec!["catalog".to_owned(), "--json".to_owned()]
679        );
680    }
681
682    /// Opt-in live check (`STRIPE_CATALOG_LIVE=1`): run the real
683    /// `stripe projects catalog --json` and assert the typed model still fully
684    /// covers it. No-op in CI; the canonical way to catch a stale fixture.
685    #[tokio::test]
686    async fn live_catalog_matches_model() {
687        if std::env::var("STRIPE_CATALOG_LIVE").as_deref() != Ok("1") {
688            return;
689        }
690        let dir = std::env::current_dir().unwrap();
691        let stripe = StripeProjects::new(TokioRunner, dir);
692        let catalog = stripe.catalog().await.expect("live catalog should fetch");
693        let report = catalog.drift_report();
694        assert!(
695            report.is_empty(),
696            "LIVE catalog drift — refresh tests/fixtures/catalog.json and update the model:\n{}",
697            report.join("\n")
698        );
699    }
700
701    /// The per-catalog-state content version (`data.last_updated`) is stable
702    /// across requests but bumps whenever Stripe republishes the catalog —
703    /// noise unrelated to a real service/schema change. Normalize it so the
704    /// committed `catalog.json` only diffs on content we actually model.
705    const NORMALIZED_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z";
706
707    /// Bless mode (`STRIPE_PROJECTS_REFRESH=1`): regenerate the three committed
708    /// snapshots — `plugin-version.txt`, `catalog.json`, `command-surface.txt` —
709    /// from the LOCALLY INSTALLED plugin. The only path that writes fixtures;
710    /// invoked by `mise run stripe-refresh` and the CI watcher. A no-op (and
711    /// needs no `stripe`) otherwise, so it stays inert in the hermetic gate.
712    #[tokio::test]
713    async fn refresh_blesses_snapshots() {
714        if std::env::var("STRIPE_PROJECTS_REFRESH").as_deref() != Ok("1") {
715            return;
716        }
717        let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
718        let stripe = StripeProjects::new(TokioRunner, std::env::current_dir().unwrap());
719
720        // 1. Pinned plugin version.
721        let version = crate::surface::plugin_version(&stripe)
722            .await
723            .expect("probe `stripe projects --version`");
724
725        // 2. Catalog envelope — refuse to bless anything the typed model can't
726        //    fully represent (forces a src/catalog.rs update first). Uses
727        //    [`StripeProjects::catalog_envelope_json`] so an empty unfiltered
728        //    pipe (plugin 0.29.0) still blesses via category filters.
729        let json = stripe
730            .catalog_envelope_json()
731            .await
732            .expect("run `stripe projects catalog --json`");
733        let report = crate::catalog::Catalog::from_json_envelope(&json)
734            .expect("catalog envelope parses")
735            .drift_report();
736        assert!(
737            report.is_empty(),
738            "live catalog has unmodeled drift — update src/catalog.rs before blessing:\n{}",
739            report.join("\n")
740        );
741        let mut envelope: serde_json::Value =
742            serde_json::from_str(&json).expect("catalog envelope is JSON");
743        if let Some(data) = envelope
744            .get_mut("data")
745            .and_then(serde_json::Value::as_object_mut)
746            && data.contains_key("last_updated")
747        {
748            data.insert(
749                "last_updated".into(),
750                serde_json::Value::String(NORMALIZED_TIMESTAMP.into()),
751            );
752        }
753        let catalog_pretty = format!(
754            "{}\n",
755            serde_json::to_string_pretty(&envelope).expect("serialize catalog")
756        );
757
758        // 3. Command surface (header carries the pinned version).
759        let body = crate::surface::command_surface(&stripe)
760            .await
761            .expect("capture command surface");
762        let surface = crate::surface::render_surface(&version, &body);
763
764        std::fs::write(fixtures.join("catalog.json"), catalog_pretty).unwrap();
765        std::fs::write(fixtures.join("command-surface.txt"), surface).unwrap();
766        std::fs::write(fixtures.join("plugin-version.txt"), format!("{version}\n")).unwrap();
767        eprintln!("blessed snapshots for stripe projects plugin v{version}");
768    }
769
770    #[tokio::test]
771    async fn confirmation_code_falls_back_to_plain_mode() {
772        let d = driver(vec![
773            out(
774                0,
775                r#"{"ok":false,"error":{"code":"JSON_REQUIRES_CONFIRMATION","message":"needs confirmation"}}"#,
776                "",
777            ),
778            out(0, "✓ created project", ""),
779        ]);
780        d.run_ok(
781            "init",
782            &["init", "atto", "--skip-skills", "--accept-tos"],
783            &["--accept-tos", "--yes"],
784        )
785        .await
786        .unwrap();
787        let calls = d.runner().calls();
788        assert_eq!(calls.len(), 2);
789        assert!(calls[0].contains(&"--json".to_owned()));
790        assert!(!calls[1].contains(&"--json".to_owned()));
791        assert!(calls[1].contains(&"--yes".to_owned()));
792    }
793}