1mod builtins;
2mod config;
3mod cron;
4mod deps;
5mod inputs;
6mod inspect;
7mod runner;
8mod spec_load;
9mod yaml_closure;
10
11pub use config::{load_user_config, UserConfig};
12pub use runner::run_jan;
13pub use spec_load::HostPlatform;
14
15use std::collections::BTreeMap;
16use std::ffi::OsString;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use anyhow::{bail, Context, Result};
21use rusqlite::Connection;
22use serde::Deserialize;
23use serde::de::{self, Deserializer, Visitor};
24use std::fmt;
25
26#[derive(Debug, Deserialize)]
27pub struct RootSpec {
28 pub metadata: Option<Metadata>,
29 #[serde(default)]
30 pub commands: BTreeMap<String, CommandNode>,
31}
32
33#[derive(Debug, Deserialize)]
34pub struct Metadata {
35 pub name: Option<String>,
36 pub description: Option<String>,
37}
38
39#[derive(Debug, Default, Clone, PartialEq, Eq)]
62pub struct EnvSpec {
63 pub public: BTreeMap<String, String>,
64 pub private: Vec<String>,
65}
66
67impl EnvSpec {
68 pub fn is_empty(&self) -> bool {
69 self.public.is_empty() && self.private.is_empty()
70 }
71
72 pub fn restricts_child_env(&self) -> bool {
74 !self.is_empty()
75 }
76
77 pub fn merge_from(&mut self, other: EnvSpec) {
78 for (k, v) in other.public {
79 self.public.insert(k, v);
80 }
81 for name in other.private {
82 if !self.private.iter().any(|p| p == &name) {
83 self.private.push(name);
84 }
85 }
86 }
87}
88
89impl<'de> Deserialize<'de> for EnvSpec {
90 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
91 where
92 D: Deserializer<'de>,
93 {
94 #[derive(Deserialize)]
95 struct Structured {
96 #[serde(default)]
97 public: BTreeMap<String, String>,
98 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
99 private: Vec<String>,
100 }
101
102 #[derive(Deserialize)]
103 #[serde(untagged)]
104 enum EnvDe {
105 Flat(BTreeMap<String, String>),
106 Sections(Structured),
107 }
108
109 Ok(match EnvDe::deserialize(deserializer)? {
110 EnvDe::Flat(public) => Self {
111 public,
112 private: Vec::new(),
113 },
114 EnvDe::Sections(s) => Self {
115 public: s.public,
116 private: s.private,
117 },
118 })
119 }
120}
121
122#[derive(Debug, Deserialize, Default, Clone)]
123pub struct CommandNode {
124 #[serde(default)]
127 pub os: Vec<String>,
128 #[serde(default)]
129 pub about: String,
130 pub path: Option<String>,
132 #[serde(default)]
134 pub dependencies: Vec<String>,
135 #[serde(default)]
137 pub requires: Vec<String>,
138 #[serde(default)]
140 pub env: EnvSpec,
141 #[serde(default)]
143 pub inputs: BTreeMap<String, crate::inputs::InputDef>,
144 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
147 pub cron: Vec<String>,
148 #[serde(default)]
149 pub commands: BTreeMap<String, CommandNode>,
150 pub exec: Option<ExecSpec>,
151}
152
153pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
154where
155 D: Deserializer<'de>,
156{
157 struct StringOrSeq;
158
159 impl<'de> Visitor<'de> for StringOrSeq {
160 type Value = Vec<String>;
161
162 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
163 formatter.write_str("a string or a sequence of strings")
164 }
165
166 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
167 where
168 E: de::Error,
169 {
170 if value.trim().is_empty() {
171 Ok(Vec::new())
172 } else {
173 Ok(vec![value.to_string()])
174 }
175 }
176
177 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
178 where
179 E: de::Error,
180 {
181 self.visit_str(&value)
182 }
183
184 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
185 where
186 A: de::SeqAccess<'de>,
187 {
188 let mut out = Vec::new();
189 while let Some(s) = seq.next_element::<String>()? {
190 if !s.trim().is_empty() {
191 out.push(s);
192 }
193 }
194 Ok(out)
195 }
196
197 fn visit_none<E>(self) -> Result<Self::Value, E>
198 where
199 E: de::Error,
200 {
201 Ok(Vec::new())
202 }
203
204 fn visit_unit<E>(self) -> Result<Self::Value, E>
205 where
206 E: de::Error,
207 {
208 Ok(Vec::new())
209 }
210 }
211
212 deserializer.deserialize_any(StringOrSeq)
213}
214
215#[derive(Debug, Deserialize, Clone)]
216pub struct ExecSpec {
217 pub argv: Vec<String>,
219 #[serde(default)]
221 pub passthrough: bool,
222}
223
224impl CommandNode {
225 pub fn is_leaf_exec(&self) -> bool {
226 self.exec.is_some()
227 }
228
229 pub fn validate(&self, path: &str) -> Result<()> {
230 if self.exec.is_some() && !self.commands.is_empty() {
231 bail!("command '{path}' cannot define both `exec` and nested `commands`");
232 }
233 if let Some(ref e) = self.exec {
234 if e.argv.is_empty() {
235 bail!("command '{path}': exec.argv must not be empty");
236 }
237 }
238 for name in self.inputs.keys() {
239 inputs::InputDef::validate_name(name)
240 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
241 }
242 for (name, child) in &self.commands {
243 let p = if path.is_empty() {
244 name.clone()
245 } else {
246 format!("{path} {name}")
247 };
248 child.validate(&p)?;
249 }
250 Ok(())
251 }
252}
253
254pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
257 for (name, node) in overlay.commands {
258 match base.commands.get_mut(&name) {
259 Some(existing) => merge_command_node(existing, node)?,
260 None => {
261 base.commands.insert(name, node);
262 }
263 }
264 }
265 Ok(())
266}
267
268fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
269 if src.exec.is_some() && !src.commands.is_empty() {
270 bail!("merge overlay: command cannot define both `exec` and nested `commands`");
271 }
272 if !src.os.is_empty() {
273 dst.os = src.os;
274 }
275 if !src.about.trim().is_empty() {
276 dst.about = src.about;
277 }
278 if src.path.is_some() {
279 dst.path = src.path;
280 }
281 if !src.dependencies.is_empty() {
282 dst.dependencies = src.dependencies;
283 }
284 if !src.requires.is_empty() {
285 dst.requires = src.requires;
286 }
287 if !src.cron.is_empty() {
288 dst.cron = src.cron;
289 }
290 if !src.env.is_empty() {
291 dst.env.merge_from(src.env);
292 }
293 for (k, v) in src.inputs {
294 dst.inputs.insert(k, v);
295 }
296 if let Some(exec) = src.exec {
297 dst.exec = Some(exec);
298 dst.commands.clear();
299 return Ok(());
300 }
301 if !src.commands.is_empty() {
302 dst.exec = None;
303 for (k, child) in src.commands {
304 match dst.commands.get_mut(&k) {
305 Some(existing) => merge_command_node(existing, child)?,
306 None => {
307 dst.commands.insert(k, child);
308 }
309 }
310 }
311 }
312 Ok(())
313}
314
315pub fn validate_spec(spec: &RootSpec) -> Result<()> {
317 for (name, node) in &spec.commands {
318 node.validate(name)?;
319 }
320 Ok(())
321}
322
323pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
325 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
326}
327
328pub fn load_spec(path: &Path) -> Result<RootSpec> {
329 spec_load::load_spec_from_path(path, HostPlatform::detect())
330}
331
332pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
333 if let Some(b) = override_branch {
334 if !b.is_empty() {
335 return b.to_string();
336 }
337 }
338 if let Ok(v) = std::env::var("JAN_BRANCH") {
339 if !v.is_empty() {
340 return v;
341 }
342 }
343 let output = Command::new("git")
344 .args(["rev-parse", "--abbrev-ref", "HEAD"])
345 .current_dir(cwd)
346 .output();
347 match output {
348 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
349 _ => "(no-git)".to_string(),
350 }
351}
352
353fn first_line(s: &str) -> String {
354 s.lines().next().unwrap_or("").trim().to_string()
355}
356
357pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
358 let mut out = String::new();
359 let bin = spec
360 .metadata
361 .as_ref()
362 .and_then(|m| m.name.as_deref())
363 .unwrap_or("jan");
364 let full_cmd = if chain.is_empty() {
365 bin.to_string()
366 } else {
367 format!("{} {}", bin, chain.join(" "))
368 };
369
370 let (about, children, exec) = match node {
371 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
372 None => ("", &spec.commands, None),
373 };
374
375 if chain.is_empty() {
376 if let Some(meta) = &spec.metadata {
377 if let Some(desc) = &meta.description {
378 out.push_str(desc.trim());
379 out.push_str("\n\n");
380 }
381 }
382 }
383
384 if !about.is_empty() {
385 out.push_str(about.trim());
386 out.push_str("\n\n");
387 }
388
389 if exec.is_some() && children.is_empty() {
390 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
391 let defs = inputs::collect_chain_inputs(chain, spec);
392 if !defs.is_empty() {
393 out.push('\n');
394 out.push_str(&inputs::format_inputs_help(&defs));
395 }
396 return out;
397 }
398
399 if !children.is_empty() {
400 out.push_str("Subcommands:\n");
401 for (name, child) in children {
402 let line = if child.about.is_empty() {
403 format!(" {name}\n")
404 } else {
405 format!(" {name} — {}\n", first_line(&child.about))
406 };
407 out.push_str(&line);
408 }
409 out.push('\n');
410 out.push_str(&format!(
411 "Use `{} --help` for more about a subcommand.\n",
412 full_cmd
413 ));
414 let defs = inputs::collect_chain_inputs(chain, spec);
415 if !defs.is_empty() {
416 out.push('\n');
417 out.push_str(&inputs::format_inputs_help(&defs));
418 }
419 } else if exec.is_none() {
420 out.push_str("(No subcommands defined.)\n");
421 }
422 if chain.is_empty() && node.is_none() {
423 out.push_str(
424 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
425 );
426 }
427 out
428}
429
430#[derive(Debug, Clone)]
432pub struct SpecRootIdentity {
433 pub spec_dir: String,
435 pub root_yaml: String,
437}
438
439pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
441 let cfg = config::load_user_config().context("load user config")?;
442 let Some(dir_s) = cfg
443 .jan_dir
444 .as_ref()
445 .map(|s| s.trim())
446 .filter(|s| !s.is_empty())
447 else {
448 bail!(
449 "no preferred jan directory configured\n\
450 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
451 );
452 };
453 let dir = PathBuf::from(dir_s);
454 if !dir.is_dir() {
455 bail!(
456 "preferred jan directory does not exist: {}\n\
457 Fix the path or run `jan use <DIR>` again (config: {})",
458 dir.display(),
459 config::config_path().display()
460 );
461 }
462 let root = cfg
463 .spec_root
464 .as_deref()
465 .map(str::trim)
466 .filter(|s| !s.is_empty())
467 .unwrap_or("scripts.spec.yaml");
468 resolve_spec_dir_entry(&dir, root)
469}
470
471pub fn resolve_spec_dir_entry(
473 spec_dir: &Path,
474 root_yaml: &str,
475) -> Result<(PathBuf, SpecRootIdentity)> {
476 let rel = Path::new(root_yaml);
477 if rel.is_absolute() {
478 bail!("entry YAML must be a relative file name, not an absolute path");
479 }
480 if rel
481 .components()
482 .any(|c| matches!(c, std::path::Component::ParentDir))
483 {
484 bail!("entry YAML must not contain `..`");
485 }
486 let normal_only = rel
487 .components()
488 .all(|c| matches!(c, std::path::Component::Normal(_)));
489 let n = rel
490 .components()
491 .filter(|c| matches!(c, std::path::Component::Normal(_)))
492 .count();
493 if !normal_only || n != 1 {
494 bail!("entry YAML must be a single file name inside the jan directory");
495 }
496 let dir = spec_dir
497 .canonicalize()
498 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
499 if !dir.is_dir() {
500 bail!("not a directory: {}", dir.display());
501 }
502 let spec_path = dir.join(rel);
503 if !spec_path.is_file() {
504 bail!(
505 "spec entry not found: {} (under {})\n\
506 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
507 spec_path.display(),
508 dir.display()
509 );
510 }
511 let identity = SpecRootIdentity {
512 spec_dir: dir.to_string_lossy().into_owned(),
513 root_yaml: rel
514 .file_name()
515 .expect("relative root has file_name")
516 .to_string_lossy()
517 .into_owned(),
518 };
519 Ok((spec_path, identity))
520}
521
522pub struct RunContext<'a> {
523 pub cwd: &'a Path,
524 pub db_path: Option<&'a Path>,
525 pub branch: String,
526 pub no_log: bool,
527 pub spec_root: &'a SpecRootIdentity,
528}
529
530pub fn run_matched(
531 spec: &RootSpec,
532 chain: &[String],
533 node: &CommandNode,
534 trailing: &[OsString],
535 ctx: &RunContext<'_>,
536) -> Result<i32> {
537 let exec = match &node.exec {
538 Some(e) => e,
539 None => {
540 let help = format_help(spec, chain, Some(node));
541 print!("{help}");
542 bail!("missing subcommand");
543 }
544 };
545 if exec.argv.is_empty() {
546 bail!("exec.argv must not be empty");
547 }
548
549 let input_defs = inputs::collect_chain_inputs(chain, spec);
550 let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
551
552 let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len());
553 for a in &exec.argv {
554 argv.push(inputs::interpolate(a, &input_vals)?);
555 }
556 if exec.passthrough {
557 for a in &rest {
558 argv.push(a.to_string_lossy().into_owned());
559 }
560 } else if !rest.is_empty() {
561 let preview = rest
562 .iter()
563 .take(3)
564 .map(|s| s.to_string_lossy().into_owned())
565 .collect::<Vec<_>>()
566 .join(" ");
567 bail!(
568 "unexpected trailing arguments: {preview}{}",
569 if rest.len() > 3 { "…" } else { "" }
570 );
571 }
572
573 let cmd_path = if chain.is_empty() {
574 "(root)".to_string()
575 } else {
576 chain.join(" ")
577 };
578
579 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
580 deps::check_requires(&requires)?;
581
582 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
583 let program = deps::resolve_program(&argv[0], &path_dirs)?;
584 let mut env_spec = deps::collect_chain_env(chain, spec);
585 for value in env_spec.public.values_mut() {
586 *value = inputs::interpolate(value, &input_vals)?;
587 }
588 deps::check_private_env(&env_spec.private)?;
589 let path_override = if !path_dirs.is_empty() {
590 Some(deps::prepend_path_env(&path_dirs)?)
591 } else {
592 None
593 };
594
595 let mut c = Command::new(&program);
596 if argv.len() > 1 {
597 c.args(&argv[1..]);
598 }
599 c.current_dir(ctx.cwd);
600 deps::apply_process_env(&mut c, &env_spec, path_override)?;
601
602 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
603 let code = status.code().unwrap_or(255);
604
605 if !ctx.no_log {
606 if let Some(db) = ctx.db_path {
607 log_invocation(
608 db,
609 &ctx.branch,
610 ctx.cwd,
611 &cmd_path,
612 &argv,
613 code,
614 ctx.spec_root,
615 )?;
616 }
617 }
618
619 Ok(code)
620}
621
622fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
623 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
624 let cols: Vec<String> = stmt
625 .query_map([], |row| row.get::<_, String>(1))?
626 .collect::<std::result::Result<_, _>>()?;
627 if !cols.iter().any(|c| c == "spec_root_id") {
628 conn.execute(
629 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
630 [],
631 )?;
632 }
633 Ok(())
634}
635
636fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
637 let ts = unix_ts();
638 conn.execute(
639 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
640 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
641 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
642 )?;
643 let id: i64 = conn.query_row(
644 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
645 [&spec.spec_dir, &spec.root_yaml],
646 |r| r.get(0),
647 )?;
648 Ok(id)
649}
650
651fn log_invocation(
652 db_path: &Path,
653 branch: &str,
654 cwd: &Path,
655 command_path: &str,
656 argv: &[String],
657 exit_code: i32,
658 spec_root: &SpecRootIdentity,
659) -> Result<()> {
660 if let Some(parent) = db_path.parent() {
661 std::fs::create_dir_all(parent).ok();
662 }
663 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
664 conn.execute_batch(
665 r"
666 CREATE TABLE IF NOT EXISTS spec_roots (
667 id INTEGER PRIMARY KEY AUTOINCREMENT,
668 spec_dir TEXT NOT NULL,
669 root_yaml TEXT NOT NULL,
670 last_used_ts TEXT NOT NULL,
671 UNIQUE(spec_dir, root_yaml)
672 );
673 CREATE TABLE IF NOT EXISTS invocations (
674 id INTEGER PRIMARY KEY AUTOINCREMENT,
675 ts TEXT NOT NULL,
676 git_branch TEXT NOT NULL,
677 cwd TEXT NOT NULL,
678 command_path TEXT NOT NULL,
679 argv_json TEXT NOT NULL,
680 exit_code INTEGER NOT NULL,
681 spec_root_id INTEGER
682 );
683 ",
684 )?;
685 ensure_invocations_spec_root_column(&conn)?;
686 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
687 let ts = unix_ts();
688 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
689 let cwd_s = cwd.to_string_lossy();
690 conn.execute(
691 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
692 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
693 rusqlite::params![
694 ts,
695 branch,
696 cwd_s.as_ref(),
697 command_path,
698 argv_json,
699 exit_code,
700 spec_root_id
701 ],
702 )?;
703 Ok(())
704}
705
706fn unix_ts() -> String {
707 use std::time::SystemTime;
708 SystemTime::now()
709 .duration_since(std::time::UNIX_EPOCH)
710 .unwrap_or_default()
711 .as_secs()
712 .to_string()
713}
714
715#[derive(Debug)]
716pub struct MatchOutcome<'a> {
717 pub chain: Vec<String>,
718 pub node: Option<&'a CommandNode>,
719 pub trailing: Vec<OsString>,
720 pub wants_help: bool,
721}
722
723pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
724 let mut chain = Vec::new();
725 let mut node: Option<&'a CommandNode> = None;
726 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
727 let mut i = 0usize;
728 let len = args.len();
729 while i < len {
730 let raw = &args[i];
731 if raw == "--help" || raw == "-h" {
732 return MatchOutcome {
733 chain,
734 node,
735 trailing: args[i + 1..].to_vec(),
736 wants_help: true,
737 };
738 }
739 let key = raw.to_string_lossy();
740 if let Some(next) = map.get(key.as_ref()) {
741 chain.push(key.into_owned());
742 node = Some(next);
743 map = &next.commands;
744 i += 1;
745 continue;
746 }
747 break;
748 }
749 MatchOutcome {
750 chain,
751 node,
752 trailing: args[i..].to_vec(),
753 wants_help: false,
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use std::io::Write;
761
762 #[test]
763 fn examples_default_spec_validates() {
764 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
765 load_spec(&path).unwrap();
766 }
767
768 #[test]
769 fn merge_specs_adds_and_replaces_leaves() {
770 let mut base = load_spec_from_str(
771 r"
772commands:
773 a:
774 about: base
775 commands:
776 x:
777 about: old
778 exec:
779 argv: [echo, old]
780",
781 None,
782 )
783 .unwrap();
784 let overlay = load_spec_from_str(
785 r"
786commands:
787 a:
788 commands:
789 x:
790 about: new leaf
791 exec:
792 argv: [echo, new]
793 b:
794 about: added top
795 exec:
796 argv: [echo, b]
797",
798 None,
799 )
800 .unwrap();
801 merge_specs_into(&mut base, overlay).unwrap();
802 base.commands["a"].commands["x"].validate("a x").unwrap();
803 assert_eq!(
804 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
805 vec!["echo", "new"]
806 );
807 assert_eq!(
808 base.commands["b"].exec.as_ref().unwrap().argv,
809 vec!["echo", "b"]
810 );
811 }
812
813 #[test]
814 fn validate_rejects_exec_with_children() {
815 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
816 write!(
817 tmp,
818 r"
819commands:
820 x:
821 exec:
822 argv: [echo]
823 commands:
824 child:
825 about: nested
826"
827 )
828 .unwrap();
829 let err = load_spec(tmp.path()).unwrap_err();
830 assert!(err.to_string().contains("cannot define both"));
831 }
832}
833
834pub fn default_db_path() -> PathBuf {
835 if let Ok(p) = std::env::var("JAN_DB") {
836 return PathBuf::from(p);
837 }
838 dirs::data_local_dir()
839 .unwrap_or_else(|| PathBuf::from("."))
840 .join("jan-cli")
841 .join("audit.db")
842}