seam-cli 0.5.38

CLI for the SeamJS compile-time rendering framework
/* src/cli/core/src/shell.rs */

// Shell command helpers shared across build and dev.

use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, bail};

use crate::ui;

/// Run a shell command, bail on failure (shows both stdout and stderr on error).
pub(crate) fn run_command(
	base_dir: &Path,
	command: &str,
	label: &str,
	env: &[(&str, &str)],
) -> Result<()> {
	let spinner = ui::spinner(command);
	let mut cmd = Command::new("sh");
	cmd.args(["-c", command]);
	cmd.current_dir(base_dir);
	for (k, v) in env {
		cmd.env(k, v);
	}
	let output = cmd.output().with_context(|| format!("failed to run {label}"))?;
	if !output.status.success() {
		spinner.finish_with("failed");
		let stdout = String::from_utf8_lossy(&output.stdout);
		let stderr = String::from_utf8_lossy(&output.stderr);
		let mut msg = format!("{label} exited with status {}", output.status);
		if !stderr.is_empty() {
			msg.push('\n');
			msg.push_str(&stderr);
		}
		if !stdout.is_empty() {
			msg.push('\n');
			msg.push_str(&stdout);
		}
		bail!("{msg}");
	}
	spinner.finish();
	Ok(())
}

/// Run the built-in Vite bundler via the packaged build script.
pub(crate) fn run_builtin_bundler(
	base_dir: &Path,
	entry: &str,
	out_dir: &str,
	env: &[(&str, &str)],
) -> Result<()> {
	let runtime = if which_exists("bun") { "bun" } else { "node" };
	let script = find_cli_script(base_dir, "build-frontend.mjs")?;
	let spinner = ui::spinner(&format!("{runtime} build-frontend.mjs {entry} {out_dir}"));
	let mut cmd = Command::new(runtime);
	cmd.args([script.to_str().expect("script path is valid UTF-8"), entry, out_dir]);
	cmd.current_dir(base_dir);
	for (k, v) in env {
		cmd.env(k, v);
	}
	let output = cmd.output().context("failed to run built-in bundler")?;
	if !output.status.success() {
		spinner.finish_with("failed");
		let stderr = String::from_utf8_lossy(&output.stderr);
		let stdout = String::from_utf8_lossy(&output.stdout);
		let mut msg = format!("built-in bundler exited with status {}", output.status);
		if !stderr.is_empty() {
			msg.push('\n');
			msg.push_str(&stderr);
		}
		if !stdout.is_empty() {
			msg.push('\n');
			msg.push_str(&stdout);
		}
		bail!("{msg}");
	}
	spinner.finish();
	Ok(())
}

/// Locate a script bundled with @canmi/seam-cli.
pub(crate) fn find_cli_script(base_dir: &Path, name: &str) -> Result<PathBuf> {
	let suffix = format!("@canmi/seam-cli/scripts/{name}");
	resolve_node_module(base_dir, &suffix)
		.ok_or_else(|| anyhow::anyhow!("{name} not found -- install @canmi/seam-cli"))
}

/// Resolve a path inside node_modules by walking up parent directories.
/// Mirrors Node.js module resolution: checks `<dir>/node_modules/<suffix>` at each level.
/// Also scans immediate subdirectories of `start` (bun workspace puts symlinks in member node_modules).
pub(crate) fn resolve_node_module(start: &Path, suffix: &str) -> Option<PathBuf> {
	// Walk up from start
	let mut dir = start.to_path_buf();
	loop {
		let candidate = dir.join("node_modules").join(suffix);
		if candidate.exists() {
			return Some(candidate);
		}
		if !dir.pop() {
			break;
		}
	}
	// Scan immediate subdirectories (bun workspace hoists into member node_modules)
	if let Ok(entries) = std::fs::read_dir(start) {
		for entry in entries.flatten() {
			if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
				let candidate = entry.path().join("node_modules").join(suffix);
				if candidate.exists() {
					return Some(candidate);
				}
			}
		}
	}
	None
}

/// Check if a command exists on PATH.
pub(crate) fn which_exists(cmd: &str) -> bool {
	Command::new("which")
		.arg(cmd)
		.stdout(std::process::Stdio::null())
		.stderr(std::process::Stdio::null())
		.status()
		.map(|s| s.success())
		.unwrap_or(false)
}