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, ServicesListResponse, StatusResponse, preflight_checks_from_parts,
14};
15use crate::stripe::{CommandRunner, StripeProjects};
16
17pub const INIT_PREFLIGHT_FLAGS: &[&str] =
20 &["--preflight", "--skip-skills", "--accept-tos", "--yes"];
21
22pub fn recorded_project_id(def: &StackDef) -> Option<String> {
24 def.stack
25 .projects
26 .stripe
27 .as_ref()
28 .and_then(|stripe| stripe.project.clone())
29}
30
31pub async fn ensure_project<R: CommandRunner>(
32 stripe: &StripeProjects<R>,
33 def: &StackDef,
34 definition_dir: &Path,
35) -> Result<(), ProjectsError> {
36 let recorded = recorded_project_id(def);
37 let status = stripe.json(&["status"]).await?;
38 let linked = serde_json::from_value::<StatusResponse>(status.data)
39 .ok()
40 .and_then(|s| s.project_id().map(str::to_owned));
41
42 match (&recorded, &linked) {
43 (Some(want), Some(have)) if want == have => Ok(()),
44 (Some(want), _) => {
45 stripe
46 .run_ok(
47 "pull",
48 &["pull", want, "--skip-skills", "--yes"],
49 &["--yes"],
50 )
51 .await?;
52 Ok(())
53 }
54 (None, Some(have)) => {
55 write_project_anchor(definition_dir, have)?;
56 Ok(())
57 }
58 (None, None) => {
59 run_init_preflight(stripe, def.stack.name.as_str()).await?;
60 stripe
61 .run_ok(
62 "init",
63 &[
64 "init",
65 def.stack.name.as_str(),
66 "--skip-skills",
67 "--accept-tos",
68 ],
69 &["--accept-tos", "--yes"],
70 )
71 .await?;
72 let status = stripe.json(&["status"]).await?;
73 let id = serde_json::from_value::<StatusResponse>(status.data)
74 .ok()
75 .and_then(|s| s.project_id().map(str::to_owned))
76 .ok_or_else(|| ProjectsError::ProjectAnchor {
77 detail: "created project but status reported no id".into(),
78 })?;
79 write_project_anchor(definition_dir, &id)?;
80 Ok(())
81 }
82 }
83}
84
85pub fn write_project_anchor(definition_dir: &Path, project_id: &str) -> Result<(), ProjectsError> {
86 let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(definition_dir);
87 let _guard = stackless_core::lockfile::FileLock::acquire_with_wait(
88 &lock_path,
89 Duration::from_secs(30 * 60),
90 )
91 .map_err(|err| ProjectsError::LockHeld {
92 definition_dir: definition_dir.display().to_string(),
93 detail: err.to_string(),
94 })?;
95 let path = definition_dir.join("stackless.toml");
96 let text = std::fs::read_to_string(&path).map_err(|err| ProjectsError::ProjectAnchor {
97 detail: format!("cannot read {}: {err}", path.display()),
98 })?;
99 let mut doc =
100 text.parse::<toml_edit::DocumentMut>()
101 .map_err(|err| ProjectsError::ProjectAnchor {
102 detail: format!("cannot parse {}: {err}", path.display()),
103 })?;
104 let stack = doc["stack"].or_insert(toml_edit::table());
105 if let Some(stack_table) = stack.as_table_mut() {
106 stack_table.set_implicit(false);
107 }
108 let projects = doc["stack"]["projects"].or_insert(toml_edit::table());
109 if let Some(projects_table) = projects.as_table_mut() {
110 projects_table.set_implicit(false);
111 }
112 let stripe = doc["stack"]["projects"]["stripe"].or_insert(toml_edit::table());
113 if let Some(stripe_table) = stripe.as_table_mut() {
114 stripe_table.set_implicit(false);
115 }
116 doc["stack"]["projects"]["stripe"]["project"] = toml_edit::value(project_id);
117 std::fs::write(&path, doc.to_string()).map_err(|err| ProjectsError::ProjectAnchor {
118 detail: format!("cannot write {}: {err}", path.display()),
119 })?;
120 Ok(())
121}
122
123pub async fn ensure_environment<R: CommandRunner>(
124 stripe: &StripeProjects<R>,
125 instance: &str,
126) -> Result<(), ProjectsError> {
127 let list = stripe.json(&["env", "list"]).await?;
128 let exists = serde_json::from_value::<EnvListResponse>(list.data)
129 .map(|response| response.contains(instance))
130 .unwrap_or(false);
131 if exists {
132 stripe
133 .run_ok("env use", &["env", "use", instance], &["--yes"])
134 .await?;
135 } else {
136 let output = format!(".env.{instance}");
137 stripe
138 .run_ok(
139 "env create",
140 &["env", "create", instance, "--output", &output, "--yes"],
141 &["--yes"],
142 )
143 .await?;
144 }
145 Ok(())
146}
147
148pub async fn resource_registered<R: CommandRunner>(
149 stripe: &StripeProjects<R>,
150 name: &str,
151) -> Result<bool, ProjectsError> {
152 let result = stripe.json(&["services", "list"]).await?;
153 if !result.ok {
154 return Ok(false);
155 }
156 Ok(serde_json::from_value::<ServicesListResponse>(result.data)
157 .map(|response| response.contains(name))
158 .unwrap_or(false))
159}
160
161pub async fn add_resource<R: CommandRunner>(
162 stripe: &StripeProjects<R>,
163 reference: &str,
164 name: &str,
165 config: &Value,
166 paid: bool,
167) -> Result<Value, ProjectsError> {
168 if resource_registered(stripe, name).await? {
169 return Ok(Value::Null);
170 }
171 let config_str = config.to_string();
172 let mut args: Vec<&str> = vec![
173 "add",
174 reference,
175 "--name",
176 name,
177 "--config",
178 &config_str,
179 "--accept-tos",
180 "--yes",
181 ];
182 if paid {
183 args.push("--confirm-paid-service");
184 }
185 let plain_extra = if paid {
186 vec!["--accept-tos", "--yes", "--confirm-paid-service"]
187 } else {
188 vec!["--accept-tos", "--yes"]
189 };
190 let data = stripe
191 .run_ok(&format!("add {reference}"), &args, &plain_extra)
192 .await?;
193 stripe
194 .run_ok(
195 &format!("env add {name}"),
196 &["env", "add", name, "--resource"],
197 &["--yes"],
198 )
199 .await?;
200 Ok(data)
201}
202
203pub async fn refreshed_env_value<R: CommandRunner>(
204 stripe: &StripeProjects<R>,
205 service_reference: &str,
206 key: &str,
207) -> Result<Option<String>, ProjectsError> {
208 let data = stripe
209 .run_ok(
210 "env",
211 &["env", "--service", service_reference, "--refresh"],
212 &["--yes"],
213 )
214 .await?;
215 Ok(find_env_value(&data, key))
216}
217
218pub async fn pull_env_value<R: CommandRunner>(
219 stripe: &StripeProjects<R>,
220 instance: &str,
221 key: &str,
222) -> Result<Option<String>, ProjectsError> {
223 Ok(pull_env_values(stripe, instance, &[key])
224 .await?
225 .into_iter()
226 .next()
227 .flatten())
228}
229
230pub async fn pull_env_values<R: CommandRunner>(
234 stripe: &StripeProjects<R>,
235 instance: &str,
236 keys: &[&str],
237) -> Result<Vec<Option<String>>, ProjectsError> {
238 refresh_vault(stripe).await?;
239 let map = vault_env_from_dir(stripe.dir(), Some(instance));
240 let values = keys.iter().map(|&key| map.get(key).cloned()).collect();
241 Ok(values)
242}
243
244pub fn find_env_value(value: &Value, key: &str) -> Option<String> {
245 match value {
246 Value::Object(map) => {
247 if let Some(found) = map.get(key).and_then(Value::as_str)
248 && !is_redacted(found)
249 {
250 return Some(found.to_owned());
251 }
252 let named_key = map
253 .get("key")
254 .or_else(|| map.get("name"))
255 .and_then(Value::as_str);
256 if named_key == Some(key)
257 && let Some(found) = map.get("value").and_then(Value::as_str)
258 && !is_redacted(found)
259 {
260 return Some(found.to_owned());
261 }
262 map.values().find_map(|child| find_env_value(child, key))
263 }
264 Value::Array(values) => values.iter().find_map(|child| find_env_value(child, key)),
265 _ => None,
266 }
267}
268
269fn is_redacted(value: &str) -> bool {
270 let lower = value.to_ascii_lowercase();
271 value.contains('•')
272 || value.contains('*')
273 || lower.contains("redacted")
274 || lower.contains("hidden")
275}
276
277pub fn unquote_env_value(value: &str) -> String {
278 let bytes = value.as_bytes();
279 if bytes.len() >= 2
280 && ((bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
281 || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"'))
282 {
283 value[1..value.len() - 1].to_owned()
284 } else {
285 value.to_owned()
286 }
287}
288
289pub async fn remove_resource<R: CommandRunner>(
290 stripe: &StripeProjects<R>,
291 resource: &str,
292) -> Result<(), ProjectsError> {
293 if !resource_registered(stripe, resource).await? {
297 return Ok(());
298 }
299 stripe
300 .run_ok(
301 &format!("remove {resource}"),
302 &["remove", resource, "--yes", "--force"],
303 &["--yes", "--force"],
304 )
305 .await?;
306 Ok(())
307}
308
309pub async fn delete_environment<R: CommandRunner>(
310 stripe: &StripeProjects<R>,
311 instance: &str,
312) -> Result<(), ProjectsError> {
313 stripe
314 .run_ok(
315 &format!("env delete {instance}"),
316 &["env", "delete", instance, "--yes"],
317 &["--yes"],
318 )
319 .await?;
320 Ok(())
321}
322
323pub async fn set_spend_cap<R: CommandRunner>(
324 stripe: &StripeProjects<R>,
325 limit_usd: u32,
326 provider: &str,
327) -> Result<(), ProjectsError> {
328 let limit = limit_usd.to_string();
329 stripe
330 .run_ok(
331 "billing update",
332 &[
333 "billing",
334 "update",
335 "--limit",
336 &limit,
337 "--provider",
338 provider,
339 "--yes",
340 ],
341 &["--yes"],
342 )
343 .await?;
344 Ok(())
345}
346
347pub async fn spend_summary<R: CommandRunner>(stripe: &StripeProjects<R>) -> Option<String> {
348 let result = stripe.json(&["spend"]).await.ok()?;
349 if !result.ok {
350 return None;
351 }
352 Some(result.data.to_string())
353}
354
355pub async fn run_init_preflight<R: CommandRunner>(
358 stripe: &StripeProjects<R>,
359 stack_name: &str,
360) -> Result<(), ProjectsError> {
361 let mut args = Vec::with_capacity(2 + INIT_PREFLIGHT_FLAGS.len());
362 args.push("init");
363 args.push(stack_name);
364 args.extend_from_slice(INIT_PREFLIGHT_FLAGS);
365 let result = stripe.json(&args).await?;
366 if result.ok {
367 return Ok(());
368 }
369 Err(preflight_failure("init", &result))
370}
371
372async fn refresh_vault<R: CommandRunner>(stripe: &StripeProjects<R>) -> Result<(), ProjectsError> {
373 stripe
374 .run_ok("env --pull", &["env", "--pull", "--refresh"], &["--yes"])
375 .await?;
376 Ok(())
377}
378
379const EMPTY_ENV_PULL_CODE: &str = "PROJECT_ENVIRONMENT_HAS_NO_RESOURCES";
380
381pub async fn sync_vault_pull_for_instance<R: CommandRunner>(
390 stripe: &StripeProjects<R>,
391 instance: &str,
392) -> Result<(), ProjectsError> {
393 ensure_environment(stripe, instance).await?;
394 match refresh_vault(stripe).await {
395 Ok(()) => Ok(()),
396 Err(ProjectsError::Failed { detail, .. }) if detail.contains(EMPTY_ENV_PULL_CODE) => {
397 clear_stale_instance_env(stripe.dir(), instance);
398 Ok(())
399 }
400 Err(err) => Err(err),
401 }
402}
403
404fn clear_stale_instance_env(definition_dir: &Path, instance: &str) {
405 let path = definition_dir.join(format!(".env.{instance}"));
406 let _ = std::fs::remove_file(&path);
407}
408
409pub fn project_initialized_in_dir(definition_dir: &Path) -> bool {
412 definition_dir.join(".projects").is_dir()
413}
414
415pub fn vault_env_from_dir(
418 definition_dir: &Path,
419 instance: Option<&str>,
420) -> BTreeMap<String, String> {
421 let mut out = BTreeMap::new();
422 let base = definition_dir.join(".env");
423 if let Ok(text) = std::fs::read_to_string(&base) {
424 merge_env_lines(&mut out, &text);
425 }
426 if let Some(instance) = instance {
427 let inst = definition_dir.join(format!(".env.{instance}"));
428 if let Ok(text) = std::fs::read_to_string(inst) {
429 merge_env_lines(&mut out, &text);
430 }
431 }
432 out
433}
434
435pub fn merge_env_lines(out: &mut BTreeMap<String, String>, text: &str) {
437 for line in text.lines() {
438 let line = line.trim();
439 if line.is_empty() || line.starts_with('#') {
440 continue;
441 }
442 let Some((key, value)) = line.split_once('=') else {
443 continue;
444 };
445 out.insert(key.trim().to_owned(), unquote_env_value(value.trim()));
446 }
447}
448
449fn preflight_failure(command: &str, result: &crate::stripe::StripeResult) -> ProjectsError {
450 let checks =
451 preflight_checks_from_parts(&result.data, result.ok, result.error_details.as_ref());
452 let detail = if checks.iter().any(|c| !c.pass) {
453 checks
454 .iter()
455 .filter(|c| !c.pass)
456 .map(|c| {
457 let remedy = c.remedy.as_deref().unwrap_or("");
458 if remedy.is_empty() {
459 c.label.clone()
460 } else {
461 format!("{} — {remedy}", c.label)
462 }
463 })
464 .collect::<Vec<_>>()
465 .join("; ")
466 } else {
467 result
468 .error_message
469 .clone()
470 .unwrap_or_else(|| "preflight blocked".into())
471 };
472 ProjectsError::Failed {
473 command: command.to_owned(),
474 detail,
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::stripe::{CommandOutput, CommandRunner, StripeProjects};
482 use crate::test_support::{ScriptedRunner, env_list, ok_empty};
483 use async_trait::async_trait;
484 use serde_json::json;
485
486 #[test]
487 fn anchor_writeback_preserves_comments_and_adds_neutral_project() {
488 let dir = tempfile::tempdir().unwrap();
489 let path = dir.path().join("stackless.toml");
490 std::fs::write(
491 &path,
492 "# atto dogfood\n[stack]\nname = \"atto\"\n\n[stack.render]\nregion = \"oregon\"\n",
493 )
494 .unwrap();
495
496 write_project_anchor(dir.path(), "project_abc123").unwrap();
497
498 let after = std::fs::read_to_string(&path).unwrap();
499 assert!(after.contains("# atto dogfood"));
500 assert!(after.contains("project = \"project_abc123\""));
501
502 let doc: toml::Value = toml::from_str(&after).unwrap();
503 assert_eq!(
504 doc["stack"]["projects"]["stripe"]["project"].as_str(),
505 Some("project_abc123")
506 );
507 }
508
509 struct ListRunner {
510 body: String,
511 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
512 }
513
514 #[async_trait]
515 impl CommandRunner for ListRunner {
516 async fn run(
517 &self,
518 _args: &[String],
519 _cwd: &std::path::Path,
520 ) -> Result<CommandOutput, ProjectsError> {
521 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
522 Ok(CommandOutput {
523 status: 0,
524 stdout: self.body.clone(),
525 stderr: String::new(),
526 })
527 }
528 }
529
530 #[tokio::test]
531 async fn add_resource_propagates_env_add_failure() {
532 struct FailEnvAddRunner;
533
534 #[async_trait]
535 impl CommandRunner for FailEnvAddRunner {
536 async fn run(
537 &self,
538 args: &[String],
539 _cwd: &std::path::Path,
540 ) -> Result<CommandOutput, ProjectsError> {
541 if args.iter().any(|a| a == "list") {
542 return Ok(CommandOutput {
543 status: 0,
544 stdout: r#"{"ok":true,"data":{"services":[]}}"#.into(),
545 stderr: String::new(),
546 });
547 }
548 if args.iter().any(|a| a == "add") && args.iter().any(|a| a == "--resource") {
549 return Ok(CommandOutput {
550 status: 0,
551 stdout: r#"{"ok":false,"error":{"message":"member missing"}}"#.into(),
552 stderr: String::new(),
553 });
554 }
555 Ok(CommandOutput {
556 status: 0,
557 stdout: r#"{"ok":true,"data":{}}"#.into(),
558 stderr: String::new(),
559 })
560 }
561 }
562
563 let stripe = StripeProjects::new(FailEnvAddRunner, std::env::temp_dir());
564 let err = add_resource(
565 &stripe,
566 "render/static-site",
567 "demo-web",
568 &serde_json::json!({}),
569 false,
570 )
571 .await
572 .unwrap_err();
573 assert!(matches!(err, ProjectsError::Failed { .. }));
574 }
575
576 #[test]
577 fn vault_env_from_dir_prefers_instance_file() {
578 let dir = tempfile::tempdir().unwrap();
579 std::fs::write(dir.path().join(".env"), "KEY=base\n").unwrap();
580 std::fs::write(dir.path().join(".env.demo"), "KEY=instance\n").unwrap();
581 let map = vault_env_from_dir(dir.path(), Some("demo"));
582 assert_eq!(map.get("KEY").map(String::as_str), Some("instance"));
583 }
584
585 #[tokio::test]
586 async fn add_resource_skips_when_already_registered() {
587 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
588 let runner = ListRunner {
589 body: r#"{"ok":true,"data":{"services":[{"name":"atto-cloud-web"}]}}"#.to_owned(),
590 calls: calls.clone(),
591 };
592 let stripe = StripeProjects::new(runner, std::env::temp_dir());
593 add_resource(
594 &stripe,
595 "render/static-site",
596 "atto-cloud-web",
597 &serde_json::json!({}),
598 false,
599 )
600 .await
601 .unwrap();
602 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
603 }
604
605 #[tokio::test]
606 async fn sync_vault_pull_soft_succeeds_on_empty_environment() {
607 let dir = tempfile::tempdir().unwrap();
608 let stale = dir.path().join(".env.demo");
609 std::fs::write(&stale, "CLERK_AUTH_ENVIRONMENTS=stale\n").unwrap();
610 let runner = ScriptedRunner::new(vec![
611 env_list(&["demo"]),
612 ok_empty(), CommandOutput {
614 status: 0,
615 stdout: json!({
616 "ok": false,
617 "error": {
618 "code": "PROJECT_ENVIRONMENT_HAS_NO_RESOURCES",
619 "message": "environment has no resources"
620 }
621 })
622 .to_string(),
623 stderr: String::new(),
624 },
625 ]);
626 let stripe = StripeProjects::new(&runner, dir.path());
627 sync_vault_pull_for_instance(&stripe, "demo").await.unwrap();
628 assert!(!stale.exists(), "stale instance env file must be cleared");
629 }
630
631 #[tokio::test]
632 async fn sync_vault_pull_still_fails_on_other_errors() {
633 let dir = tempfile::tempdir().unwrap();
634 let runner = ScriptedRunner::new(vec![
635 env_list(&["demo"]),
636 ok_empty(),
637 CommandOutput {
638 status: 0,
639 stdout: json!({
640 "ok": false,
641 "error": {
642 "code": "SOME_OTHER_FAILURE",
643 "message": "boom"
644 }
645 })
646 .to_string(),
647 stderr: String::new(),
648 },
649 ]);
650 let stripe = StripeProjects::new(&runner, dir.path());
651 let err = sync_vault_pull_for_instance(&stripe, "demo")
652 .await
653 .unwrap_err();
654 assert!(matches!(err, ProjectsError::Failed { .. }));
655 assert!(err.to_string().contains("SOME_OTHER_FAILURE"));
656 }
657}