1use 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#[derive(Debug)]
20pub struct AddedResource {
21 pub name: String,
22 pub data: Value,
23}
24
25pub const INIT_PREFLIGHT_FLAGS: &[&str] =
28 &["--preflight", "--skip-skills", "--accept-tos", "--yes"];
29
30pub 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
166pub 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
174fn 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
185fn 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
227pub 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 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
327pub 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 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
458pub 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
484pub 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
512pub fn project_initialized_in_dir(definition_dir: &Path) -> bool {
515 definition_dir.join(".projects").is_dir()
516}
517
518pub 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
538pub 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")]), ok_empty(), ]);
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")]), ok_empty(), ]);
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")]), ok_empty(), ]);
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(), ]);
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" } })), ok_empty(), ]);
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" } })), ok_empty(), ]);
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(&[]), ok(json!({ "variables": { "K": "v" } })), ok_empty(), ]);
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(), 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}