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(15);
16const STRIPE_CMD_BUDGET: Duration = Duration::from_secs(90);
17
18const CATALOG_CATEGORY_FILTERS: &[&str] = &[
21 "ai",
22 "analytics",
23 "auth",
24 "browser",
25 "cache",
26 "cdn",
27 "ci",
28 "communications",
29 "compute",
30 "database",
31 "domains",
32 "ecommerce",
33 "email",
34 "feature_flags",
35 "messaging",
36 "notification",
37 "observability",
38 "payments",
39 "queue",
40 "sandbox",
41 "search",
42 "storage",
43];
44
45fn json_object_slice(stdout: &str) -> Option<&str> {
46 stdout.find('{').map(|start| &stdout[start..])
47}
48
49fn envelope_service_count(json: &str) -> usize {
50 serde_json::from_str::<serde_json::Value>(json)
51 .ok()
52 .map(|value| services_in_envelope(&value))
53 .unwrap_or(0)
54}
55
56fn services_in_envelope(envelope: &serde_json::Value) -> usize {
57 envelope
58 .pointer("/data/services")
59 .and_then(serde_json::Value::as_array)
60 .map(Vec::len)
61 .unwrap_or(0)
62}
63
64#[derive(Debug, Clone)]
65pub struct CommandOutput {
66 pub status: i32,
67 pub stdout: String,
68 pub stderr: String,
69}
70
71#[async_trait]
72pub trait CommandRunner: Send + Sync {
73 async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError>;
74}
75
76#[async_trait]
77impl<T: CommandRunner + ?Sized> CommandRunner for &T {
78 async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
79 (**self).run(args, cwd).await
80 }
81}
82
83#[derive(Debug, Default)]
84pub struct TokioRunner;
85
86#[async_trait]
87impl CommandRunner for TokioRunner {
88 async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
89 let args = args.to_vec();
90 let cwd: PathBuf = cwd.to_path_buf();
91 tokio::task::spawn_blocking(move || run_stripe_locked(&args, &cwd))
92 .await
93 .map_err(|err| ProjectsError::Unavailable {
94 detail: format!("stripe task panicked: {err}"),
95 })?
96 }
97}
98
99fn run_stripe_locked(args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
100 let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(cwd);
101 let _guard =
102 stackless_core::lockfile::FileLock::acquire_with_wait(&lock_path, STRIPE_LOCK_BUDGET)
103 .map_err(|err| ProjectsError::LockHeld {
104 definition_dir: cwd.display().to_string(),
105 detail: err.to_string(),
106 })?;
107 let mut cmd = std::process::Command::new("stripe");
108 cmd.arg("projects").args(args).current_dir(cwd);
109 match stackless_core::process::run_with_timeout(&mut cmd, STRIPE_CMD_BUDGET) {
110 stackless_core::process::TimedCommand::Finished(output) => Ok(CommandOutput {
111 status: output.status.code().unwrap_or(-1),
112 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
113 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
114 }),
115 stackless_core::process::TimedCommand::TimedOut { pid } => Err(ProjectsError::Timeout {
116 budget_secs: STRIPE_CMD_BUDGET.as_secs(),
117 detail: timeout_detail(pid, args),
118 }),
119 stackless_core::process::TimedCommand::Spawn(err) => Err(ProjectsError::Unavailable {
120 detail: format!("could not run `stripe`: {err}"),
121 }),
122 }
123}
124
125fn timeout_detail(pid: u32, args: &[String]) -> String {
128 let verb = args.first().map(String::as_str).unwrap_or("?");
129 format!("killed process group {pid} after `stripe projects {verb}`")
130}
131
132#[derive(Debug, Deserialize)]
133struct Envelope {
134 ok: bool,
135 #[serde(default)]
136 error: Option<EnvelopeError>,
137 #[serde(default)]
138 data: Option<serde_json::Value>,
139 #[serde(default)]
140 meta: Option<EnvelopeMeta>,
141}
142
143#[derive(Debug, Deserialize)]
144struct EnvelopeError {
145 #[serde(default)]
146 code: Option<String>,
147 #[serde(default)]
148 message: Option<String>,
149 #[serde(default)]
150 details: Option<serde_json::Value>,
151}
152
153#[derive(Debug, Deserialize)]
154struct EnvelopeMeta {
155 #[serde(default)]
156 authenticated: Option<bool>,
157}
158
159#[derive(Debug, Clone)]
160pub struct StripeResult {
161 pub ok: bool,
162 pub error_code: Option<String>,
163 pub error_message: Option<String>,
164 pub error_details: Option<serde_json::Value>,
165 pub authenticated: bool,
166 pub data: serde_json::Value,
167}
168
169const PLAIN_FALLBACK_CODES: &[&str] = &[
170 "JSON_REQUIRES_CONFIRMATION",
171 "JSON_REQUIRES_AUTH",
172 "DIRECTORY_SELECTION_REQUIRED",
173];
174
175pub struct StripeProjects<R: CommandRunner> {
176 runner: R,
177 dir: PathBuf,
178}
179
180impl<R: CommandRunner> std::fmt::Debug for StripeProjects<R> {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("StripeProjects")
183 .field("dir", &self.dir)
184 .finish_non_exhaustive()
185 }
186}
187
188impl<R: CommandRunner> StripeProjects<R> {
189 pub fn new(runner: R, dir: impl Into<PathBuf>) -> Self {
190 Self {
191 runner,
192 dir: dir.into(),
193 }
194 }
195
196 pub fn dir(&self) -> &Path {
197 &self.dir
198 }
199
200 pub fn as_dyn(&self) -> StripeProjects<&'_ dyn CommandRunner> {
204 StripeProjects {
205 runner: &self.runner as &dyn CommandRunner,
206 dir: self.dir.clone(),
207 }
208 }
209
210 #[cfg(test)]
211 fn runner(&self) -> &R {
212 &self.runner
213 }
214
215 pub async fn json(&self, args: &[&str]) -> Result<StripeResult, ProjectsError> {
216 let mut argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
217 argv.push("--json".into());
218 let out = self.runner.run(&argv, &self.dir).await?;
219 let Some(start) = out.stdout.find('{') else {
220 let stderr = out.stderr.trim();
221 return Err(ProjectsError::Unavailable {
222 detail: format!(
223 "`stripe projects {}` exited without delivering a JSON envelope{}",
224 args.join(" "),
225 if stderr.is_empty() {
226 String::new()
227 } else {
228 format!(" (stderr: {stderr})")
229 }
230 ),
231 });
232 };
233 let envelope: Envelope = serde_json::from_str(&out.stdout[start..]).map_err(|err| {
234 ProjectsError::Unavailable {
235 detail: format!(
236 "`stripe projects {}` exited without delivering a parseable JSON envelope: {err}",
237 args.join(" ")
238 ),
239 }
240 })?;
241 Ok(StripeResult {
242 ok: envelope.ok,
243 error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
244 error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
245 error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
246 authenticated: envelope
247 .meta
248 .as_ref()
249 .and_then(|m| m.authenticated)
250 .unwrap_or(true),
251 data: envelope.data.unwrap_or(serde_json::Value::Null),
252 })
253 }
254
255 pub async fn catalog(&self) -> Result<crate::catalog::Catalog, ProjectsError> {
260 let json = self.catalog_envelope_json().await?;
261 crate::catalog::Catalog::from_json_envelope(&json).map_err(|err| {
262 ProjectsError::Unavailable {
263 detail: format!("`stripe projects catalog` returned an unmodeled catalog: {err}"),
264 }
265 })
266 }
267
268 pub async fn catalog_envelope_json(&self) -> Result<String, ProjectsError> {
277 let raw = self.plain(&["catalog", "--json"]).await?;
278 let Some(json) = json_object_slice(&raw.stdout) else {
279 return self.catalog_envelope_json_by_categories().await;
280 };
281 if let Some(fault) = self.catalog_envelope_fault(json) {
282 return Err(fault);
283 }
284 if envelope_service_count(json) > 0 {
285 return Ok(json.to_owned());
286 }
287 Ok(json.to_owned())
290 }
291
292 fn catalog_envelope_fault(&self, json: &str) -> Option<ProjectsError> {
295 let envelope: Envelope = serde_json::from_str(json).ok()?;
296 if envelope.ok {
297 return None;
298 }
299 let result = StripeResult {
300 ok: envelope.ok,
301 error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
302 error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
303 error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
304 authenticated: envelope
305 .meta
306 .as_ref()
307 .and_then(|m| m.authenticated)
308 .unwrap_or(true),
309 data: envelope.data.unwrap_or(serde_json::Value::Null),
310 };
311 Some(self.classify_failure("catalog", &result))
312 }
313
314 async fn catalog_envelope_json_by_categories(&self) -> Result<String, ProjectsError> {
315 let mut services: BTreeMap<String, serde_json::Value> = BTreeMap::new();
316 let mut template: Option<serde_json::Value> = None;
317 for filter in CATALOG_CATEGORY_FILTERS {
318 let raw = self.plain(&["catalog", filter, "--json"]).await?;
319 let Some(json) = json_object_slice(&raw.stdout) else {
320 continue;
321 };
322 if let Some(fault) = self.catalog_envelope_fault(json) {
323 return Err(fault);
324 }
325 let mut envelope: serde_json::Value =
326 serde_json::from_str(json).map_err(|err| ProjectsError::Unavailable {
327 detail: format!(
328 "`stripe projects catalog {filter} --json` emitted malformed JSON: {err}"
329 ),
330 })?;
331 if let Some(list) = envelope
332 .pointer_mut("/data/services")
333 .and_then(serde_json::Value::as_array_mut)
334 {
335 for service in list.drain(..) {
336 let id = service
337 .get("id")
338 .and_then(serde_json::Value::as_str)
339 .unwrap_or_default()
340 .to_owned();
341 if !id.is_empty() {
342 services.insert(id, service);
343 }
344 }
345 }
346 if template.is_none() {
347 template = Some(envelope);
348 }
349 }
350 let mut envelope = template.ok_or_else(|| ProjectsError::Unavailable {
351 detail: "`stripe projects catalog --json` produced no JSON, and every category filter was empty"
352 .into(),
353 })?;
354 if let Some(data) = envelope
355 .get_mut("data")
356 .and_then(serde_json::Value::as_object_mut)
357 {
358 data.insert("provider".into(), serde_json::Value::Null);
359 data.insert("category_filter".into(), serde_json::Value::Null);
360 data.insert("provider_filter".into(), serde_json::Value::Null);
361 data.insert(
362 "services".into(),
363 serde_json::Value::Array(services.into_values().collect()),
364 );
365 }
366 if services_in_envelope(&envelope) == 0 {
367 return Err(ProjectsError::Unavailable {
368 detail: "`stripe projects catalog` returned no services via unfiltered or category filters"
369 .into(),
370 });
371 }
372 Ok(envelope.to_string())
373 }
374
375 pub async fn catalog_for_reference(
381 &self,
382 reference: &str,
383 ) -> Result<crate::catalog::Catalog, ProjectsError> {
384 let provider = provider_from_reference(reference);
385 let data = self.run_ok("catalog", &["catalog", provider], &[]).await?;
386 let catalog: crate::catalog::Catalog =
387 serde_json::from_value(data).map_err(|err| ProjectsError::Unavailable {
388 detail: format!(
389 "`stripe projects catalog {provider}` returned an unmodeled catalog: {err}"
390 ),
391 })?;
392 if catalog.lookup(reference).is_none() {
393 return Err(ProjectsError::CatalogMissing {
394 reference: reference.to_owned(),
395 });
396 }
397 Ok(catalog)
398 }
399
400 pub async fn catalog_for<C: crate::catalog::verify::CatalogService>(
402 &self,
403 ) -> Result<crate::catalog::Catalog, ProjectsError> {
404 self.catalog_for_reference(C::REFERENCE).await
405 }
406
407 pub async fn plain(&self, args: &[&str]) -> Result<CommandOutput, ProjectsError> {
408 let argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
409 self.runner.run(&argv, &self.dir).await
410 }
411
412 pub fn classify_failure(&self, command: &str, result: &StripeResult) -> ProjectsError {
413 let message = result
414 .error_message
415 .clone()
416 .unwrap_or_else(|| "unknown error".into());
417 let code = result.error_code.as_deref().unwrap_or("");
418 let auth_like = !result.authenticated
419 || code == "JSON_REQUIRES_AUTH"
420 || message.to_ascii_lowercase().contains("not authenticated")
421 || message.to_ascii_lowercase().contains("log in");
422 if auth_like {
423 ProjectsError::Auth { detail: message }
424 } else {
425 ProjectsError::Failed {
426 command: command.to_owned(),
427 detail: format!(
428 "{message}{}",
429 if code.is_empty() {
430 String::new()
431 } else {
432 format!(" ({code})")
433 }
434 ),
435 }
436 }
437 }
438
439 pub async fn run_ok(
440 &self,
441 command: &str,
442 args: &[&str],
443 plain_extra: &[&str],
444 ) -> Result<serde_json::Value, ProjectsError> {
445 let result = self.json(args).await?;
446 if result.ok {
447 return Ok(result.data);
448 }
449 let code = result.error_code.as_deref().unwrap_or("");
450 let message = result.error_message.clone().unwrap_or_default();
451 let live_mode = message.to_ascii_lowercase().contains("live mode");
452 if PLAIN_FALLBACK_CODES.contains(&code) || live_mode {
453 let mut plain_args: Vec<&str> = args.to_vec();
454 plain_args.extend_from_slice(plain_extra);
455 let out = self.plain(&plain_args).await?;
456 if out.status != 0 || out.stdout.contains('✗') || out.stderr.contains('✗') {
457 return Err(ProjectsError::Failed {
458 command: command.to_owned(),
459 detail: merge_output(&out),
460 });
461 }
462 return Ok(serde_json::Value::Null);
463 }
464 Err(self.classify_failure(command, &result))
465 }
466}
467
468fn merge_output(out: &CommandOutput) -> String {
469 let merged = format!("{}{}", out.stdout.trim(), out.stderr.trim());
470 merged.trim().to_owned()
471}
472
473pub fn provider_from_reference(reference: &str) -> &str {
475 reference
476 .split_once('/')
477 .map(|(p, _)| p)
478 .unwrap_or(reference)
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use stackless_core::fault::{Fault, codes};
485 use std::sync::Mutex;
486
487 struct ScriptRunner {
488 outputs: Mutex<std::collections::VecDeque<CommandOutput>>,
489 calls: Mutex<Vec<Vec<String>>>,
490 }
491
492 impl ScriptRunner {
493 fn new(outputs: Vec<CommandOutput>) -> Self {
494 Self {
495 outputs: Mutex::new(outputs.into()),
496 calls: Mutex::new(Vec::new()),
497 }
498 }
499
500 fn calls(&self) -> Vec<Vec<String>> {
501 self.calls.lock().unwrap().clone()
502 }
503 }
504
505 #[async_trait]
506 impl CommandRunner for ScriptRunner {
507 async fn run(&self, args: &[String], _cwd: &Path) -> Result<CommandOutput, ProjectsError> {
508 self.calls.lock().unwrap().push(args.to_vec());
509 self.outputs
510 .lock()
511 .unwrap()
512 .pop_front()
513 .ok_or_else(|| ProjectsError::Unavailable {
514 detail: "ScriptRunner exhausted".into(),
515 })
516 }
517 }
518
519 fn out(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
520 CommandOutput {
521 status,
522 stdout: stdout.to_owned(),
523 stderr: stderr.to_owned(),
524 }
525 }
526
527 fn driver(outputs: Vec<CommandOutput>) -> StripeProjects<ScriptRunner> {
528 StripeProjects::new(ScriptRunner::new(outputs), std::env::temp_dir())
529 }
530
531 #[tokio::test]
532 async fn parses_ok_envelope() {
533 let d = driver(vec![out(
534 0,
535 r#"{"ok":true,"command":"status","version":"0.19.0","data":{"project":{"id":"proj_1"}}}"#,
536 "",
537 )]);
538 let result = d.json(&["status"]).await.unwrap();
539 assert!(result.ok);
540 assert_eq!(result.data["project"]["id"], "proj_1");
541 }
542
543 #[tokio::test]
544 async fn no_json_is_unavailable() {
545 let d = driver(vec![out(127, "", "command not found: stripe")]);
546 let err = d.json(&["status"]).await.unwrap_err();
547 assert_eq!(err.code(), codes::STRIPE_PROJECTS_UNAVAILABLE);
548 assert!(
549 err.to_string()
550 .contains("exited without delivering a JSON envelope"),
551 "detail should name the missing envelope, got: {err}"
552 );
553 }
554
555 #[test]
556 fn stripe_cli_timeout_kills_sleeper_and_is_timeout_fault() {
557 let mut cmd = std::process::Command::new("sleep");
558 cmd.arg("30");
559 match stackless_core::process::run_with_timeout(
560 &mut cmd,
561 STRIPE_CMD_BUDGET.min(Duration::from_millis(200)),
562 ) {
563 stackless_core::process::TimedCommand::TimedOut { pid } => {
564 assert!(pid > 0);
565 let err = ProjectsError::Timeout {
566 budget_secs: STRIPE_CMD_BUDGET.as_secs(),
567 detail: format!("killed process group {pid}"),
568 };
569 assert_eq!(err.code(), codes::STRIPE_PROJECTS_TIMEOUT);
570 }
571 other => panic!("expected timeout, got {other:?}"),
572 }
573 }
574
575 #[test]
576 fn timeout_detail_omits_config_payload() {
577 let detail = timeout_detail(
578 42,
579 &[
580 "add".into(),
581 "--config".into(),
582 r#"{"apiKey":"sk_test_secret"}"#.into(),
583 ],
584 );
585 assert!(detail.contains("stripe projects add"));
586 assert!(
587 !detail.contains("sk_test_secret") && !detail.contains("--config"),
588 "timeout detail leaked argv: {detail}"
589 );
590 }
591
592 #[test]
593 fn provider_from_reference_splits_on_first_slash() {
594 assert_eq!(provider_from_reference("clerk/auth"), "clerk");
595 assert_eq!(
596 provider_from_reference("wordpress.com/site"),
597 "wordpress.com"
598 );
599 assert_eq!(
600 provider_from_reference("cloudflare/r2:bucket"),
601 "cloudflare"
602 );
603 assert_eq!(
604 provider_from_reference("laravel_cloud/mysql"),
605 "laravel_cloud"
606 );
607 assert_eq!(provider_from_reference("bare"), "bare");
608 }
609
610 fn clerk_auth_catalog_envelope() -> String {
611 serde_json::json!({
614 "ok": true,
615 "command": "catalog",
616 "data": {
617 "last_updated": "1970-01-01T00:00:00.000Z",
618 "provider": { "id": "prvdr_clerk", "name": "Clerk" },
619 "provider_filter": { "name": "clerk" },
620 "source": "cache",
621 "services": [{
622 "id": "clerk_auth",
623 "object": "service",
624 "provider_id": "clerk",
625 "provider_name": "Clerk",
626 "service_id": "auth",
627 "kind": "saas",
628 "scope": "account",
629 "availability": "available",
630 "development": true,
631 "livemode": true,
632 "pricing": { "type": "free" }
633 }]
634 }
635 })
636 .to_string()
637 }
638
639 #[tokio::test]
640 async fn catalog_for_reference_passes_provider_filter() {
641 let d = driver(vec![out(0, &clerk_auth_catalog_envelope(), "")]);
642 let catalog = d
643 .catalog_for_reference("clerk/auth")
644 .await
645 .expect("scoped catalog");
646 assert!(catalog.lookup("clerk/auth").is_some());
647 assert_eq!(
648 d.runner().calls(),
649 vec![vec![
650 "catalog".to_owned(),
651 "clerk".to_owned(),
652 "--json".to_owned()
653 ]]
654 );
655 }
656
657 #[tokio::test]
658 async fn catalog_for_reference_missing_service_is_catalog_missing() {
659 let empty = serde_json::json!({
660 "ok": true,
661 "command": "catalog",
662 "data": {
663 "last_updated": "1970-01-01T00:00:00.000Z",
664 "provider_filter": "clerk",
665 "services": []
666 }
667 })
668 .to_string();
669 let d = driver(vec![out(0, &empty, "")]);
670 let err = d.catalog_for_reference("clerk/auth").await.unwrap_err();
671 assert_eq!(err.code(), codes::STRIPE_PROJECTS_CATALOG_MISSING);
672 }
673
674 #[tokio::test]
675 async fn catalog_envelope_falls_back_to_category_filters() {
676 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"}}"#;
677 let filtered = format!(
678 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}}}}"#
679 );
680 let mut outputs = vec![out(0, "", "")];
683 for filter in CATALOG_CATEGORY_FILTERS {
684 if *filter == "database" {
685 outputs.push(out(0, &filtered, ""));
686 } else {
687 outputs.push(out(
688 0,
689 r#"{"ok":true,"command":"projects catalog","version":"0.1","data":{"last_updated":"t","provider":null,"category_filter":null,"provider_filter":null,"services":[],"source":null}}"#,
690 "",
691 ));
692 }
693 }
694 let d = driver(outputs);
695 let json = d.catalog_envelope_json().await.unwrap();
696 let catalog = crate::catalog::Catalog::from_json_envelope(&json).unwrap();
697 assert_eq!(catalog.services.len(), 1);
698 assert_eq!(catalog.services[0].reference(), "neon/postgres");
699 assert!(catalog.category_filter.is_none());
700 }
701
702 #[tokio::test]
703 async fn unauthenticated_envelope_is_auth_fault() {
704 let d = driver(vec![out(
705 0,
706 r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
707 "",
708 )]);
709 let err = d.run_ok("status", &["status"], &[]).await.unwrap_err();
710 assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
711 }
712
713 #[tokio::test]
714 async fn unauthenticated_catalog_envelope_is_auth_fault_not_empty_pipe_fallback() {
715 let d = driver(vec![out(
716 0,
717 r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
718 "",
719 )]);
720 let err = d.catalog_envelope_json().await.unwrap_err();
721 assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
722 assert_eq!(d.runner().calls().len(), 1);
724 assert_eq!(
725 d.runner().calls()[0],
726 vec!["catalog".to_owned(), "--json".to_owned()]
727 );
728 }
729
730 #[tokio::test]
734 async fn live_catalog_matches_model() {
735 if std::env::var("STRIPE_CATALOG_LIVE").as_deref() != Ok("1") {
736 return;
737 }
738 let dir = std::env::current_dir().unwrap();
739 let stripe = StripeProjects::new(TokioRunner, dir);
740 let catalog = stripe.catalog().await.expect("live catalog should fetch");
741 let report = catalog.drift_report();
742 assert!(
743 report.is_empty(),
744 "LIVE catalog drift — refresh tests/fixtures/catalog.json and update the model:\n{}",
745 report.join("\n")
746 );
747 }
748
749 const NORMALIZED_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z";
754
755 #[tokio::test]
761 async fn refresh_blesses_snapshots() {
762 if std::env::var("STRIPE_PROJECTS_REFRESH").as_deref() != Ok("1") {
763 return;
764 }
765 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
766 let stripe = StripeProjects::new(TokioRunner, std::env::current_dir().unwrap());
767
768 let version = crate::surface::plugin_version(&stripe)
770 .await
771 .expect("probe `stripe projects --version`");
772
773 let json = stripe
778 .catalog_envelope_json()
779 .await
780 .expect("run `stripe projects catalog --json`");
781 let report = crate::catalog::Catalog::from_json_envelope(&json)
782 .expect("catalog envelope parses")
783 .drift_report();
784 assert!(
785 report.is_empty(),
786 "live catalog has unmodeled drift — update src/catalog.rs before blessing:\n{}",
787 report.join("\n")
788 );
789 let mut envelope: serde_json::Value =
790 serde_json::from_str(&json).expect("catalog envelope is JSON");
791 if let Some(data) = envelope
792 .get_mut("data")
793 .and_then(serde_json::Value::as_object_mut)
794 && data.contains_key("last_updated")
795 {
796 data.insert(
797 "last_updated".into(),
798 serde_json::Value::String(NORMALIZED_TIMESTAMP.into()),
799 );
800 }
801 let catalog_pretty = format!(
802 "{}\n",
803 serde_json::to_string_pretty(&envelope).expect("serialize catalog")
804 );
805
806 let body = crate::surface::command_surface(&stripe)
808 .await
809 .expect("capture command surface");
810 let surface = crate::surface::render_surface(&version, &body);
811
812 std::fs::write(fixtures.join("catalog.json"), catalog_pretty).unwrap();
813 std::fs::write(fixtures.join("command-surface.txt"), surface).unwrap();
814 std::fs::write(fixtures.join("plugin-version.txt"), format!("{version}\n")).unwrap();
815 eprintln!("blessed snapshots for stripe projects plugin v{version}");
816 }
817
818 #[tokio::test]
819 async fn confirmation_code_falls_back_to_plain_mode() {
820 let d = driver(vec![
821 out(
822 0,
823 r#"{"ok":false,"error":{"code":"JSON_REQUIRES_CONFIRMATION","message":"needs confirmation"}}"#,
824 "",
825 ),
826 out(0, "✓ created project", ""),
827 ]);
828 d.run_ok(
829 "init",
830 &["init", "atto", "--skip-skills", "--accept-tos"],
831 &["--accept-tos", "--yes"],
832 )
833 .await
834 .unwrap();
835 let calls = d.runner().calls();
836 assert_eq!(calls.len(), 2);
837 assert!(calls[0].contains(&"--json".to_owned()));
838 assert!(!calls[1].contains(&"--json".to_owned()));
839 assert!(calls[1].contains(&"--yes".to_owned()));
840 }
841}