Skip to main content

stellar_scaffold_cli/
extension.rs

1use std::io::Write as _;
2use std::path::PathBuf;
3
4use stellar_cli::print::Print;
5use stellar_scaffold_ext_types::{ExtensionManifest, HookName};
6use tokio::io::AsyncWriteExt as _;
7
8use crate::commands::build::env_toml::ExtensionEntry;
9
10/// A fully validated, ready-to-invoke extension.
11#[derive(Debug, Clone)]
12pub struct ResolvedExtension {
13    /// Name as declared in `environments.toml` (e.g. `"reporter"`).
14    pub name: String,
15    /// Absolute path to the `stellar-scaffold-<name>` binary.
16    pub binary: PathBuf,
17    /// Parsed manifest returned by `stellar-scaffold-<name> manifest`.
18    pub manifest: ExtensionManifest,
19    /// Per-extension config from `[env.ext.<name>]`, if provided.
20    pub config: Option<serde_json::Value>,
21}
22
23/// Resolves each entry in `entries` to a [`ResolvedExtension`] by finding the
24/// binary on `PATH`, invoking `<binary> manifest`, and parsing the output.
25///
26/// Missing binaries, failed invocations, and malformed manifests are each
27/// warned and skipped — this never fails the overall build. The returned list
28/// preserves the input order, minus any entries that could not be resolved.
29pub fn discover(entries: &[ExtensionEntry], printer: &Print) -> Vec<ResolvedExtension> {
30    let search_dirs = path_dirs();
31    discover_in(entries, printer, &search_dirs)
32}
33
34/// Runs a single lifecycle hook across all registered extensions.
35///
36/// For each extension whose manifest lists `hook`, spawns
37/// `stellar-scaffold-<name> <hook>` as a subprocess, serializes `context`
38/// as JSON with `config` injected from the extension's entry in
39/// `environments.toml`, writes the result to stdin, waits for it to exit,
40/// then forwards its stdout to Scaffold's own stdout.
41///
42/// Non-zero exits are logged as errors but do not abort the loop — all
43/// extensions are given a chance to run regardless of whether an earlier one
44/// failed. The function itself is infallible from the caller's perspective.
45pub async fn run_hook<C: serde::Serialize>(
46    extensions: &[ResolvedExtension],
47    hook: HookName,
48    context: &C,
49    printer: &Print,
50) {
51    let hook_str = hook.as_str();
52
53    // Serialize context to a Value once; per-extension config is injected
54    // into the object before writing to each extension's stdin.
55    let ctx_value = match serde_json::to_value(context) {
56        Ok(v) => v,
57        Err(e) => {
58            printer.errorln(format!(
59                "Extension hook {hook_str:?}: failed to serialize context: {e}"
60            ));
61            return;
62        }
63    };
64
65    for ext in extensions {
66        if !ext.manifest.hooks.iter().any(|h| h == hook_str) {
67            continue;
68        }
69
70        let input_json = match inject_config(&ctx_value, ext.config.as_ref()) {
71            Ok(bytes) => bytes,
72            Err(e) => {
73                printer.errorln(format!(
74                    "Extension {:?} hook {hook_str:?}: failed to serialize input: {e}",
75                    ext.name
76                ));
77                continue;
78            }
79        };
80
81        let binary_name = binary_name(&ext.name);
82
83        let mut child = match tokio::process::Command::new(&ext.binary)
84            .arg(hook_str)
85            .stdin(std::process::Stdio::piped())
86            .stdout(std::process::Stdio::piped())
87            .stderr(std::process::Stdio::piped())
88            .spawn()
89        {
90            Ok(child) => child,
91            Err(e) => {
92                printer.errorln(format!(
93                    "Extension {:?} hook {hook_str:?}: failed to spawn \
94                     `{binary_name}`: {e}",
95                    ext.name
96                ));
97                continue;
98            }
99        };
100
101        // Write input JSON then shut down stdin so the child sees EOF.
102        // Dropping without shutdown() could leave the pipe open on some
103        // platforms, causing the child to block waiting for more input.
104        if let Some(mut stdin) = child.stdin.take() {
105            if let Err(e) = stdin.write_all(&input_json).await {
106                printer.errorln(format!(
107                    "Extension {:?} hook {hook_str:?}: failed to write context \
108                     to stdin: {e}",
109                    ext.name
110                ));
111                let _ = child.kill().await;
112                continue;
113            }
114            let _ = stdin.shutdown().await;
115        }
116
117        let output = match child.wait_with_output().await {
118            Ok(output) => output,
119            Err(e) => {
120                printer.errorln(format!(
121                    "Extension {:?} hook {hook_str:?}: failed to wait for \
122                     `{binary_name}`: {e}",
123                    ext.name
124                ));
125                continue;
126            }
127        };
128
129        // Forward the extension's stdout verbatim to Scaffold's stdout so
130        // extensions can emit progress, JSON payloads, or human-readable
131        // output without any added formatting.
132        if !output.stdout.is_empty() {
133            let _ = std::io::stdout().write_all(&output.stdout);
134        }
135
136        if !output.status.success() {
137            let stderr = String::from_utf8_lossy(&output.stderr);
138            printer.errorln(format!(
139                "Extension {:?} hook {hook_str:?}: `{binary_name}` exited \
140                 with {}: {stderr}",
141                ext.name, output.status
142            ));
143            // Continue — give remaining extensions a chance to run.
144        }
145    }
146}
147
148/// Injects `config` into a serialized context value and returns the combined
149/// JSON bytes written to an extension's stdin.
150///
151/// `ctx` must be a JSON object (the output of serializing any context type).
152/// The `config` key is set to the extension's config value, or `null` if the
153/// extension has no config entry in `environments.toml`.
154fn inject_config(
155    ctx: &serde_json::Value,
156    config: Option<&serde_json::Value>,
157) -> Result<Vec<u8>, serde_json::Error> {
158    let mut map = match ctx {
159        serde_json::Value::Object(m) => m.clone(),
160        _ => serde_json::Map::new(),
161    };
162    map.insert(
163        "config".to_string(),
164        config.cloned().unwrap_or(serde_json::Value::Null),
165    );
166    serde_json::to_vec(&serde_json::Value::Object(map))
167}
168
169/// The resolved status of a single extension entry, used by `ext ls`.
170#[derive(Debug)]
171pub enum ExtensionListStatus {
172    /// Binary found and manifest parsed successfully.
173    Found { version: String, hooks: Vec<String> },
174    /// Binary `stellar-scaffold-<name>` not found on PATH.
175    MissingBinary,
176    /// Binary found but `manifest` subcommand failed or returned malformed JSON.
177    ManifestError(String),
178}
179
180/// Per-entry result returned by [`list`].
181#[derive(Debug)]
182pub struct ExtensionListEntry {
183    pub name: String,
184    pub status: ExtensionListStatus,
185}
186
187/// Returns one [`ExtensionListEntry`] per entry in `entries`, including entries
188/// whose binary is missing or whose manifest is broken. Unlike [`discover`],
189/// this never skips entries — it is intended for display, not for hook dispatch.
190pub fn list(entries: &[ExtensionEntry]) -> Vec<ExtensionListEntry> {
191    list_in(entries, &path_dirs())
192}
193
194fn list_in(entries: &[ExtensionEntry], search_dirs: &[PathBuf]) -> Vec<ExtensionListEntry> {
195    entries
196        .iter()
197        .map(|entry| {
198            let name = &entry.name;
199            let Some(binary) = find_binary(name, search_dirs) else {
200                return ExtensionListEntry {
201                    name: name.clone(),
202                    status: ExtensionListStatus::MissingBinary,
203                };
204            };
205
206            let output = match std::process::Command::new(&binary).arg("manifest").output() {
207                Err(e) => {
208                    return ExtensionListEntry {
209                        name: name.clone(),
210                        status: ExtensionListStatus::ManifestError(e.to_string()),
211                    };
212                }
213                Ok(o) => o,
214            };
215
216            if !output.status.success() {
217                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
218                return ExtensionListEntry {
219                    name: name.clone(),
220                    status: ExtensionListStatus::ManifestError(stderr),
221                };
222            }
223
224            match serde_json::from_slice::<ExtensionManifest>(&output.stdout) {
225                Err(e) => ExtensionListEntry {
226                    name: name.clone(),
227                    status: ExtensionListStatus::ManifestError(e.to_string()),
228                },
229                Ok(manifest) => ExtensionListEntry {
230                    name: name.clone(),
231                    status: ExtensionListStatus::Found {
232                        version: manifest.version,
233                        hooks: manifest.hooks,
234                    },
235                },
236            }
237        })
238        .collect()
239}
240
241fn path_dirs() -> Vec<PathBuf> {
242    std::env::var_os("PATH")
243        .map(|p| std::env::split_paths(&p).collect())
244        .unwrap_or_default()
245}
246
247fn find_binary(name: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
248    let binary_name = binary_name(name);
249    search_dirs
250        .iter()
251        .map(|dir| dir.join(&binary_name))
252        .find(|p| p.is_file())
253}
254
255#[cfg(windows)]
256fn binary_name(name: &str) -> String {
257    format!("stellar-scaffold-{name}.exe")
258}
259
260#[cfg(not(windows))]
261fn binary_name(name: &str) -> String {
262    format!("stellar-scaffold-{name}")
263}
264
265fn discover_in(
266    entries: &[ExtensionEntry],
267    printer: &Print,
268    search_dirs: &[PathBuf],
269) -> Vec<ResolvedExtension> {
270    let mut resolved = Vec::new();
271
272    for entry in entries {
273        let name = &entry.name;
274        let binary_name = binary_name(name);
275
276        let Some(binary) = find_binary(name, search_dirs) else {
277            printer.warnln(format!(
278                "Extension {name:?}: binary {binary_name:?} not found on PATH, skipping"
279            ));
280            continue;
281        };
282
283        let output = match std::process::Command::new(&binary).arg("manifest").output() {
284            Ok(output) => output,
285            Err(e) => {
286                printer.warnln(format!(
287                    "Extension {name:?}: failed to run `{binary_name} manifest`: {e}, skipping"
288                ));
289                continue;
290            }
291        };
292
293        if !output.status.success() {
294            let stderr = String::from_utf8_lossy(&output.stderr);
295            printer.warnln(format!(
296                "Extension {name:?}: `{binary_name} manifest` exited with {}: {stderr}skipping",
297                output.status
298            ));
299            continue;
300        }
301
302        let manifest: ExtensionManifest = match serde_json::from_slice(&output.stdout) {
303            Ok(m) => m,
304            Err(e) => {
305                printer.warnln(format!(
306                    "Extension {name:?}: malformed manifest from `{binary_name} manifest`: \
307                     {e}, skipping"
308                ));
309                continue;
310            }
311        };
312
313        resolved.push(ResolvedExtension {
314            name: name.clone(),
315            binary,
316            manifest,
317            config: entry.config.clone(),
318        });
319    }
320
321    if !resolved.is_empty() {
322        let names: Vec<&str> = resolved.iter().map(|e| e.name.as_str()).collect();
323        printer.infoln(format!("Registered extensions: {}", names.join(", ")));
324    }
325
326    resolved
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use stellar_scaffold_ext_types::HookName;
333
334    fn printer() -> Print {
335        Print::new(true) // quiet — we assert on return values, not output
336    }
337
338    fn entry(name: &str) -> ExtensionEntry {
339        ExtensionEntry {
340            name: name.to_owned(),
341            config: None,
342        }
343    }
344
345    fn entry_with_config(name: &str, config: serde_json::Value) -> ExtensionEntry {
346        ExtensionEntry {
347            name: name.to_owned(),
348            config: Some(config),
349        }
350    }
351
352    /// Write a shell script to `dir/<binary_name>` and make it executable.
353    #[cfg(unix)]
354    fn make_script(dir: &tempfile::TempDir, name: &str, body: &str) -> PathBuf {
355        use std::os::unix::fs::PermissionsExt;
356        let path = dir.path().join(binary_name(name));
357        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
358        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
359        path
360    }
361
362    /// Script that echoes a valid manifest JSON and exits 0.
363    #[cfg(unix)]
364    fn valid_manifest_script(dir: &tempfile::TempDir, name: &str, hooks: &[&str]) {
365        let hooks_json = hooks
366            .iter()
367            .map(|h| format!("\"{h}\""))
368            .collect::<Vec<_>>()
369            .join(",");
370        make_script(
371            dir,
372            name,
373            &format!(r#"echo '{{"name":"{name}","version":"1.0.0","hooks":[{hooks_json}]}}'"#),
374        );
375    }
376
377    #[test]
378    #[cfg(unix)]
379    fn discovers_valid_extension() {
380        let dir = tempfile::TempDir::new().unwrap();
381        valid_manifest_script(&dir, "reporter", &["post-compile", "post-deploy"]);
382
383        let entries = vec![entry("reporter")];
384        let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
385
386        assert_eq!(result.len(), 1);
387        assert_eq!(result[0].name, "reporter");
388        assert_eq!(result[0].manifest.name, "reporter");
389        assert_eq!(
390            result[0].manifest.hooks,
391            vec!["post-compile", "post-deploy"]
392        );
393        assert!(result[0].config.is_none());
394    }
395
396    #[test]
397    #[cfg(unix)]
398    fn passes_config_through_to_resolved() {
399        let dir = tempfile::TempDir::new().unwrap();
400        valid_manifest_script(&dir, "reporter", &["post-compile"]);
401
402        let config = serde_json::json!({ "warn_size_kb": 128 });
403        let entries = vec![entry_with_config("reporter", config.clone())];
404        let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
405
406        assert_eq!(result.len(), 1);
407        assert_eq!(result[0].config, Some(config));
408    }
409
410    #[test]
411    fn skips_missing_binary() {
412        let dir = tempfile::TempDir::new().unwrap();
413        // No binary written to dir.
414
415        let entries = vec![entry("missing")];
416        let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
417
418        assert!(result.is_empty());
419    }
420
421    #[test]
422    #[cfg(unix)]
423    fn skips_failing_manifest_subcommand() {
424        let dir = tempfile::TempDir::new().unwrap();
425        make_script(&dir, "bad-exit", "exit 1");
426
427        let entries = vec![entry("bad-exit")];
428        let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
429
430        assert!(result.is_empty());
431    }
432
433    #[test]
434    #[cfg(unix)]
435    fn skips_malformed_manifest_json() {
436        let dir = tempfile::TempDir::new().unwrap();
437        make_script(&dir, "bad-json", "echo 'not valid json'");
438
439        let entries = vec![entry("bad-json")];
440        let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
441
442        assert!(result.is_empty());
443    }
444
445    #[test]
446    #[cfg(unix)]
447    fn preserves_order_and_skips_bad_entries() {
448        let dir = tempfile::TempDir::new().unwrap();
449        valid_manifest_script(&dir, "first", &["pre-compile"]);
450        // "missing" has no binary.
451        valid_manifest_script(&dir, "third", &["post-compile"]);
452
453        let entries = vec![entry("first"), entry("missing"), entry("third")];
454        let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
455
456        assert_eq!(result.len(), 2);
457        assert_eq!(result[0].name, "first");
458        assert_eq!(result[1].name, "third");
459    }
460
461    // -----------------------------------------------------------------------
462    // run_hook tests
463    // -----------------------------------------------------------------------
464
465    /// Build a `ResolvedExtension` directly, bypassing discovery.
466    #[cfg(unix)]
467    fn make_resolved(name: &str, binary: PathBuf, hooks: &[&str]) -> ResolvedExtension {
468        ResolvedExtension {
469            name: name.to_owned(),
470            binary,
471            manifest: ExtensionManifest {
472                name: name.to_owned(),
473                version: "1.0.0".to_owned(),
474                hooks: hooks.iter().map(|h| (*h).to_string()).collect(),
475            },
476            config: None,
477        }
478    }
479
480    #[tokio::test]
481    #[cfg(unix)]
482    async fn run_hook_sends_context_to_stdin() {
483        let dir = tempfile::TempDir::new().unwrap();
484        // Script writes whatever it receives on stdin into received.json
485        // next to the script itself.
486        make_script(&dir, "reporter", r#"cat > "$(dirname "$0")/received.json""#);
487
488        #[derive(serde::Serialize)]
489        #[allow(clippy::items_after_statements)]
490        struct Ctx {
491            env: String,
492        }
493        let exts = vec![make_resolved(
494            "reporter",
495            dir.path().join(binary_name("reporter")),
496            &["post-compile"],
497        )];
498        run_hook(
499            &exts,
500            HookName::PostCompile,
501            &Ctx {
502                env: "development".to_owned(),
503            },
504            &printer(),
505        )
506        .await;
507
508        let received = std::fs::read_to_string(dir.path().join("received.json")).unwrap();
509        let parsed: serde_json::Value = serde_json::from_str(&received).unwrap();
510        assert_eq!(parsed["env"], "development");
511    }
512
513    #[tokio::test]
514    #[cfg(unix)]
515    async fn run_hook_skips_extension_not_registered_for_hook() {
516        let dir = tempfile::TempDir::new().unwrap();
517        // Script creates a sentinel file when invoked.
518        make_script(&dir, "reporter", r#"touch "$(dirname "$0")/was_invoked""#);
519        let exts = vec![make_resolved(
520            "reporter",
521            dir.path().join(binary_name("reporter")),
522            &["post-compile"], // registered for post-compile, not post-deploy
523        )];
524        run_hook(
525            &exts,
526            HookName::PostDeploy,
527            &serde_json::json!({}),
528            &printer(),
529        )
530        .await;
531
532        assert!(!dir.path().join("was_invoked").exists());
533    }
534
535    #[tokio::test]
536    #[cfg(unix)]
537    async fn run_hook_continues_after_non_zero_exit() {
538        let dir = tempfile::TempDir::new().unwrap();
539        // First extension: exits 1, writes nothing.
540        make_script(&dir, "failing", "exit 1");
541        // Second extension: writes received context to a file.
542        make_script(
543            &dir,
544            "succeeding",
545            r#"cat > "$(dirname "$0")/received.json""#,
546        );
547
548        let exts = vec![
549            make_resolved(
550                "failing",
551                dir.path().join(binary_name("failing")),
552                &["post-compile"],
553            ),
554            make_resolved(
555                "succeeding",
556                dir.path().join(binary_name("succeeding")),
557                &["post-compile"],
558            ),
559        ];
560
561        run_hook(
562            &exts,
563            HookName::PostCompile,
564            &serde_json::json!({ "env": "test" }),
565            &printer(),
566        )
567        .await;
568
569        // The second extension ran despite the first one failing.
570        assert!(dir.path().join("received.json").exists());
571    }
572}