1use 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
17const 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 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 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 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 Ok(json.to_owned())
279 }
280
281 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 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 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
462pub 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 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 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 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 #[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 const NORMALIZED_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z";
706
707 #[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 let version = crate::surface::plugin_version(&stripe)
722 .await
723 .expect("probe `stripe projects --version`");
724
725 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 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}