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