1mod builtins;
2mod config;
3mod cron;
4mod deps;
5mod inputs;
6mod inspect;
7mod packages;
8pub mod remote;
9mod runner;
10mod spec_load;
11mod yaml_closure;
12
13pub use config::{load_user_config, UserConfig};
14pub use runner::run_jan;
15pub use spec_load::HostPlatform;
16
17use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22use anyhow::{bail, Context, Result};
23use rusqlite::Connection;
24use serde::Deserialize;
25use serde::de::{self, Deserializer, Visitor};
26use std::fmt;
27
28#[derive(Debug, Deserialize)]
29pub struct RootSpec {
30 pub metadata: Option<Metadata>,
31 #[serde(default)]
32 pub commands: BTreeMap<String, CommandNode>,
33}
34
35#[derive(Debug, Deserialize)]
36pub struct Metadata {
37 pub name: Option<String>,
38 pub description: Option<String>,
39}
40
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
68pub struct EnvSpec {
69 pub public: BTreeMap<String, String>,
70 pub private: Vec<String>,
71 pub pass: BTreeMap<String, String>,
73}
74
75impl EnvSpec {
76 pub fn is_empty(&self) -> bool {
77 self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
78 }
79
80 pub fn restricts_child_env(&self) -> bool {
82 !self.is_empty()
83 }
84
85 pub fn merge_from(&mut self, other: EnvSpec) {
86 for (k, v) in other.public {
87 self.public.insert(k, v);
88 }
89 for name in other.private {
90 if !self.private.iter().any(|p| p == &name) {
91 self.private.push(name);
92 }
93 }
94 for (k, v) in other.pass {
95 self.pass.insert(k, v);
96 }
97 }
98
99 pub fn validate(&self, path: &str) -> Result<()> {
101 for name in &self.private {
102 if name.trim().is_empty() {
103 bail!("command '{path}': env.private entry must not be empty");
104 }
105 }
106 for (env_name, pass_id) in &self.pass {
107 if env_name.trim().is_empty() {
108 bail!("command '{path}': env.pass key must not be empty");
109 }
110 if pass_id.trim().is_empty() {
111 bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
112 }
113 if self.private.iter().any(|p| p == env_name) {
114 bail!(
115 "command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
116 );
117 }
118 }
119 Ok(())
120 }
121}
122
123impl<'de> Deserialize<'de> for EnvSpec {
124 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
125 where
126 D: Deserializer<'de>,
127 {
128 #[derive(Deserialize)]
129 struct Structured {
130 #[serde(default)]
131 public: BTreeMap<String, String>,
132 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
133 private: Vec<String>,
134 #[serde(default)]
135 pass: BTreeMap<String, String>,
136 }
137
138 #[derive(Deserialize)]
139 #[serde(untagged)]
140 enum EnvDe {
141 Flat(BTreeMap<String, String>),
142 Sections(Structured),
143 }
144
145 Ok(match EnvDe::deserialize(deserializer)? {
146 EnvDe::Flat(public) => Self {
147 public,
148 private: Vec::new(),
149 pass: BTreeMap::new(),
150 },
151 EnvDe::Sections(s) => Self {
152 public: s.public,
153 private: s.private,
154 pass: s.pass,
155 },
156 })
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum IncludeLinkKind {
163 Yaml,
164 Script,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct IncludeLink {
170 pub kind: IncludeLinkKind,
171 pub path: Option<String>,
173 pub url: Option<String>,
175 pub sha256: Option<String>,
177}
178
179#[derive(Debug, Deserialize, Default, Clone)]
180pub struct CommandNode {
181 #[serde(default)]
184 pub os: Vec<String>,
185 #[serde(default)]
186 pub about: String,
187 pub path: Option<String>,
189 #[serde(default)]
191 pub dependencies: Vec<String>,
192 #[serde(default)]
194 pub requires: Vec<String>,
195 #[serde(default)]
197 pub env: EnvSpec,
198 #[serde(default)]
200 pub inputs: BTreeMap<String, crate::inputs::InputDef>,
201 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
204 pub cron: Vec<String>,
205 #[serde(default)]
207 pub packages: PackagesSpec,
208 #[serde(default)]
209 pub commands: BTreeMap<String, CommandNode>,
210 pub exec: Option<ExecSpec>,
211 #[serde(skip)]
213 pub source: Option<IncludeLink>,
214}
215
216#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
221pub struct PackagesSpec {
222 #[serde(default)]
223 pub uv: Option<UvPackages>,
224 #[serde(default)]
226 pub pnpm: Option<serde_yaml::Value>,
227}
228
229impl PackagesSpec {
230 pub fn is_empty(&self) -> bool {
231 self.uv.is_none() && self.pnpm.is_none()
232 }
233
234 pub fn merge_from(&mut self, other: PackagesSpec) {
236 if other.uv.is_some() {
237 self.uv = other.uv;
238 }
239 if other.pnpm.is_some() {
240 self.pnpm = other.pnpm;
241 }
242 }
243
244 pub fn validate(&self, path: &str) -> Result<()> {
245 if self.pnpm.is_some() {
246 bail!("command '{path}': packages.pnpm is not implemented yet");
247 }
248 if let Some(uv) = &self.uv {
249 uv.validate(path)?;
250 }
251 Ok(())
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum UvPackages {
258 List(Vec<String>),
259 Project(String),
260 Requirements(String),
261}
262
263impl UvPackages {
264 pub fn validate(&self, path: &str) -> Result<()> {
265 match self {
266 Self::List(pkgs) => {
267 if pkgs.is_empty() {
268 bail!("command '{path}': packages.uv list must not be empty");
269 }
270 for p in pkgs {
271 if p.trim().is_empty() {
272 bail!("command '{path}': packages.uv entry must not be empty");
273 }
274 }
275 }
276 Self::Project(p) | Self::Requirements(p) => {
277 if p.trim().is_empty() {
278 bail!("command '{path}': packages.uv path must not be empty");
279 }
280 }
281 }
282 Ok(())
283 }
284}
285
286impl<'de> Deserialize<'de> for UvPackages {
287 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
288 where
289 D: Deserializer<'de>,
290 {
291 #[derive(Deserialize)]
292 #[serde(deny_unknown_fields)]
293 struct MapForm {
294 #[serde(default)]
295 project: Option<String>,
296 #[serde(default)]
297 requirements: Option<String>,
298 }
299
300 #[derive(Deserialize)]
301 #[serde(untagged)]
302 enum Helper {
303 List(Vec<String>),
304 Map(MapForm),
305 }
306
307 match Helper::deserialize(deserializer)? {
308 Helper::List(pkgs) => {
309 let pkgs: Vec<String> = pkgs
310 .into_iter()
311 .map(|s| s.trim().to_string())
312 .filter(|s| !s.is_empty())
313 .collect();
314 Ok(UvPackages::List(pkgs))
315 }
316 Helper::Map(m) => {
317 let project = m
318 .project
319 .map(|s| s.trim().to_string())
320 .filter(|s| !s.is_empty());
321 let requirements = m
322 .requirements
323 .map(|s| s.trim().to_string())
324 .filter(|s| !s.is_empty());
325 match (project, requirements) {
326 (Some(p), None) => Ok(UvPackages::Project(p)),
327 (None, Some(r)) => Ok(UvPackages::Requirements(r)),
328 (None, None) => Err(de::Error::custom(
329 "packages.uv map must set exactly one of `project` or `requirements`",
330 )),
331 (Some(_), Some(_)) => Err(de::Error::custom(
332 "packages.uv map must set exactly one of `project` or `requirements`, not both",
333 )),
334 }
335 }
336 }
337 }
338}
339
340pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
341where
342 D: Deserializer<'de>,
343{
344 struct StringOrSeq;
345
346 impl<'de> Visitor<'de> for StringOrSeq {
347 type Value = Vec<String>;
348
349 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
350 formatter.write_str("a string or a sequence of strings")
351 }
352
353 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
354 where
355 E: de::Error,
356 {
357 if value.trim().is_empty() {
358 Ok(Vec::new())
359 } else {
360 Ok(vec![value.to_string()])
361 }
362 }
363
364 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
365 where
366 E: de::Error,
367 {
368 self.visit_str(&value)
369 }
370
371 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
372 where
373 A: de::SeqAccess<'de>,
374 {
375 let mut out = Vec::new();
376 while let Some(s) = seq.next_element::<String>()? {
377 if !s.trim().is_empty() {
378 out.push(s);
379 }
380 }
381 Ok(out)
382 }
383
384 fn visit_none<E>(self) -> Result<Self::Value, E>
385 where
386 E: de::Error,
387 {
388 Ok(Vec::new())
389 }
390
391 fn visit_unit<E>(self) -> Result<Self::Value, E>
392 where
393 E: de::Error,
394 {
395 Ok(Vec::new())
396 }
397 }
398
399 deserializer.deserialize_any(StringOrSeq)
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct LocalInclude {
405 pub path: String,
406 pub sha256: Option<String>,
408 pub argv: Vec<String>,
410 pub passthrough: bool,
412}
413
414impl LocalInclude {
415 pub fn from_path(path: impl Into<String>) -> Self {
416 Self {
417 path: path.into(),
418 sha256: None,
419 argv: Vec::new(),
420 passthrough: false,
421 }
422 }
423
424 pub fn is_yaml(&self) -> bool {
425 let lower = self.path.to_ascii_lowercase();
426 lower.ends_with(".yaml") || lower.ends_with(".yml")
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
432pub enum IncludeRef {
433 Local(LocalInclude),
435 Remote(RemoteInclude),
437}
438
439#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
440pub struct RemoteInclude {
441 pub url: String,
442 pub sha256: String,
443 #[serde(default)]
444 pub ttl: Option<u64>,
445}
446
447impl IncludeRef {
448 pub fn is_remote(&self) -> bool {
449 matches!(self, Self::Remote(_))
450 }
451
452 pub fn local_path(&self) -> Option<&str> {
453 match self {
454 Self::Local(l) => Some(l.path.as_str()),
455 Self::Remote(_) => None,
456 }
457 }
458
459 pub fn cycle_token(&self) -> String {
460 match self {
461 Self::Local(l) => match &l.sha256 {
462 Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
463 None => l.path.clone(),
464 },
465 Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
466 }
467 }
468}
469
470impl<'de> Deserialize<'de> for IncludeRef {
471 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
472 where
473 D: Deserializer<'de>,
474 {
475 #[derive(Deserialize)]
476 #[serde(deny_unknown_fields)]
477 struct LocalMap {
478 path: String,
479 #[serde(default)]
480 sha256: Option<String>,
481 #[serde(default)]
482 argv: Vec<String>,
483 #[serde(default)]
484 passthrough: bool,
485 }
486
487 #[derive(Deserialize)]
488 #[serde(untagged)]
489 enum Helper {
490 Path(String),
491 Local(LocalMap),
492 Remote(RemoteInclude),
493 }
494
495 match Helper::deserialize(deserializer)? {
496 Helper::Path(path) => {
497 let path = path.trim();
498 if path.is_empty() {
499 return Err(de::Error::custom("include path must not be empty"));
500 }
501 Ok(IncludeRef::Local(LocalInclude::from_path(path)))
502 }
503 Helper::Local(m) => {
504 let path = m.path.trim();
505 if path.is_empty() {
506 return Err(de::Error::custom("include.path must not be empty"));
507 }
508 let sha256 = m
509 .sha256
510 .map(|s| s.trim().to_string())
511 .filter(|s| !s.is_empty());
512 Ok(IncludeRef::Local(LocalInclude {
513 path: path.to_string(),
514 sha256,
515 argv: m.argv,
516 passthrough: m.passthrough,
517 }))
518 }
519 Helper::Remote(r) => {
520 if r.url.trim().is_empty() {
521 return Err(de::Error::custom("include.url must not be empty"));
522 }
523 if r.sha256.trim().is_empty() {
524 return Err(de::Error::custom(
525 "include.sha256 is required with include.url",
526 ));
527 }
528 Ok(IncludeRef::Remote(r))
529 }
530 }
531 }
532}
533
534#[derive(Debug, Deserialize, Clone, Default)]
535pub struct ExecSpec {
536 #[serde(default)]
539 pub argv: Vec<String>,
540 #[serde(default)]
542 pub passthrough: bool,
543 #[serde(default)]
545 pub url: Option<String>,
546 #[serde(default)]
548 pub file: Option<String>,
549 #[serde(default)]
551 pub sha256: Option<String>,
552 #[serde(default)]
554 pub ttl: Option<u64>,
555}
556
557impl ExecSpec {
558 pub fn is_remote(&self) -> bool {
559 self.url
560 .as_deref()
561 .map(|u| !u.trim().is_empty())
562 .unwrap_or(false)
563 }
564
565 pub fn is_local_file(&self) -> bool {
566 self.file
567 .as_deref()
568 .map(|u| !u.trim().is_empty())
569 .unwrap_or(false)
570 }
571
572 pub fn validate(&self, path: &str) -> Result<()> {
573 let url = self
574 .url
575 .as_deref()
576 .map(str::trim)
577 .filter(|s| !s.is_empty());
578 let file = self
579 .file
580 .as_deref()
581 .map(str::trim)
582 .filter(|s| !s.is_empty());
583 let hash = self
584 .sha256
585 .as_deref()
586 .map(str::trim)
587 .filter(|s| !s.is_empty());
588 if url.is_some() && file.is_some() {
589 bail!("command '{path}': exec cannot set both `url` and `file`");
590 }
591 match (url, file, hash) {
592 (Some(_), None, Some(_)) => Ok(()),
593 (Some(_), None, None) => {
594 bail!("command '{path}': exec.sha256 is required with exec.url")
595 }
596 (None, Some(_), _) => Ok(()),
597 (None, None, Some(_)) => {
598 bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
599 }
600 (None, None, None) => {
601 if self.argv.is_empty() {
602 bail!(
603 "command '{path}': exec.argv must not be empty (or set exec.url / exec.file)"
604 );
605 }
606 Ok(())
607 }
608 (Some(_), Some(_), _) => unreachable!("checked above"),
609 }
610 }
611}
612
613impl CommandNode {
614 pub fn is_leaf_exec(&self) -> bool {
615 self.exec.is_some()
616 }
617
618 pub fn validate(&self, path: &str) -> Result<()> {
619 if self.exec.is_some() && !self.commands.is_empty() {
620 bail!("command '{path}' cannot define both `exec` and nested `commands`");
621 }
622 if let Some(ref e) = self.exec {
623 e.validate(path)?;
624 }
625 self.env.validate(path)?;
626 self.packages.validate(path)?;
627 for name in self.inputs.keys() {
628 inputs::InputDef::validate_name(name)
629 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
630 }
631 for (name, child) in &self.commands {
632 let p = if path.is_empty() {
633 name.clone()
634 } else {
635 format!("{path} {name}")
636 };
637 child.validate(&p)?;
638 }
639 Ok(())
640 }
641}
642
643pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
646 for (name, node) in overlay.commands {
647 match base.commands.get_mut(&name) {
648 Some(existing) => merge_command_node(existing, node)?,
649 None => {
650 base.commands.insert(name, node);
651 }
652 }
653 }
654 Ok(())
655}
656
657fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
658 if src.exec.is_some() && !src.commands.is_empty() {
659 bail!("merge overlay: command cannot define both `exec` and nested `commands`");
660 }
661 if !src.os.is_empty() {
662 dst.os = src.os;
663 }
664 if !src.about.trim().is_empty() {
665 dst.about = src.about;
666 }
667 if src.path.is_some() {
668 dst.path = src.path;
669 }
670 if !src.dependencies.is_empty() {
671 dst.dependencies = src.dependencies;
672 }
673 if !src.requires.is_empty() {
674 dst.requires = src.requires;
675 }
676 if !src.cron.is_empty() {
677 dst.cron = src.cron;
678 }
679 if !src.env.is_empty() {
680 dst.env.merge_from(src.env);
681 }
682 for (k, v) in src.inputs {
683 dst.inputs.insert(k, v);
684 }
685 if let Some(exec) = src.exec {
686 dst.exec = Some(exec);
687 dst.commands.clear();
688 return Ok(());
689 }
690 if !src.commands.is_empty() {
691 dst.exec = None;
692 for (k, child) in src.commands {
693 match dst.commands.get_mut(&k) {
694 Some(existing) => merge_command_node(existing, child)?,
695 None => {
696 dst.commands.insert(k, child);
697 }
698 }
699 }
700 }
701 Ok(())
702}
703
704pub fn validate_spec(spec: &RootSpec) -> Result<()> {
706 for (name, node) in &spec.commands {
707 node.validate(name)?;
708 }
709 Ok(())
710}
711
712pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
714 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
715}
716
717pub fn load_spec(path: &Path) -> Result<RootSpec> {
718 spec_load::load_spec_from_path(path, HostPlatform::detect())
719}
720
721pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
722 if let Some(b) = override_branch {
723 if !b.is_empty() {
724 return b.to_string();
725 }
726 }
727 if let Ok(v) = std::env::var("JAN_BRANCH") {
728 if !v.is_empty() {
729 return v;
730 }
731 }
732 let output = Command::new("git")
733 .args(["rev-parse", "--abbrev-ref", "HEAD"])
734 .current_dir(cwd)
735 .output();
736 match output {
737 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
738 _ => "(no-git)".to_string(),
739 }
740}
741
742fn first_line(s: &str) -> String {
743 s.lines().next().unwrap_or("").trim().to_string()
744}
745
746pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
747 let mut out = String::new();
748 let bin = spec
749 .metadata
750 .as_ref()
751 .and_then(|m| m.name.as_deref())
752 .unwrap_or("jan");
753 let full_cmd = if chain.is_empty() {
754 bin.to_string()
755 } else {
756 format!("{} {}", bin, chain.join(" "))
757 };
758
759 let (about, children, exec) = match node {
760 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
761 None => ("", &spec.commands, None),
762 };
763
764 if chain.is_empty() {
765 if let Some(meta) = &spec.metadata {
766 if let Some(desc) = &meta.description {
767 out.push_str(desc.trim());
768 out.push_str("\n\n");
769 }
770 }
771 }
772
773 if !about.is_empty() {
774 out.push_str(about.trim());
775 out.push_str("\n\n");
776 }
777
778 if exec.is_some() && children.is_empty() {
779 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
780 let defs = inputs::collect_chain_inputs(chain, spec);
781 if !defs.is_empty() {
782 out.push('\n');
783 out.push_str(&inputs::format_inputs_help(&defs));
784 }
785 return out;
786 }
787
788 if !children.is_empty() {
789 out.push_str("Subcommands:\n");
790 for (name, child) in children {
791 let line = if child.about.is_empty() {
792 format!(" {name}\n")
793 } else {
794 format!(" {name} — {}\n", first_line(&child.about))
795 };
796 out.push_str(&line);
797 }
798 out.push('\n');
799 out.push_str(&format!(
800 "Use `{} --help` for more about a subcommand.\n",
801 full_cmd
802 ));
803 let defs = inputs::collect_chain_inputs(chain, spec);
804 if !defs.is_empty() {
805 out.push('\n');
806 out.push_str(&inputs::format_inputs_help(&defs));
807 }
808 } else if exec.is_none() {
809 out.push_str("(No subcommands defined.)\n");
810 }
811 if chain.is_empty() && node.is_none() {
812 out.push_str(
813 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
814 );
815 }
816 out
817}
818
819#[derive(Debug, Clone)]
821pub struct SpecRootIdentity {
822 pub spec_dir: String,
824 pub root_yaml: String,
826}
827
828pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
830 let cfg = config::load_user_config().context("load user config")?;
831 let Some(dir_s) = cfg
832 .jan_dir
833 .as_ref()
834 .map(|s| s.trim())
835 .filter(|s| !s.is_empty())
836 else {
837 bail!(
838 "no preferred jan directory configured\n\
839 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
840 );
841 };
842 let dir = PathBuf::from(dir_s);
843 if !dir.is_dir() {
844 bail!(
845 "preferred jan directory does not exist: {}\n\
846 Fix the path or run `jan use <DIR>` again (config: {})",
847 dir.display(),
848 config::config_path().display()
849 );
850 }
851 let root = cfg
852 .spec_root
853 .as_deref()
854 .map(str::trim)
855 .filter(|s| !s.is_empty())
856 .unwrap_or("scripts.spec.yaml");
857 resolve_spec_dir_entry(&dir, root)
858}
859
860pub fn resolve_spec_dir_entry(
862 spec_dir: &Path,
863 root_yaml: &str,
864) -> Result<(PathBuf, SpecRootIdentity)> {
865 let rel = Path::new(root_yaml);
866 if rel.is_absolute() {
867 bail!("entry YAML must be a relative file name, not an absolute path");
868 }
869 if rel
870 .components()
871 .any(|c| matches!(c, std::path::Component::ParentDir))
872 {
873 bail!("entry YAML must not contain `..`");
874 }
875 let normal_only = rel
876 .components()
877 .all(|c| matches!(c, std::path::Component::Normal(_)));
878 let n = rel
879 .components()
880 .filter(|c| matches!(c, std::path::Component::Normal(_)))
881 .count();
882 if !normal_only || n != 1 {
883 bail!("entry YAML must be a single file name inside the jan directory");
884 }
885 let dir = spec_dir
886 .canonicalize()
887 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
888 if !dir.is_dir() {
889 bail!("not a directory: {}", dir.display());
890 }
891 let spec_path = dir.join(rel);
892 if !spec_path.is_file() {
893 bail!(
894 "spec entry not found: {} (under {})\n\
895 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
896 spec_path.display(),
897 dir.display()
898 );
899 }
900 let identity = SpecRootIdentity {
901 spec_dir: dir.to_string_lossy().into_owned(),
902 root_yaml: rel
903 .file_name()
904 .expect("relative root has file_name")
905 .to_string_lossy()
906 .into_owned(),
907 };
908 Ok((spec_path, identity))
909}
910
911pub struct RunContext<'a> {
912 pub cwd: &'a Path,
913 pub db_path: Option<&'a Path>,
914 pub branch: String,
915 pub no_log: bool,
916 pub spec_root: &'a SpecRootIdentity,
917}
918
919fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
926 if argv.len() != 3 {
927 return false;
928 }
929 let prog = Path::new(&argv[0])
930 .file_name()
931 .and_then(|s| s.to_str())
932 .unwrap_or(argv[0].as_str());
933 let is_shell = matches!(
934 prog,
935 "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
936 );
937 is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
938}
939
940fn shell_passthrough_argv0(chain: &[String]) -> String {
941 chain
942 .iter()
943 .rev()
944 .find(|s| s.as_str() != "run")
945 .cloned()
946 .or_else(|| chain.last().cloned())
947 .unwrap_or_else(|| "jan".to_string())
948}
949
950pub fn run_matched(
951 spec: &RootSpec,
952 chain: &[String],
953 node: &CommandNode,
954 trailing: &[OsString],
955 ctx: &RunContext<'_>,
956) -> Result<i32> {
957 let exec = match &node.exec {
958 Some(e) => e,
959 None => {
960 let help = format_help(spec, chain, Some(node));
961 print!("{help}");
962 bail!("missing subcommand");
963 }
964 };
965 exec.validate(&chain.join(" "))?;
966
967 let input_defs = inputs::collect_chain_inputs(chain, spec);
968 let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
969
970 let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
971 for a in &exec.argv {
972 argv.push(inputs::interpolate(a, &input_vals)?);
973 }
974
975 if exec.is_remote() {
976 let url = exec.url.as_deref().unwrap().trim();
977 let hash = exec.sha256.as_deref().unwrap().trim();
978 let mut opts = remote::FetchOpts::new();
979 if let Some(ttl) = exec.ttl {
980 opts = opts.with_ttl(ttl);
981 }
982 let cached = remote::fetch_verified(url, hash, &opts, true)?;
983 argv.push(cached.to_string_lossy().into_owned());
984 } else if exec.is_local_file() {
985 let rel = exec.file.as_deref().unwrap().trim();
986 let use_root = Path::new(&ctx.spec_root.spec_dir);
987 let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
988 if let Some(hash) = exec.sha256.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
989 remote::verify_file_sha256(&resolved, hash)
990 .with_context(|| format!("verify exec.file `{rel}`"))?;
991 }
992 argv.push(resolved.to_string_lossy().into_owned());
993 } else if argv.is_empty() {
994 bail!("exec.argv must not be empty");
995 }
996
997 if exec.passthrough {
998 let mut rest = rest;
999 if rest.first().is_some_and(|a| a == "--") {
1003 rest = rest[1..].to_vec();
1004 }
1005 if shell_inline_c_needs_argv0(&argv) {
1006 argv.push(shell_passthrough_argv0(chain));
1007 }
1008 for a in &rest {
1009 argv.push(a.to_string_lossy().into_owned());
1010 }
1011 } else if !rest.is_empty() {
1012 let preview = rest
1013 .iter()
1014 .take(3)
1015 .map(|s| s.to_string_lossy().into_owned())
1016 .collect::<Vec<_>>()
1017 .join(" ");
1018 bail!(
1019 "unexpected trailing arguments: {preview}{}",
1020 if rest.len() > 3 { "…" } else { "" }
1021 );
1022 }
1023
1024 let cmd_path = if chain.is_empty() {
1025 "(root)".to_string()
1026 } else {
1027 chain.join(" ")
1028 };
1029
1030 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
1031 deps::check_requires(&requires)?;
1032
1033 let pkgs = packages::collect_chain_packages(chain, spec);
1034 let uv_env = packages::ensure_packages(&pkgs, ctx)?;
1035
1036 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
1037 let program = packages::resolve_program_with_uv(&argv[0], uv_env.as_ref(), &path_dirs)?;
1038 let mut env_spec = deps::collect_chain_env(chain, spec);
1039 for value in env_spec.public.values_mut() {
1040 *value = inputs::interpolate(value, &input_vals)?;
1041 }
1042 deps::check_private_env(&env_spec.private)?;
1043 let mut path_override = if !path_dirs.is_empty() {
1044 Some(deps::prepend_path_env(&path_dirs)?)
1045 } else {
1046 None
1047 };
1048 if let Some(ref uv) = uv_env {
1049 path_override = Some(packages::prepend_uv_path(uv, path_override)?);
1050 }
1051
1052 let mut c = Command::new(&program);
1053 if argv.len() > 1 {
1054 c.args(&argv[1..]);
1055 }
1056 c.current_dir(ctx.cwd);
1057 deps::apply_process_env(&mut c, &env_spec, path_override)?;
1058
1059 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
1060 let code = status.code().unwrap_or(255);
1061
1062 if !ctx.no_log {
1063 if let Some(db) = ctx.db_path {
1064 log_invocation(
1065 db,
1066 &ctx.branch,
1067 ctx.cwd,
1068 &cmd_path,
1069 &argv,
1070 code,
1071 ctx.spec_root,
1072 )?;
1073 }
1074 }
1075
1076 Ok(code)
1077}
1078
1079fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
1080 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
1081 let cols: Vec<String> = stmt
1082 .query_map([], |row| row.get::<_, String>(1))?
1083 .collect::<std::result::Result<_, _>>()?;
1084 if !cols.iter().any(|c| c == "spec_root_id") {
1085 conn.execute(
1086 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
1087 [],
1088 )?;
1089 }
1090 Ok(())
1091}
1092
1093fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
1094 let ts = unix_ts();
1095 conn.execute(
1096 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
1097 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
1098 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
1099 )?;
1100 let id: i64 = conn.query_row(
1101 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
1102 [&spec.spec_dir, &spec.root_yaml],
1103 |r| r.get(0),
1104 )?;
1105 Ok(id)
1106}
1107
1108fn log_invocation(
1109 db_path: &Path,
1110 branch: &str,
1111 cwd: &Path,
1112 command_path: &str,
1113 argv: &[String],
1114 exit_code: i32,
1115 spec_root: &SpecRootIdentity,
1116) -> Result<()> {
1117 if let Some(parent) = db_path.parent() {
1118 std::fs::create_dir_all(parent).ok();
1119 }
1120 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
1121 conn.execute_batch(
1122 r"
1123 CREATE TABLE IF NOT EXISTS spec_roots (
1124 id INTEGER PRIMARY KEY AUTOINCREMENT,
1125 spec_dir TEXT NOT NULL,
1126 root_yaml TEXT NOT NULL,
1127 last_used_ts TEXT NOT NULL,
1128 UNIQUE(spec_dir, root_yaml)
1129 );
1130 CREATE TABLE IF NOT EXISTS invocations (
1131 id INTEGER PRIMARY KEY AUTOINCREMENT,
1132 ts TEXT NOT NULL,
1133 git_branch TEXT NOT NULL,
1134 cwd TEXT NOT NULL,
1135 command_path TEXT NOT NULL,
1136 argv_json TEXT NOT NULL,
1137 exit_code INTEGER NOT NULL,
1138 spec_root_id INTEGER
1139 );
1140 ",
1141 )?;
1142 ensure_invocations_spec_root_column(&conn)?;
1143 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
1144 let ts = unix_ts();
1145 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
1146 let cwd_s = cwd.to_string_lossy();
1147 conn.execute(
1148 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
1149 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1150 rusqlite::params![
1151 ts,
1152 branch,
1153 cwd_s.as_ref(),
1154 command_path,
1155 argv_json,
1156 exit_code,
1157 spec_root_id
1158 ],
1159 )?;
1160 Ok(())
1161}
1162
1163fn unix_ts() -> String {
1164 use std::time::SystemTime;
1165 SystemTime::now()
1166 .duration_since(std::time::UNIX_EPOCH)
1167 .unwrap_or_default()
1168 .as_secs()
1169 .to_string()
1170}
1171
1172#[derive(Debug)]
1173pub struct MatchOutcome<'a> {
1174 pub chain: Vec<String>,
1175 pub node: Option<&'a CommandNode>,
1176 pub trailing: Vec<OsString>,
1177 pub wants_help: bool,
1178}
1179
1180pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
1181 let mut chain = Vec::new();
1182 let mut node: Option<&'a CommandNode> = None;
1183 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
1184 let mut i = 0usize;
1185 let len = args.len();
1186 while i < len {
1187 let raw = &args[i];
1188 if raw == "--help" || raw == "-h" {
1189 return MatchOutcome {
1190 chain,
1191 node,
1192 trailing: args[i + 1..].to_vec(),
1193 wants_help: true,
1194 };
1195 }
1196 let key = raw.to_string_lossy();
1197 if let Some(next) = map.get(key.as_ref()) {
1198 chain.push(key.into_owned());
1199 node = Some(next);
1200 map = &next.commands;
1201 i += 1;
1202 continue;
1203 }
1204 break;
1205 }
1206 MatchOutcome {
1207 chain,
1208 node,
1209 trailing: args[i..].to_vec(),
1210 wants_help: false,
1211 }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217 use std::io::Write;
1218
1219 #[test]
1220 fn examples_default_spec_validates() {
1221 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
1222 load_spec(&path).unwrap();
1223 }
1224
1225 #[test]
1226 fn merge_specs_adds_and_replaces_leaves() {
1227 let mut base = load_spec_from_str(
1228 r"
1229commands:
1230 a:
1231 about: base
1232 commands:
1233 x:
1234 about: old
1235 exec:
1236 argv: [echo, old]
1237",
1238 None,
1239 )
1240 .unwrap();
1241 let overlay = load_spec_from_str(
1242 r"
1243commands:
1244 a:
1245 commands:
1246 x:
1247 about: new leaf
1248 exec:
1249 argv: [echo, new]
1250 b:
1251 about: added top
1252 exec:
1253 argv: [echo, b]
1254",
1255 None,
1256 )
1257 .unwrap();
1258 merge_specs_into(&mut base, overlay).unwrap();
1259 base.commands["a"].commands["x"].validate("a x").unwrap();
1260 assert_eq!(
1261 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
1262 vec!["echo", "new"]
1263 );
1264 assert_eq!(
1265 base.commands["b"].exec.as_ref().unwrap().argv,
1266 vec!["echo", "b"]
1267 );
1268 }
1269
1270 #[test]
1271 fn validate_rejects_exec_with_children() {
1272 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
1273 write!(
1274 tmp,
1275 r"
1276commands:
1277 x:
1278 exec:
1279 argv: [echo]
1280 commands:
1281 child:
1282 about: nested
1283"
1284 )
1285 .unwrap();
1286 let err = load_spec(tmp.path()).unwrap_err();
1287 assert!(err.to_string().contains("cannot define both"));
1288 }
1289
1290 #[test]
1291 fn shell_inline_c_needs_argv0_detects_bash_lc() {
1292 let argv = vec![
1293 "bash".into(),
1294 "-lc".into(),
1295 "case \"$1\" in create) ;; esac".into(),
1296 ];
1297 assert!(shell_inline_c_needs_argv0(&argv));
1298 let with_placeholder = vec![
1299 "zsh".into(),
1300 "-c".into(),
1301 "echo".into(),
1302 "issue".into(),
1303 ];
1304 assert!(!shell_inline_c_needs_argv0(&with_placeholder));
1305 assert!(!shell_inline_c_needs_argv0(&[
1306 "echo".into(),
1307 "start".into()
1308 ]));
1309 assert!(!shell_inline_c_needs_argv0(&[
1310 "python3".into(),
1311 "-c".into(),
1312 "print(1)".into()
1313 ]));
1314 }
1315
1316 #[test]
1317 fn shell_passthrough_argv0_skips_run_leaf() {
1318 assert_eq!(
1319 shell_passthrough_argv0(&["scripts".into(), "misc".into(), "issue".into(), "run".into()]),
1320 "issue"
1321 );
1322 assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
1323 }
1324}
1325
1326pub fn default_db_path() -> PathBuf {
1327 if let Ok(p) = std::env::var("JAN_DB") {
1328 return PathBuf::from(p);
1329 }
1330 dirs::data_local_dir()
1331 .unwrap_or_else(|| PathBuf::from("."))
1332 .join("jan-cli")
1333 .join("audit.db")
1334}