stackless_stripe_projects/
stripe.rs1use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use async_trait::async_trait;
10use serde::Deserialize;
11
12use crate::error::ProjectsError;
13
14const STRIPE_LOCK_BUDGET: Duration = Duration::from_secs(30 * 60);
15
16#[derive(Debug, Clone)]
17pub struct CommandOutput {
18 pub status: i32,
19 pub stdout: String,
20 pub stderr: String,
21}
22
23#[async_trait]
24pub trait CommandRunner: Send + Sync {
25 async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError>;
26}
27
28#[async_trait]
29impl<T: CommandRunner + ?Sized> CommandRunner for &T {
30 async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
31 (**self).run(args, cwd).await
32 }
33}
34
35#[derive(Debug, Default)]
36pub struct TokioRunner;
37
38#[async_trait]
39impl CommandRunner for TokioRunner {
40 async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
41 let args = args.to_vec();
42 let cwd: PathBuf = cwd.to_path_buf();
43 tokio::task::spawn_blocking(move || run_stripe_locked(&args, &cwd))
44 .await
45 .map_err(|err| ProjectsError::Unavailable {
46 detail: format!("stripe task panicked: {err}"),
47 })?
48 }
49}
50
51fn run_stripe_locked(args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
52 let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(cwd);
53 let _guard =
54 stackless_core::lockfile::FileLock::acquire_with_wait(&lock_path, STRIPE_LOCK_BUDGET)
55 .map_err(|err| ProjectsError::LockHeld {
56 definition_dir: cwd.display().to_string(),
57 detail: err.to_string(),
58 })?;
59 let output = std::process::Command::new("stripe")
60 .arg("projects")
61 .args(args)
62 .current_dir(cwd)
63 .output()
64 .map_err(|err| ProjectsError::Unavailable {
65 detail: format!("could not run `stripe`: {err}"),
66 })?;
67 Ok(CommandOutput {
68 status: output.status.code().unwrap_or(-1),
69 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
70 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
71 })
72}
73
74#[derive(Debug, Deserialize)]
75struct Envelope {
76 ok: bool,
77 #[serde(default)]
78 error: Option<EnvelopeError>,
79 #[serde(default)]
80 data: Option<serde_json::Value>,
81 #[serde(default)]
82 meta: Option<EnvelopeMeta>,
83}
84
85#[derive(Debug, Deserialize)]
86struct EnvelopeError {
87 #[serde(default)]
88 code: Option<String>,
89 #[serde(default)]
90 message: Option<String>,
91 #[serde(default)]
92 details: Option<serde_json::Value>,
93}
94
95#[derive(Debug, Deserialize)]
96struct EnvelopeMeta {
97 #[serde(default)]
98 authenticated: Option<bool>,
99}
100
101#[derive(Debug, Clone)]
102pub struct StripeResult {
103 pub ok: bool,
104 pub error_code: Option<String>,
105 pub error_message: Option<String>,
106 pub error_details: Option<serde_json::Value>,
107 pub authenticated: bool,
108 pub data: serde_json::Value,
109}
110
111const PLAIN_FALLBACK_CODES: &[&str] = &[
112 "JSON_REQUIRES_CONFIRMATION",
113 "JSON_REQUIRES_AUTH",
114 "DIRECTORY_SELECTION_REQUIRED",
115];
116
117pub struct StripeProjects<R: CommandRunner> {
118 runner: R,
119 dir: PathBuf,
120}
121
122impl<R: CommandRunner> std::fmt::Debug for StripeProjects<R> {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("StripeProjects")
125 .field("dir", &self.dir)
126 .finish_non_exhaustive()
127 }
128}
129
130impl<R: CommandRunner> StripeProjects<R> {
131 pub fn new(runner: R, dir: impl Into<PathBuf>) -> Self {
132 Self {
133 runner,
134 dir: dir.into(),
135 }
136 }
137
138 pub fn dir(&self) -> &Path {
139 &self.dir
140 }
141
142 pub fn as_dyn(&self) -> StripeProjects<&'_ dyn CommandRunner> {
146 StripeProjects {
147 runner: &self.runner as &dyn CommandRunner,
148 dir: self.dir.clone(),
149 }
150 }
151
152 #[cfg(test)]
153 fn runner(&self) -> &R {
154 &self.runner
155 }
156
157 pub async fn json(&self, args: &[&str]) -> Result<StripeResult, ProjectsError> {
158 let mut argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
159 argv.push("--json".into());
160 let out = self.runner.run(&argv, &self.dir).await?;
161 let Some(start) = out.stdout.find('{') else {
162 let stderr = out.stderr.trim();
163 return Err(ProjectsError::Unavailable {
164 detail: format!(
165 "`stripe projects {}` produced no JSON output{}",
166 args.join(" "),
167 if stderr.is_empty() {
168 String::new()
169 } else {
170 format!(" (stderr: {stderr})")
171 }
172 ),
173 });
174 };
175 let envelope: Envelope = serde_json::from_str(&out.stdout[start..]).map_err(|err| {
176 ProjectsError::Unavailable {
177 detail: format!(
178 "`stripe projects {}` emitted malformed JSON: {err}",
179 args.join(" ")
180 ),
181 }
182 })?;
183 Ok(StripeResult {
184 ok: envelope.ok,
185 error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
186 error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
187 error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
188 authenticated: envelope
189 .meta
190 .as_ref()
191 .and_then(|m| m.authenticated)
192 .unwrap_or(true),
193 data: envelope.data.unwrap_or(serde_json::Value::Null),
194 })
195 }
196
197 pub async fn catalog(&self) -> Result<crate::catalog::Catalog, ProjectsError> {
199 let data = self.run_ok("catalog", &["catalog"], &[]).await?;
200 serde_json::from_value(data).map_err(|err| ProjectsError::Unavailable {
201 detail: format!("`stripe projects catalog` returned an unmodeled catalog: {err}"),
202 })
203 }
204
205 pub async fn plain(&self, args: &[&str]) -> Result<CommandOutput, ProjectsError> {
206 let argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
207 self.runner.run(&argv, &self.dir).await
208 }
209
210 pub fn classify_failure(&self, command: &str, result: &StripeResult) -> ProjectsError {
211 let message = result
212 .error_message
213 .clone()
214 .unwrap_or_else(|| "unknown error".into());
215 let code = result.error_code.as_deref().unwrap_or("");
216 let auth_like = !result.authenticated
217 || code == "JSON_REQUIRES_AUTH"
218 || message.to_ascii_lowercase().contains("not authenticated")
219 || message.to_ascii_lowercase().contains("log in");
220 if auth_like {
221 ProjectsError::Auth { detail: message }
222 } else {
223 ProjectsError::Failed {
224 command: command.to_owned(),
225 detail: format!(
226 "{message}{}",
227 if code.is_empty() {
228 String::new()
229 } else {
230 format!(" ({code})")
231 }
232 ),
233 }
234 }
235 }
236
237 pub async fn run_ok(
238 &self,
239 command: &str,
240 args: &[&str],
241 plain_extra: &[&str],
242 ) -> Result<serde_json::Value, ProjectsError> {
243 let result = self.json(args).await?;
244 if result.ok {
245 return Ok(result.data);
246 }
247 let code = result.error_code.as_deref().unwrap_or("");
248 let message = result.error_message.clone().unwrap_or_default();
249 let live_mode = message.to_ascii_lowercase().contains("live mode");
250 if PLAIN_FALLBACK_CODES.contains(&code) || live_mode {
251 let mut plain_args: Vec<&str> = args.to_vec();
252 plain_args.extend_from_slice(plain_extra);
253 let out = self.plain(&plain_args).await?;
254 if out.status != 0 || out.stdout.contains('✗') || out.stderr.contains('✗') {
255 return Err(ProjectsError::Failed {
256 command: command.to_owned(),
257 detail: merge_output(&out),
258 });
259 }
260 return Ok(serde_json::Value::Null);
261 }
262 Err(self.classify_failure(command, &result))
263 }
264}
265
266fn merge_output(out: &CommandOutput) -> String {
267 let merged = format!("{}{}", out.stdout.trim(), out.stderr.trim());
268 merged.trim().to_owned()
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use stackless_core::fault::{Fault, codes};
275 use std::sync::Mutex;
276
277 struct ScriptRunner {
278 outputs: Mutex<std::collections::VecDeque<CommandOutput>>,
279 calls: Mutex<Vec<Vec<String>>>,
280 }
281
282 impl ScriptRunner {
283 fn new(outputs: Vec<CommandOutput>) -> Self {
284 Self {
285 outputs: Mutex::new(outputs.into()),
286 calls: Mutex::new(Vec::new()),
287 }
288 }
289
290 fn calls(&self) -> Vec<Vec<String>> {
291 self.calls.lock().unwrap().clone()
292 }
293 }
294
295 #[async_trait]
296 impl CommandRunner for ScriptRunner {
297 async fn run(&self, args: &[String], _cwd: &Path) -> Result<CommandOutput, ProjectsError> {
298 self.calls.lock().unwrap().push(args.to_vec());
299 self.outputs
300 .lock()
301 .unwrap()
302 .pop_front()
303 .ok_or_else(|| ProjectsError::Unavailable {
304 detail: "ScriptRunner exhausted".into(),
305 })
306 }
307 }
308
309 fn out(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
310 CommandOutput {
311 status,
312 stdout: stdout.to_owned(),
313 stderr: stderr.to_owned(),
314 }
315 }
316
317 fn driver(outputs: Vec<CommandOutput>) -> StripeProjects<ScriptRunner> {
318 StripeProjects::new(ScriptRunner::new(outputs), std::env::temp_dir())
319 }
320
321 #[tokio::test]
322 async fn parses_ok_envelope() {
323 let d = driver(vec![out(
324 0,
325 r#"{"ok":true,"command":"status","version":"0.19.0","data":{"project":{"id":"proj_1"}}}"#,
326 "",
327 )]);
328 let result = d.json(&["status"]).await.unwrap();
329 assert!(result.ok);
330 assert_eq!(result.data["project"]["id"], "proj_1");
331 }
332
333 #[tokio::test]
334 async fn no_json_is_unavailable() {
335 let d = driver(vec![out(127, "", "command not found: stripe")]);
336 let err = d.json(&["status"]).await.unwrap_err();
337 assert_eq!(err.code(), codes::STRIPE_PROJECTS_UNAVAILABLE);
338 }
339
340 #[tokio::test]
341 async fn unauthenticated_envelope_is_auth_fault() {
342 let d = driver(vec![out(
343 0,
344 r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
345 "",
346 )]);
347 let err = d.run_ok("status", &["status"], &[]).await.unwrap_err();
348 assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
349 }
350
351 #[tokio::test]
355 async fn live_catalog_matches_model() {
356 if std::env::var("STRIPE_CATALOG_LIVE").as_deref() != Ok("1") {
357 return;
358 }
359 let dir = std::env::current_dir().unwrap();
360 let stripe = StripeProjects::new(TokioRunner, dir);
361 let catalog = stripe.catalog().await.expect("live catalog should fetch");
362 let report = catalog.drift_report();
363 assert!(
364 report.is_empty(),
365 "LIVE catalog drift — refresh tests/fixtures/catalog.json and update the model:\n{}",
366 report.join("\n")
367 );
368 }
369
370 const NORMALIZED_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z";
375
376 #[tokio::test]
382 async fn refresh_blesses_snapshots() {
383 if std::env::var("STRIPE_PROJECTS_REFRESH").as_deref() != Ok("1") {
384 return;
385 }
386 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
387 let stripe = StripeProjects::new(TokioRunner, std::env::current_dir().unwrap());
388
389 let version = crate::surface::plugin_version(&stripe)
391 .await
392 .expect("probe `stripe projects --version`");
393
394 let raw = stripe
397 .plain(&["catalog", "--json"])
398 .await
399 .expect("run `stripe projects catalog --json`");
400 let start = raw.stdout.find('{').expect("catalog produced JSON");
401 let json = &raw.stdout[start..];
402 let report = crate::catalog::Catalog::from_json_envelope(json)
403 .expect("catalog envelope parses")
404 .drift_report();
405 assert!(
406 report.is_empty(),
407 "live catalog has unmodeled drift — update src/catalog.rs before blessing:\n{}",
408 report.join("\n")
409 );
410 let mut envelope: serde_json::Value =
411 serde_json::from_str(json).expect("catalog envelope is JSON");
412 if let Some(data) = envelope
413 .get_mut("data")
414 .and_then(serde_json::Value::as_object_mut)
415 && data.contains_key("last_updated")
416 {
417 data.insert(
418 "last_updated".into(),
419 serde_json::Value::String(NORMALIZED_TIMESTAMP.into()),
420 );
421 }
422 let catalog_pretty = format!(
423 "{}\n",
424 serde_json::to_string_pretty(&envelope).expect("serialize catalog")
425 );
426
427 let body = crate::surface::command_surface(&stripe)
429 .await
430 .expect("capture command surface");
431 let surface = crate::surface::render_surface(&version, &body);
432
433 std::fs::write(fixtures.join("catalog.json"), catalog_pretty).unwrap();
434 std::fs::write(fixtures.join("command-surface.txt"), surface).unwrap();
435 std::fs::write(fixtures.join("plugin-version.txt"), format!("{version}\n")).unwrap();
436 eprintln!("blessed snapshots for stripe projects plugin v{version}");
437 }
438
439 #[tokio::test]
440 async fn confirmation_code_falls_back_to_plain_mode() {
441 let d = driver(vec![
442 out(
443 0,
444 r#"{"ok":false,"error":{"code":"JSON_REQUIRES_CONFIRMATION","message":"needs confirmation"}}"#,
445 "",
446 ),
447 out(0, "✓ created project", ""),
448 ]);
449 d.run_ok(
450 "init",
451 &["init", "atto", "--skip-skills", "--accept-tos"],
452 &["--accept-tos", "--yes"],
453 )
454 .await
455 .unwrap();
456 let calls = d.runner().calls();
457 assert_eq!(calls.len(), 2);
458 assert!(calls[0].contains(&"--json".to_owned()));
459 assert!(!calls[1].contains(&"--json".to_owned()));
460 assert!(calls[1].contains(&"--yes".to_owned()));
461 }
462}