Skip to main content

command_stream/
command_packages.rs

1//! Typed command packages, middleware and reloadable exact-argv manifests.
2use crate::commands::{CommandContext, StreamChunk, VirtualCommandRegistry};
3use crate::{CommandBuilder, CommandResult, OutputChunk, StreamingRunner};
4use serde::Deserialize;
5use std::collections::HashMap;
6use std::future::Future;
7use std::path::{Path, PathBuf};
8use std::pin::Pin;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11
12mod marketplace;
13mod watch;
14pub use marketplace::{Marketplace, PackageMetadata, SearchOptions};
15pub use watch::{watch_package, PackageWatcher};
16
17pub type HandlerFuture = Pin<Box<dyn Future<Output = CommandResult> + Send>>;
18pub type PackageHandler = Arc<dyn Fn(CommandContext) -> HandlerFuture + Send + Sync>;
19
20/// Build a streaming, cancellable handler from literal executable arguments.
21pub fn argv_handler(file: impl Into<String>, args: Vec<String>) -> PackageHandler {
22    let file = file.into();
23    Arc::new(move |context| Box::pin(run_manifest(file.clone(), args.clone(), context)))
24}
25
26#[derive(Clone)]
27pub struct CommandPackage {
28    pub name: String,
29    pub version: String,
30    pub commands: HashMap<String, PackageHandler>,
31}
32
33#[derive(Deserialize)]
34struct Manifest {
35    name: String,
36    version: String,
37    commands: HashMap<String, Specification>,
38}
39#[derive(Deserialize)]
40struct Specification {
41    file: String,
42    #[serde(default)]
43    args: Vec<String>,
44}
45
46impl CommandPackage {
47    /// Loading a JSON manifest can be repeated after a file edit, without unsafe dynamic linking.
48    pub async fn load(path: impl AsRef<Path>) -> std::io::Result<Self> {
49        let manifest: Manifest = serde_json::from_str(&tokio::fs::read_to_string(path).await?)?;
50        let commands = manifest
51            .commands
52            .into_iter()
53            .map(|(name, specification)| {
54                let handler = argv_handler(specification.file, specification.args);
55                (name, handler)
56            })
57            .collect();
58        Ok(Self {
59            name: manifest.name,
60            version: manifest.version,
61            commands,
62        })
63    }
64}
65
66async fn run_manifest(
67    file: String,
68    mut args: Vec<String>,
69    context: CommandContext,
70) -> CommandResult {
71    if context.is_cancelled() {
72        return CommandResult::error_with_code("Command cancelled", 130);
73    }
74    let cancelled: Option<Arc<dyn Fn() -> bool + Send + Sync>> =
75        context.is_cancelled.map(Arc::from);
76    args.extend(context.args);
77    let mut runner =
78        StreamingRunner::from_argv(file, args).stdin(context.stdin.unwrap_or_default());
79    if let Some(cwd) = context.cwd {
80        runner = runner.cwd(cwd);
81    }
82    if let Some(env) = context.env {
83        runner = runner.env(env);
84    }
85    let mut stream = runner.stream();
86    let mut stdout = Vec::new();
87    let mut stderr = Vec::new();
88    let mut code = 0;
89    let mut stopped = false;
90    let mut stdout_decoder = Decoder::default();
91    let mut stderr_decoder = Decoder::default();
92    loop {
93        let chunk = tokio::select! {
94            chunk = stream.next() => chunk,
95            () = wait_cancel(&cancelled), if !stopped && cancelled.is_some() => {
96                stream.kill();
97                stopped = true;
98                continue;
99            }
100        };
101        let Some(chunk) = chunk else {
102            break;
103        };
104        let output = match chunk {
105            OutputChunk::Stdout(data) => {
106                if context.output_tx.is_none() {
107                    stdout.extend(&data);
108                }
109                let text = stdout_decoder.push(&data);
110                if text.is_empty() {
111                    None
112                } else {
113                    Some(StreamChunk::Stdout(text))
114                }
115            }
116            OutputChunk::Stderr(data) => {
117                if context.output_tx.is_none() {
118                    stderr.extend(&data);
119                }
120                let text = stderr_decoder.push(&data);
121                if text.is_empty() {
122                    None
123                } else {
124                    Some(StreamChunk::Stderr(text))
125                }
126            }
127            OutputChunk::Exit(status) => {
128                code = status;
129                None
130            }
131        };
132        if let (Some(tx), Some(chunk)) = (&context.output_tx, output) {
133            if send_chunk(tx, chunk, &cancelled).await.is_err() {
134                stream.kill();
135                return CommandResult::error_with_code(
136                    "Command cancelled or output consumer closed",
137                    130,
138                );
139            }
140        }
141    }
142    if let Some(tx) = &context.output_tx {
143        for chunk in [
144            StreamChunk::Stdout(stdout_decoder.finish()),
145            StreamChunk::Stderr(stderr_decoder.finish()),
146        ] {
147            let empty = match &chunk {
148                StreamChunk::Stdout(text) | StreamChunk::Stderr(text) => text.is_empty(),
149            };
150            if !empty && send_chunk(tx, chunk, &cancelled).await.is_err() {
151                return CommandResult::error_with_code(
152                    "Command cancelled or output consumer closed",
153                    130,
154                );
155            }
156        }
157    }
158    CommandResult::new(
159        String::from_utf8_lossy(&stdout).into_owned(),
160        String::from_utf8_lossy(&stderr).into_owned(),
161        code,
162    )
163}
164
165async fn wait_cancel(cancelled: &Option<Arc<dyn Fn() -> bool + Send + Sync>>) {
166    if let Some(cancelled) = cancelled {
167        loop {
168            if cancelled() {
169                return;
170            }
171            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
172        }
173    }
174    std::future::pending::<()>().await;
175}
176
177async fn send_chunk(
178    tx: &mpsc::Sender<StreamChunk>,
179    chunk: StreamChunk,
180    cancelled: &Option<Arc<dyn Fn() -> bool + Send + Sync>>,
181) -> Result<(), ()> {
182    tokio::select! {
183        result = tx.send(chunk) => result.map_err(|_| ()),
184        () = wait_cancel(cancelled) => Err(()),
185    }
186}
187
188/// Preserve incomplete UTF-8 code points between process byte chunks.
189#[derive(Default)]
190struct Decoder {
191    pending: Vec<u8>,
192}
193impl Decoder {
194    fn push(&mut self, data: &[u8]) -> String {
195        self.pending.extend(data);
196        let mut text = String::new();
197        let mut consumed = 0;
198        while consumed < self.pending.len() {
199            let remaining = &self.pending[consumed..];
200            match std::str::from_utf8(remaining) {
201                Ok(valid) => {
202                    text.push_str(valid);
203                    consumed = self.pending.len();
204                }
205                Err(error) => {
206                    let valid = error.valid_up_to();
207                    text.push_str(
208                        std::str::from_utf8(&remaining[..valid]).expect("valid UTF-8 prefix"),
209                    );
210                    consumed += valid;
211                    match error.error_len() {
212                        Some(length) => {
213                            text.push('\u{fffd}');
214                            consumed += length;
215                        }
216                        None => break,
217                    }
218                }
219            }
220        }
221        self.pending.drain(..consumed);
222        text
223    }
224    fn finish(&mut self) -> String {
225        let text = String::from_utf8_lossy(&self.pending).into_owned();
226        self.pending.clear();
227        text
228    }
229}
230
231#[derive(Default, Clone, Copy)]
232pub struct InstallOptions {
233    /// Allow displacement of an existing handler, restored on uninstall.
234    pub replace: bool,
235}
236
237struct Installed {
238    package: CommandPackage,
239    previous: HashMap<String, (PackageHandler, Option<String>)>,
240}
241
242#[derive(Default)]
243pub struct CommandEnvironment {
244    commands: HashMap<String, PackageHandler>,
245    owners: HashMap<String, String>,
246    packages: HashMap<String, Installed>,
247    files: HashMap<PathBuf, String>,
248}
249
250impl CommandEnvironment {
251    pub fn new() -> Self {
252        Self::default()
253    }
254    /// Register a typed handler after validating the complete command name.
255    /// Non-string names are rejected by the compiler before registry mutation.
256    ///
257    /// ```compile_fail
258    /// use command_stream::{CommandEnvironment, CommandResult};
259    /// let mut environment = CommandEnvironment::new();
260    /// environment.create(None, |_| async { CommandResult::success_empty() });
261    /// ```
262    ///
263    /// ```compile_fail
264    /// use command_stream::{CommandEnvironment, CommandResult};
265    /// let mut environment = CommandEnvironment::new();
266    /// environment.create(42, |_| async { CommandResult::success_empty() });
267    /// ```
268    pub fn create<F, Fut>(&mut self, name: &str, handler: F) -> Result<(), String>
269    where
270        F: Fn(CommandContext) -> Fut + Send + Sync + 'static,
271        Fut: Future<Output = CommandResult> + Send + 'static,
272    {
273        validate(name)?;
274        self.owners.remove(name);
275        self.commands.insert(
276            name.into(),
277            Arc::new(move |context| Box::pin(handler(context))),
278        );
279        Ok(())
280    }
281    pub fn list(&self) -> Vec<String> {
282        let mut names = self.commands.keys().cloned().collect::<Vec<_>>();
283        names.sort();
284        names
285    }
286    pub fn unregister(&mut self, name: &str) -> bool {
287        self.owners.remove(name);
288        self.commands.remove(name).is_some()
289    }
290    pub fn installed(&self, name: &str) -> Option<&CommandPackage> {
291        self.packages.get(name).map(|installed| &installed.package)
292    }
293    pub fn get(&self, name: &str) -> Option<PackageHandler> {
294        self.commands.get(name).cloned().or_else(|| {
295            let handler = *VirtualCommandRegistry::with_builtins().get(name)?;
296            Some(Arc::new(handler) as PackageHandler)
297        })
298    }
299    pub async fn run(&self, name: &str, context: CommandContext) -> CommandResult {
300        match self.get(name) {
301            Some(handler) => handler(context).await,
302            None => CommandResult::error_with_code(format!("Unknown command: {name}"), 127),
303        }
304    }
305    pub fn extend<F>(&mut self, name: &str, middleware: F) -> Result<(), String>
306    where
307        F: Fn(CommandContext, PackageHandler) -> HandlerFuture + Send + Sync + 'static,
308    {
309        let parent = self
310            .get(name)
311            .ok_or_else(|| format!("Unknown command: {name}"))?;
312        self.commands.insert(
313            name.into(),
314            Arc::new(move |context| middleware(context, parent.clone())),
315        );
316        Ok(())
317    }
318    pub fn inherit(&mut self, name: &str, parent: &str, args: Vec<String>) -> Result<(), String> {
319        let handler = self
320            .get(parent)
321            .ok_or_else(|| format!("Unknown command: {parent}"))?;
322        self.create(name, move |mut context| {
323            let mut prefix = args.clone();
324            prefix.extend(context.args);
325            context.args = prefix;
326            handler(context)
327        })
328    }
329    pub fn compose(&mut self, name: &str, names: &[&str]) -> Result<(), String> {
330        if names.is_empty() {
331            return Err("Composition requires commands".into());
332        }
333        let handlers = names
334            .iter()
335            .map(|name| {
336                self.get(name)
337                    .ok_or_else(|| format!("Unknown command: {name}"))
338            })
339            .collect::<Result<Vec<_>, _>>()?;
340        self.create(name, move |context| {
341            let handlers = handlers.clone();
342            Box::pin(async move {
343                let mut result = CommandResult::success(context.stdin.unwrap_or_default());
344                let cancelled: Option<Arc<dyn Fn() -> bool + Send + Sync>> =
345                    context.is_cancelled.map(Arc::from);
346                for handler in handlers {
347                    let (tx, mut rx) = mpsc::channel(16);
348                    let cancelled = cancelled.clone();
349                    let next = CommandContext {
350                        args: context.args.clone(),
351                        stdin: Some(result.stdout.to_string()),
352                        cwd: context.cwd.clone(),
353                        env: context.env.clone(),
354                        output_tx: Some(tx),
355                        is_cancelled: cancelled.map(|cancelled| {
356                            Box::new(move || cancelled()) as Box<dyn Fn() -> bool + Send + Sync>
357                        }),
358                    };
359                    // Drain streamed stages concurrently, without exposing intermediate
360                    // chunks or deadlocking a bounded producer. Composition captures.
361                    let collect = async {
362                        let mut stdout = String::new();
363                        let mut stderr = String::new();
364                        while let Some(chunk) = rx.recv().await {
365                            match chunk {
366                                StreamChunk::Stdout(chunk) => stdout.push_str(&chunk),
367                                StreamChunk::Stderr(chunk) => stderr.push_str(&chunk),
368                            }
369                        }
370                        (stdout, stderr)
371                    };
372                    let (mut next, (stdout, stderr)) = tokio::join!(handler(next), collect);
373                    if next.stdout.is_empty() {
374                        next.stdout = stdout.into();
375                    }
376                    if next.stderr.is_empty() {
377                        next.stderr = stderr.into();
378                    }
379                    result = next;
380                    if result.code != 0 {
381                        break;
382                    }
383                }
384                result
385            })
386        })
387    }
388    /// Validate all commands before replacing a package, including when hot reloading.
389    pub fn install(&mut self, package: CommandPackage) -> Result<(), String> {
390        self.install_with_options(package, InstallOptions::default())
391    }
392    pub fn install_with_options(
393        &mut self,
394        package: CommandPackage,
395        options: InstallOptions,
396    ) -> Result<(), String> {
397        if package.commands.is_empty() {
398            return Err("Empty command package".into());
399        }
400        for name in package.commands.keys() {
401            validate(name)?;
402            let owned = self
403                .packages
404                .get(&package.name)
405                .and_then(|installed| installed.package.commands.get(name))
406                .zip(self.commands.get(name))
407                .is_some_and(|(owned, current)| Arc::ptr_eq(owned, current));
408            if self.get(name).is_some() && !owned && !options.replace {
409                return Err(format!("Command already registered: {name}"));
410            }
411        }
412        self.remove_package(&package.name);
413        let mut previous = HashMap::new();
414        for (name, handler) in &package.commands {
415            if let Some(displaced) = self.commands.get(name) {
416                previous.insert(
417                    name.clone(),
418                    (displaced.clone(), self.owners.get(name).cloned()),
419                );
420            }
421            self.owners.insert(name.clone(), package.name.clone());
422            self.commands.insert(name.clone(), handler.clone());
423        }
424        crate::trace::trace_lazy("CommandPackages", || {
425            format!("Installed {}@{}", package.name, package.version)
426        });
427        self.packages
428            .insert(package.name.clone(), Installed { package, previous });
429        Ok(())
430    }
431    pub fn uninstall(&mut self, package: &str) {
432        self.remove_package(package);
433        self.files.retain(|_, name| name != package);
434    }
435    fn remove_package(&mut self, package: &str) {
436        if let Some(installed) = self.packages.remove(package) {
437            for (name, handler) in installed.package.commands {
438                if !self
439                    .commands
440                    .get(&name)
441                    .is_some_and(|current| Arc::ptr_eq(current, &handler))
442                {
443                    continue;
444                }
445                self.owners.remove(&name);
446                self.commands.remove(&name);
447                if let Some((previous, owner)) = installed.previous.get(&name) {
448                    self.commands.insert(name.clone(), previous.clone());
449                    if let Some(owner) = owner {
450                        self.owners.insert(name, owner.clone());
451                    }
452                }
453            }
454        }
455    }
456    /// Canonical file identity makes repeated installation idempotent, including symlinks.
457    pub async fn install_file(&mut self, path: impl AsRef<Path>) -> Result<String, String> {
458        let file = tokio::fs::canonicalize(path)
459            .await
460            .map_err(|error| error.to_string())?;
461        if let Some(name) = self.files.get(&file) {
462            return Ok(name.clone());
463        }
464        self.reload_file(file).await
465    }
466    /// Resolve an npm package containing `command-stream.json`. Rust executes
467    /// its literal argv commands without evaluating JavaScript. Missing packages
468    /// may be installed with npm lifecycle scripts disabled; `install = false`
469    /// requires an existing package. Reload and uninstall use the same lifecycle.
470    pub async fn install_npm(
471        &mut self,
472        specifier: &str,
473        cwd: impl AsRef<Path>,
474        install: bool,
475    ) -> Result<String, String> {
476        static NAME: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
477            regex::Regex::new(r"^(@[a-z0-9._-]+/)?[a-z0-9._-]+(?:@[^\s]+)?$").unwrap()
478        });
479        if !NAME.is_match(specifier) {
480            return Err("Expected an npm package name".into());
481        }
482        let name = match specifier.rfind('@') {
483            Some(index) if index > 0 => &specifier[..index],
484            _ => specifier,
485        };
486        if name
487            .split('/')
488            .any(|part| part == "." || part == ".." || part == "@..")
489        {
490            return Err("Invalid npm package name".into());
491        }
492        let cwd = cwd.as_ref();
493        let file = cwd
494            .join("node_modules")
495            .join(name)
496            .join("command-stream.json");
497        if !file.exists() && install {
498            let npm = if cfg!(windows) { "npm.cmd" } else { "npm" };
499            let result = CommandBuilder::new(npm)
500                .args([
501                    "install",
502                    "--ignore-scripts",
503                    "--no-audit",
504                    "--no-fund",
505                    "--",
506                    specifier,
507                ])
508                .cwd(cwd)
509                .run()
510                .await
511                .map_err(|error| error.to_string())?;
512            if result.code != 0 {
513                return Err(format!("Package installation failed: {}", result.stderr));
514            }
515        }
516        self.install_file(file).await
517    }
518    /// Invalid manifests or collisions leave the installed version intact.
519    pub async fn reload_file(&mut self, path: impl AsRef<Path>) -> Result<String, String> {
520        let file = tokio::fs::canonicalize(path)
521            .await
522            .map_err(|error| error.to_string())?;
523        let package = CommandPackage::load(&file)
524            .await
525            .map_err(|error| error.to_string())?;
526        if self
527            .files
528            .iter()
529            .any(|(other, name)| other != &file && name == &package.name)
530        {
531            return Err("Package already installed from another file".into());
532        }
533        if self
534            .files
535            .get(&file)
536            .is_some_and(|name| name != &package.name)
537        {
538            return Err("Reload cannot change a package's name".into());
539        }
540        let name = package.name.clone();
541        self.install(package)?;
542        self.files.insert(file, name.clone());
543        Ok(name)
544    }
545}
546
547fn validate(name: &str) -> Result<(), String> {
548    if name.is_empty()
549        || !name
550            .chars()
551            .next()
552            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
553        || !name
554            .chars()
555            .all(|c| c.is_ascii_alphanumeric() || "_:.-".contains(c))
556    {
557        return Err(format!("Invalid command: {name}"));
558    }
559    Ok(())
560}