Skip to main content

jan_cli/
lib.rs

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::de::{self, Deserializer, Visitor};
25use serde::Deserialize;
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/// Child-process environment declaration for a command node.
42///
43/// Two YAML shapes are accepted:
44///
45/// ```yaml
46/// # Legacy / shorthand — all keys are public assignments
47/// env:
48///   FOO: bar
49///
50/// # Explicit sections
51/// env:
52///   public:
53///     FOO: bar
54///   private:
55///     - GH_TOKEN
56///   pass:
57///     GH_TOKEN: github/pat
58/// ```
59///
60/// `public` values are taken from the YAML. `private` names must already exist in
61/// jan's own environment; their values are copied into the child and never stored
62/// in the spec. `pass` maps an environment variable name to a `pass` store id;
63/// jan runs `pass <id>` and sets only the first line of stdout as that variable
64/// in the child. When any section is non-empty, the child runs with a cleared
65/// environment containing only those variables plus a small essential allowlist
66/// (PATH, HOME, …).
67#[derive(Debug, Default, Clone, PartialEq, Eq)]
68pub struct EnvSpec {
69    pub public: BTreeMap<String, String>,
70    pub private: Vec<String>,
71    /// Env var name → `pass` store id (e.g. `GH_TOKEN` → `github/pat`).
72    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    /// True when the child should not inherit the full parent environment.
81    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    /// Reject overlapping private/pass names and empty keys/ids.
100    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/// Whether an include link pointed at YAML (subtree graft) or a script file (exec leaf).
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum IncludeLinkKind {
163    Yaml,
164    Script,
165}
166
167/// Retained include identity after load (not authored directly in YAML).
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct IncludeLink {
170    pub kind: IncludeLinkKind,
171    /// Relative path under the jan use root (local includes).
172    pub path: Option<String>,
173    /// Remote URL when the include was fetched over HTTPS.
174    pub url: Option<String>,
175    /// Declared SHA256 when present (required for remote; optional for local).
176    pub sha256: Option<String>,
177}
178
179#[derive(Debug, Deserialize, Default, Clone)]
180pub struct CommandNode {
181    /// If non-empty, this command and its subtree are only offered on these
182    /// platforms (`linux`, `macos`, `windows`, …). `darwin` is accepted as an alias for `macos`.
183    #[serde(default)]
184    pub os: Vec<String>,
185    #[serde(default)]
186    pub about: String,
187    /// Directory prepended to PATH when this script (or a descendant leaf) runs.
188    pub path: Option<String>,
189    /// Other script names whose `path` directories are prepended before this one runs.
190    #[serde(default)]
191    pub dependencies: Vec<String>,
192    /// External binaries that must be on PATH (e.g. `fzf`, `jq`) before the leaf runs.
193    #[serde(default)]
194    pub requires: Vec<String>,
195    /// Public assignments and/or private names required from the host environment.
196    #[serde(default)]
197    pub env: EnvSpec,
198    /// Named CLI inputs (`--name value`) available as `${{ inputs.name }}` in env/argv.
199    #[serde(default)]
200    pub inputs: BTreeMap<String, crate::inputs::InputDef>,
201    /// Optional crontab schedule(s). When set, `jan cron` runs this script's `run`
202    /// leaf whenever the local time matches any expression.
203    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
204    pub cron: Vec<String>,
205    /// Package-manager dependencies (uv now; pnpm reserved).
206    #[serde(default)]
207    pub packages: PackagesSpec,
208    #[serde(default)]
209    pub commands: BTreeMap<String, CommandNode>,
210    pub exec: Option<ExecSpec>,
211    /// Include link this node was loaded from, if any (filled by the loader).
212    #[serde(skip)]
213    pub source: Option<IncludeLink>,
214}
215
216/// Package-manager deps for a command node (`packages:` in YAML).
217///
218/// Distinct from `dependencies:`, which names other jan scripts whose `path`
219/// directories are prepended to PATH.
220#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
221pub struct PackagesSpec {
222    #[serde(default)]
223    pub uv: Option<UvPackages>,
224    #[serde(default)]
225    pub pnpm: Option<PnpmPackages>,
226    #[serde(default)]
227    pub gradle: Option<GradlePackages>,
228}
229
230impl PackagesSpec {
231    pub fn is_empty(&self) -> bool {
232        self.uv.is_none() && self.pnpm.is_none() && self.gradle.is_none()
233    }
234
235    /// Deeper node wins per manager (no list merge).
236    pub fn merge_from(&mut self, other: PackagesSpec) {
237        if other.uv.is_some() {
238            self.uv = other.uv;
239        }
240        if other.pnpm.is_some() {
241            self.pnpm = other.pnpm;
242        }
243        if other.gradle.is_some() {
244            self.gradle = other.gradle;
245        }
246    }
247
248    pub fn validate(&self, path: &str) -> Result<()> {
249        if let Some(uv) = &self.uv {
250            uv.validate(path)?;
251        }
252        if let Some(pnpm) = &self.pnpm {
253            pnpm.validate(path)?;
254        }
255        if let Some(gradle) = &self.gradle {
256            gradle.validate(path)?;
257        }
258        Ok(())
259    }
260}
261
262/// uv dependency declaration: inline list, project dir, or requirements file.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum UvPackages {
265    List(Vec<String>),
266    Project(String),
267    Requirements(String),
268}
269
270impl UvPackages {
271    pub fn validate(&self, path: &str) -> Result<()> {
272        match self {
273            Self::List(pkgs) => {
274                if pkgs.is_empty() {
275                    bail!("command '{path}': packages.uv list must not be empty");
276                }
277                for p in pkgs {
278                    if p.trim().is_empty() {
279                        bail!("command '{path}': packages.uv entry must not be empty");
280                    }
281                    packages::check_pinned_requirement(p)
282                        .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
283                }
284            }
285            Self::Project(p) | Self::Requirements(p) => {
286                if p.trim().is_empty() {
287                    bail!("command '{path}': packages.uv path must not be empty");
288                }
289            }
290        }
291        Ok(())
292    }
293}
294
295impl<'de> Deserialize<'de> for UvPackages {
296    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297    where
298        D: Deserializer<'de>,
299    {
300        #[derive(Deserialize)]
301        #[serde(deny_unknown_fields)]
302        struct MapForm {
303            #[serde(default)]
304            project: Option<String>,
305            #[serde(default)]
306            requirements: Option<String>,
307        }
308
309        #[derive(Deserialize)]
310        #[serde(untagged)]
311        enum Helper {
312            List(Vec<String>),
313            Map(MapForm),
314        }
315
316        match Helper::deserialize(deserializer)? {
317            Helper::List(pkgs) => {
318                let pkgs: Vec<String> = pkgs
319                    .into_iter()
320                    .map(|s| s.trim().to_string())
321                    .filter(|s| !s.is_empty())
322                    .collect();
323                Ok(UvPackages::List(pkgs))
324            }
325            Helper::Map(m) => {
326                let project = m
327                    .project
328                    .map(|s| s.trim().to_string())
329                    .filter(|s| !s.is_empty());
330                let requirements = m
331                    .requirements
332                    .map(|s| s.trim().to_string())
333                    .filter(|s| !s.is_empty());
334                match (project, requirements) {
335                    (Some(p), None) => Ok(UvPackages::Project(p)),
336                    (None, Some(r)) => Ok(UvPackages::Requirements(r)),
337                    (None, None) => Err(de::Error::custom(
338                        "packages.uv map must set exactly one of `project` or `requirements`",
339                    )),
340                    (Some(_), Some(_)) => Err(de::Error::custom(
341                        "packages.uv map must set exactly one of `project` or `requirements`, not both",
342                    )),
343                }
344            }
345        }
346    }
347}
348
349/// pnpm dependency declaration: inline list or a project dir with a lockfile.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum PnpmPackages {
352    List(Vec<String>),
353    Project(String),
354}
355
356impl PnpmPackages {
357    pub fn validate(&self, path: &str) -> Result<()> {
358        match self {
359            Self::List(pkgs) => {
360                if pkgs.is_empty() {
361                    bail!("command '{path}': packages.pnpm list must not be empty");
362                }
363                for p in pkgs {
364                    if p.trim().is_empty() {
365                        bail!("command '{path}': packages.pnpm entry must not be empty");
366                    }
367                    packages::check_pinned_npm_spec(p)
368                        .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
369                }
370            }
371            Self::Project(p) => {
372                if p.trim().is_empty() {
373                    bail!("command '{path}': packages.pnpm path must not be empty");
374                }
375            }
376        }
377        Ok(())
378    }
379}
380
381impl<'de> Deserialize<'de> for PnpmPackages {
382    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
383    where
384        D: Deserializer<'de>,
385    {
386        #[derive(Deserialize)]
387        #[serde(deny_unknown_fields)]
388        struct MapForm {
389            #[serde(default)]
390            project: Option<String>,
391        }
392
393        #[derive(Deserialize)]
394        #[serde(untagged)]
395        enum Helper {
396            List(Vec<String>),
397            Map(MapForm),
398        }
399
400        match Helper::deserialize(deserializer)? {
401            Helper::List(pkgs) => {
402                let pkgs: Vec<String> = pkgs
403                    .into_iter()
404                    .map(|s| s.trim().to_string())
405                    .filter(|s| !s.is_empty())
406                    .collect();
407                Ok(PnpmPackages::List(pkgs))
408            }
409            Helper::Map(m) => {
410                let project = m
411                    .project
412                    .map(|s| s.trim().to_string())
413                    .filter(|s| !s.is_empty());
414                match project {
415                    Some(p) => Ok(PnpmPackages::Project(p)),
416                    None => Err(de::Error::custom(
417                        "packages.pnpm map must set `project` (a directory with package.json and pnpm-lock.yaml)",
418                    )),
419                }
420            }
421        }
422    }
423}
424
425/// Gradle dependency declaration: pinned Maven coordinates or a locked project.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum GradlePackages {
428    List(Vec<String>),
429    Project(String),
430}
431
432impl GradlePackages {
433    pub fn validate(&self, path: &str) -> Result<()> {
434        match self {
435            Self::List(pkgs) => {
436                if pkgs.is_empty() {
437                    bail!("command '{path}': packages.gradle list must not be empty");
438                }
439                for p in pkgs {
440                    if p.trim().is_empty() {
441                        bail!("command '{path}': packages.gradle entry must not be empty");
442                    }
443                    packages::check_pinned_maven_coord(p)
444                        .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
445                }
446            }
447            Self::Project(p) => {
448                if p.trim().is_empty() {
449                    bail!("command '{path}': packages.gradle path must not be empty");
450                }
451            }
452        }
453        Ok(())
454    }
455}
456
457impl<'de> Deserialize<'de> for GradlePackages {
458    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
459    where
460        D: Deserializer<'de>,
461    {
462        #[derive(Deserialize)]
463        #[serde(deny_unknown_fields)]
464        struct MapForm {
465            #[serde(default)]
466            project: Option<String>,
467        }
468
469        #[derive(Deserialize)]
470        #[serde(untagged)]
471        enum Helper {
472            List(Vec<String>),
473            Map(MapForm),
474        }
475
476        match Helper::deserialize(deserializer)? {
477            Helper::List(pkgs) => {
478                let pkgs: Vec<String> = pkgs
479                    .into_iter()
480                    .map(|s| s.trim().to_string())
481                    .filter(|s| !s.is_empty())
482                    .collect();
483                Ok(GradlePackages::List(pkgs))
484            }
485            Helper::Map(m) => {
486                let project = m
487                    .project
488                    .map(|s| s.trim().to_string())
489                    .filter(|s| !s.is_empty());
490                match project {
491                    Some(p) => Ok(GradlePackages::Project(p)),
492                    None => Err(de::Error::custom(
493                        "packages.gradle map must set `project` (a directory with a Gradle build and gradle.lockfile)",
494                    )),
495                }
496            }
497        }
498    }
499}
500
501pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
502where
503    D: Deserializer<'de>,
504{
505    struct StringOrSeq;
506
507    impl<'de> Visitor<'de> for StringOrSeq {
508        type Value = Vec<String>;
509
510        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
511            formatter.write_str("a string or a sequence of strings")
512        }
513
514        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
515        where
516            E: de::Error,
517        {
518            if value.trim().is_empty() {
519                Ok(Vec::new())
520            } else {
521                Ok(vec![value.to_string()])
522            }
523        }
524
525        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
526        where
527            E: de::Error,
528        {
529            self.visit_str(&value)
530        }
531
532        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
533        where
534            A: de::SeqAccess<'de>,
535        {
536            let mut out = Vec::new();
537            while let Some(s) = seq.next_element::<String>()? {
538                if !s.trim().is_empty() {
539                    out.push(s);
540                }
541            }
542            Ok(out)
543        }
544
545        fn visit_none<E>(self) -> Result<Self::Value, E>
546        where
547            E: de::Error,
548        {
549            Ok(Vec::new())
550        }
551
552        fn visit_unit<E>(self) -> Result<Self::Value, E>
553        where
554            E: de::Error,
555        {
556            Ok(Vec::new())
557        }
558    }
559
560    deserializer.deserialize_any(StringOrSeq)
561}
562
563/// Local include under the preferred jan directory (YAML subtree or script file).
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct LocalInclude {
566    pub path: String,
567    /// Optional integrity pin; verified when present.
568    pub sha256: Option<String>,
569    /// Interpreter prefix for script includes only (e.g. `["bash"]`).
570    pub argv: Vec<String>,
571    /// Passthrough trailing CLI args for script includes only.
572    pub passthrough: bool,
573}
574
575impl LocalInclude {
576    pub fn from_path(path: impl Into<String>) -> Self {
577        Self {
578            path: path.into(),
579            sha256: None,
580            argv: Vec::new(),
581            passthrough: false,
582        }
583    }
584
585    pub fn is_yaml(&self) -> bool {
586        let lower = self.path.to_ascii_lowercase();
587        lower.ends_with(".yaml") || lower.ends_with(".yml")
588    }
589}
590
591/// Local path or remote HTTPS include target.
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub enum IncludeRef {
594    /// Relative path under the preferred jan directory (optional sha256 / script opts).
595    Local(LocalInclude),
596    /// Remote YAML fetched with SHA256 verification.
597    Remote(RemoteInclude),
598}
599
600#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
601pub struct RemoteInclude {
602    pub url: String,
603    pub sha256: String,
604    #[serde(default)]
605    pub ttl: Option<u64>,
606}
607
608impl IncludeRef {
609    pub fn is_remote(&self) -> bool {
610        matches!(self, Self::Remote(_))
611    }
612
613    pub fn local_path(&self) -> Option<&str> {
614        match self {
615            Self::Local(l) => Some(l.path.as_str()),
616            Self::Remote(_) => None,
617        }
618    }
619
620    pub fn cycle_token(&self) -> String {
621        match self {
622            Self::Local(l) => match &l.sha256 {
623                Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
624                None => l.path.clone(),
625            },
626            Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
627        }
628    }
629}
630
631impl<'de> Deserialize<'de> for IncludeRef {
632    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
633    where
634        D: Deserializer<'de>,
635    {
636        #[derive(Deserialize)]
637        #[serde(deny_unknown_fields)]
638        struct LocalMap {
639            path: String,
640            #[serde(default)]
641            sha256: Option<String>,
642            #[serde(default)]
643            argv: Vec<String>,
644            #[serde(default)]
645            passthrough: bool,
646        }
647
648        #[derive(Deserialize)]
649        #[serde(untagged)]
650        enum Helper {
651            Path(String),
652            Local(LocalMap),
653            Remote(RemoteInclude),
654        }
655
656        match Helper::deserialize(deserializer)? {
657            Helper::Path(path) => {
658                let path = path.trim();
659                if path.is_empty() {
660                    return Err(de::Error::custom("include path must not be empty"));
661                }
662                Ok(IncludeRef::Local(LocalInclude::from_path(path)))
663            }
664            Helper::Local(m) => {
665                let path = m.path.trim();
666                if path.is_empty() {
667                    return Err(de::Error::custom("include.path must not be empty"));
668                }
669                let sha256 = m
670                    .sha256
671                    .map(|s| s.trim().to_string())
672                    .filter(|s| !s.is_empty());
673                Ok(IncludeRef::Local(LocalInclude {
674                    path: path.to_string(),
675                    sha256,
676                    argv: m.argv,
677                    passthrough: m.passthrough,
678                }))
679            }
680            Helper::Remote(r) => {
681                if r.url.trim().is_empty() {
682                    return Err(de::Error::custom("include.url must not be empty"));
683                }
684                if r.sha256.trim().is_empty() {
685                    return Err(de::Error::custom(
686                        "include.sha256 is required with include.url",
687                    ));
688                }
689                Ok(IncludeRef::Remote(r))
690            }
691        }
692    }
693}
694
695#[derive(Debug, Deserialize, Clone, Default)]
696pub struct ExecSpec {
697    /// Program argv. For remote `url` / local `file` leaves this is an optional
698    /// interpreter prefix (e.g. `["python3"]`); the script path is appended automatically.
699    #[serde(default)]
700    pub argv: Vec<String>,
701    /// Append extra CLI arguments after those from `argv` / the script path.
702    #[serde(default)]
703    pub passthrough: bool,
704    /// HTTPS URL of a remote script to download, verify, and run.
705    #[serde(default)]
706    pub url: Option<String>,
707    /// Local script path relative to the jan use root (optional `sha256` pin).
708    #[serde(default)]
709    pub file: Option<String>,
710    /// SHA256 of the script: required with `url`, optional with `file`.
711    #[serde(default)]
712    pub sha256: Option<String>,
713    /// Optional TTL override (seconds) for the remote script cache.
714    #[serde(default)]
715    pub ttl: Option<u64>,
716}
717
718impl ExecSpec {
719    pub fn is_remote(&self) -> bool {
720        self.url
721            .as_deref()
722            .map(|u| !u.trim().is_empty())
723            .unwrap_or(false)
724    }
725
726    pub fn is_local_file(&self) -> bool {
727        self.file
728            .as_deref()
729            .map(|u| !u.trim().is_empty())
730            .unwrap_or(false)
731    }
732
733    pub fn validate(&self, path: &str) -> Result<()> {
734        let url = self.url.as_deref().map(str::trim).filter(|s| !s.is_empty());
735        let file = self
736            .file
737            .as_deref()
738            .map(str::trim)
739            .filter(|s| !s.is_empty());
740        let hash = self
741            .sha256
742            .as_deref()
743            .map(str::trim)
744            .filter(|s| !s.is_empty());
745        if url.is_some() && file.is_some() {
746            bail!("command '{path}': exec cannot set both `url` and `file`");
747        }
748        match (url, file, hash) {
749            (Some(_), None, Some(_)) => Ok(()),
750            (Some(_), None, None) => {
751                bail!("command '{path}': exec.sha256 is required with exec.url")
752            }
753            (None, Some(_), _) => Ok(()),
754            (None, None, Some(_)) => {
755                bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
756            }
757            (None, None, None) => {
758                if self.argv.is_empty() {
759                    bail!(
760                        "command '{path}': exec.argv must not be empty (or set exec.url / exec.file)"
761                    );
762                }
763                Ok(())
764            }
765            (Some(_), Some(_), _) => unreachable!("checked above"),
766        }
767    }
768}
769
770impl CommandNode {
771    pub fn is_leaf_exec(&self) -> bool {
772        self.exec.is_some()
773    }
774
775    pub fn validate(&self, path: &str) -> Result<()> {
776        if self.exec.is_some() && !self.commands.is_empty() {
777            bail!("command '{path}' cannot define both `exec` and nested `commands`");
778        }
779        if let Some(ref e) = self.exec {
780            e.validate(path)?;
781        }
782        self.env.validate(path)?;
783        self.packages.validate(path)?;
784        for name in self.inputs.keys() {
785            inputs::InputDef::validate_name(name)
786                .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
787        }
788        for (name, child) in &self.commands {
789            let p = if path.is_empty() {
790                name.clone()
791            } else {
792                format!("{path} {name}")
793            };
794            child.validate(&p)?;
795        }
796        Ok(())
797    }
798}
799
800/// Deep-merge `overlay.commands` into `base`, letting included YAML fragments
801/// add or replace leaves and extend nested groups.
802pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
803    for (name, node) in overlay.commands {
804        match base.commands.get_mut(&name) {
805            Some(existing) => merge_command_node(existing, node)?,
806            None => {
807                base.commands.insert(name, node);
808            }
809        }
810    }
811    Ok(())
812}
813
814fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
815    if src.exec.is_some() && !src.commands.is_empty() {
816        bail!("merge overlay: command cannot define both `exec` and nested `commands`");
817    }
818    if !src.os.is_empty() {
819        dst.os = src.os;
820    }
821    if !src.about.trim().is_empty() {
822        dst.about = src.about;
823    }
824    if src.path.is_some() {
825        dst.path = src.path;
826    }
827    if !src.dependencies.is_empty() {
828        dst.dependencies = src.dependencies;
829    }
830    if !src.requires.is_empty() {
831        dst.requires = src.requires;
832    }
833    if !src.cron.is_empty() {
834        dst.cron = src.cron;
835    }
836    if !src.env.is_empty() {
837        dst.env.merge_from(src.env);
838    }
839    for (k, v) in src.inputs {
840        dst.inputs.insert(k, v);
841    }
842    if let Some(exec) = src.exec {
843        dst.exec = Some(exec);
844        dst.commands.clear();
845        return Ok(());
846    }
847    if !src.commands.is_empty() {
848        dst.exec = None;
849        for (k, child) in src.commands {
850            match dst.commands.get_mut(&k) {
851                Some(existing) => merge_command_node(existing, child)?,
852                None => {
853                    dst.commands.insert(k, child);
854                }
855            }
856        }
857    }
858    Ok(())
859}
860
861/// Validate every command in the tree (after merges or programmatic edits).
862pub fn validate_spec(spec: &RootSpec) -> Result<()> {
863    for (name, node) in &spec.commands {
864        node.validate(name)?;
865    }
866    Ok(())
867}
868
869/// Parse YAML from memory. Use `include_base` when the document uses `include:` (root or subtree).
870pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
871    spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
872}
873
874pub fn load_spec(path: &Path) -> Result<RootSpec> {
875    spec_load::load_spec_from_path(path, HostPlatform::detect())
876}
877
878pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
879    if let Some(b) = override_branch {
880        if !b.is_empty() {
881            return b.to_string();
882        }
883    }
884    if let Ok(v) = std::env::var("JAN_BRANCH") {
885        if !v.is_empty() {
886            return v;
887        }
888    }
889    let output = Command::new("git")
890        .args(["rev-parse", "--abbrev-ref", "HEAD"])
891        .current_dir(cwd)
892        .output();
893    match output {
894        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
895        _ => "(no-git)".to_string(),
896    }
897}
898
899fn first_line(s: &str) -> String {
900    s.lines().next().unwrap_or("").trim().to_string()
901}
902
903pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
904    let mut out = String::new();
905    let bin = spec
906        .metadata
907        .as_ref()
908        .and_then(|m| m.name.as_deref())
909        .unwrap_or("jan");
910    let full_cmd = if chain.is_empty() {
911        bin.to_string()
912    } else {
913        format!("{} {}", bin, chain.join(" "))
914    };
915
916    let (about, children, exec) = match node {
917        Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
918        None => ("", &spec.commands, None),
919    };
920
921    if chain.is_empty() {
922        if let Some(meta) = &spec.metadata {
923            if let Some(desc) = &meta.description {
924                out.push_str(desc.trim());
925                out.push_str("\n\n");
926            }
927        }
928    }
929
930    if !about.is_empty() {
931        out.push_str(about.trim());
932        out.push_str("\n\n");
933    }
934
935    if exec.is_some() && children.is_empty() {
936        out.push_str("This command runs an external program (see spec `exec.argv`).\n");
937        let defs = inputs::collect_chain_inputs(chain, spec);
938        if !defs.is_empty() {
939            out.push('\n');
940            out.push_str(&inputs::format_inputs_help(&defs));
941        }
942        return out;
943    }
944
945    if !children.is_empty() {
946        out.push_str("Subcommands:\n");
947        for (name, child) in children {
948            let line = if child.about.is_empty() {
949                format!("  {name}\n")
950            } else {
951                format!("  {name} — {}\n", first_line(&child.about))
952            };
953            out.push_str(&line);
954        }
955        out.push('\n');
956        out.push_str(&format!(
957            "Use `{} --help` for more about a subcommand.\n",
958            full_cmd
959        ));
960        let defs = inputs::collect_chain_inputs(chain, spec);
961        if !defs.is_empty() {
962            out.push('\n');
963            out.push_str(&inputs::format_inputs_help(&defs));
964        }
965    } else if exec.is_none() {
966        out.push_str("(No subcommands defined.)\n");
967    }
968    if chain.is_empty() && node.is_none() {
969        out.push_str(
970            "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
971        );
972    }
973    out
974}
975
976/// Stable identity for where a YAML spec tree is rooted (directory of linked fragments + entry file).
977#[derive(Debug, Clone)]
978pub struct SpecRootIdentity {
979    /// Canonical directory containing top-level YAML fragments.
980    pub spec_dir: String,
981    /// Entry YAML file name relative to `spec_dir`.
982    pub root_yaml: String,
983}
984
985/// Resolve the preferred jan directory saved by `jan use`.
986pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
987    let cfg = config::load_user_config().context("load user config")?;
988    let Some(dir_s) = cfg
989        .jan_dir
990        .as_ref()
991        .map(|s| s.trim())
992        .filter(|s| !s.is_empty())
993    else {
994        bail!(
995            "no preferred jan directory configured\n\
996             Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
997        );
998    };
999    let dir = PathBuf::from(dir_s);
1000    if !dir.is_dir() {
1001        bail!(
1002            "preferred jan directory does not exist: {}\n\
1003             Fix the path or run `jan use <DIR>` again (config: {})",
1004            dir.display(),
1005            config::config_path().display()
1006        );
1007    }
1008    let root = cfg
1009        .spec_root
1010        .as_deref()
1011        .map(str::trim)
1012        .filter(|s| !s.is_empty())
1013        .unwrap_or("scripts.spec.yaml");
1014    resolve_spec_dir_entry(&dir, root)
1015}
1016
1017/// Resolve a jan directory + entry file name into an absolute spec path and identity.
1018pub fn resolve_spec_dir_entry(
1019    spec_dir: &Path,
1020    root_yaml: &str,
1021) -> Result<(PathBuf, SpecRootIdentity)> {
1022    let rel = Path::new(root_yaml);
1023    if rel.is_absolute() {
1024        bail!("entry YAML must be a relative file name, not an absolute path");
1025    }
1026    if rel
1027        .components()
1028        .any(|c| matches!(c, std::path::Component::ParentDir))
1029    {
1030        bail!("entry YAML must not contain `..`");
1031    }
1032    let normal_only = rel
1033        .components()
1034        .all(|c| matches!(c, std::path::Component::Normal(_)));
1035    let n = rel
1036        .components()
1037        .filter(|c| matches!(c, std::path::Component::Normal(_)))
1038        .count();
1039    if !normal_only || n != 1 {
1040        bail!("entry YAML must be a single file name inside the jan directory");
1041    }
1042    let dir = spec_dir
1043        .canonicalize()
1044        .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
1045    if !dir.is_dir() {
1046        bail!("not a directory: {}", dir.display());
1047    }
1048    let spec_path = dir.join(rel);
1049    if !spec_path.is_file() {
1050        bail!(
1051            "spec entry not found: {} (under {})\n\
1052             Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
1053            spec_path.display(),
1054            dir.display()
1055        );
1056    }
1057    let identity = SpecRootIdentity {
1058        spec_dir: dir.to_string_lossy().into_owned(),
1059        root_yaml: rel
1060            .file_name()
1061            .expect("relative root has file_name")
1062            .to_string_lossy()
1063            .into_owned(),
1064    };
1065    Ok((spec_path, identity))
1066}
1067
1068pub struct RunContext<'a> {
1069    pub cwd: &'a Path,
1070    pub db_path: Option<&'a Path>,
1071    pub branch: String,
1072    pub no_log: bool,
1073    pub spec_root: &'a SpecRootIdentity,
1074}
1075
1076/// True when `argv` is a POSIX-shell inline (`bash`/`zsh`/`sh`/… + `-c`/`-lc` + body)
1077/// with no `$0` placeholder after the body yet.
1078///
1079/// For those interpreters the first word after the `-c` string becomes `$0`, not `$1`.
1080/// Inlined jan scripts expect normal script semantics (`$1` / `"$@"` = user args), so
1081/// passthrough must insert a `$0` before forwarding.
1082fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
1083    if argv.len() != 3 {
1084        return false;
1085    }
1086    let prog = Path::new(&argv[0])
1087        .file_name()
1088        .and_then(|s| s.to_str())
1089        .unwrap_or(argv[0].as_str());
1090    let is_shell = matches!(
1091        prog,
1092        "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
1093    );
1094    is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
1095}
1096
1097fn shell_passthrough_argv0(chain: &[String]) -> String {
1098    chain
1099        .iter()
1100        .rev()
1101        .find(|s| s.as_str() != "run")
1102        .cloned()
1103        .or_else(|| chain.last().cloned())
1104        .unwrap_or_else(|| "jan".to_string())
1105}
1106
1107pub fn run_matched(
1108    spec: &RootSpec,
1109    chain: &[String],
1110    node: &CommandNode,
1111    trailing: &[OsString],
1112    ctx: &RunContext<'_>,
1113) -> Result<i32> {
1114    let exec = match &node.exec {
1115        Some(e) => e,
1116        None => {
1117            let help = format_help(spec, chain, Some(node));
1118            print!("{help}");
1119            bail!("missing subcommand");
1120        }
1121    };
1122    exec.validate(&chain.join(" "))?;
1123
1124    let input_defs = inputs::collect_chain_inputs(chain, spec);
1125    let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
1126
1127    let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
1128    for a in &exec.argv {
1129        argv.push(inputs::interpolate(a, &input_vals)?);
1130    }
1131
1132    if exec.is_remote() {
1133        let url = exec.url.as_deref().unwrap().trim();
1134        let hash = exec.sha256.as_deref().unwrap().trim();
1135        let mut opts = remote::FetchOpts::new();
1136        if let Some(ttl) = exec.ttl {
1137            opts = opts.with_ttl(ttl);
1138        }
1139        let cached = remote::fetch_verified(url, hash, &opts, true)?;
1140        argv.push(cached.to_string_lossy().into_owned());
1141    } else if exec.is_local_file() {
1142        let rel = exec.file.as_deref().unwrap().trim();
1143        let use_root = Path::new(&ctx.spec_root.spec_dir);
1144        let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
1145        if let Some(hash) = exec
1146            .sha256
1147            .as_deref()
1148            .map(str::trim)
1149            .filter(|s| !s.is_empty())
1150        {
1151            remote::verify_file_sha256(&resolved, hash)
1152                .with_context(|| format!("verify exec.file `{rel}`"))?;
1153        }
1154        argv.push(resolved.to_string_lossy().into_owned());
1155    } else if argv.is_empty() {
1156        bail!("exec.argv must not be empty");
1157    }
1158
1159    if exec.passthrough {
1160        let mut rest = rest;
1161        // `--` after the leaf is the usual jan separator; drop one leading `--` so
1162        // `run -- arg` and `run arg` match for both inline shells and `exec.file` /
1163        // script includes. A literal first arg of `--` needs `run -- --`.
1164        if rest.first().is_some_and(|a| a == "--") {
1165            rest = rest[1..].to_vec();
1166        }
1167        if shell_inline_c_needs_argv0(&argv) {
1168            argv.push(shell_passthrough_argv0(chain));
1169        }
1170        for a in &rest {
1171            argv.push(a.to_string_lossy().into_owned());
1172        }
1173    } else if !rest.is_empty() {
1174        let preview = rest
1175            .iter()
1176            .take(3)
1177            .map(|s| s.to_string_lossy().into_owned())
1178            .collect::<Vec<_>>()
1179            .join(" ");
1180        bail!(
1181            "unexpected trailing arguments: {preview}{}",
1182            if rest.len() > 3 { "…" } else { "" }
1183        );
1184    }
1185
1186    let cmd_path = if chain.is_empty() {
1187        "(root)".to_string()
1188    } else {
1189        chain.join(" ")
1190    };
1191
1192    let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
1193    deps::check_requires(&requires)?;
1194
1195    let pkgs = packages::collect_chain_packages(chain, spec);
1196    let pkg_envs = packages::ensure_packages(&pkgs, ctx)?;
1197
1198    let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
1199    let program = packages::resolve_program_with_envs(&argv[0], &pkg_envs, &path_dirs)?;
1200    let mut env_spec = deps::collect_chain_env(chain, spec);
1201    for value in env_spec.public.values_mut() {
1202        *value = inputs::interpolate(value, &input_vals)?;
1203    }
1204    deps::check_private_env(&env_spec.private)?;
1205    let mut path_override = if !path_dirs.is_empty() {
1206        Some(deps::prepend_path_env(&path_dirs)?)
1207    } else {
1208        None
1209    };
1210    if !pkg_envs.is_empty() {
1211        path_override = Some(packages::prepend_env_paths(&pkg_envs, path_override)?);
1212    }
1213    if let Some(node_path) = packages::node_path_for(&pkg_envs) {
1214        env_spec
1215            .public
1216            .entry("NODE_PATH".to_string())
1217            .or_insert(node_path);
1218    }
1219    if let Some(classpath) = packages::classpath_for(&pkg_envs) {
1220        env_spec
1221            .public
1222            .entry("CLASSPATH".to_string())
1223            .or_insert(classpath);
1224    }
1225
1226    let mut c = Command::new(&program);
1227    if argv.len() > 1 {
1228        c.args(&argv[1..]);
1229    }
1230    c.current_dir(ctx.cwd);
1231    deps::apply_process_env(&mut c, &env_spec, path_override)?;
1232
1233    let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
1234    let code = status.code().unwrap_or(255);
1235
1236    if !ctx.no_log {
1237        if let Some(db) = ctx.db_path {
1238            log_invocation(
1239                db,
1240                &ctx.branch,
1241                ctx.cwd,
1242                &cmd_path,
1243                &argv,
1244                code,
1245                ctx.spec_root,
1246            )?;
1247        }
1248    }
1249
1250    Ok(code)
1251}
1252
1253fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
1254    let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
1255    let cols: Vec<String> = stmt
1256        .query_map([], |row| row.get::<_, String>(1))?
1257        .collect::<std::result::Result<_, _>>()?;
1258    if !cols.iter().any(|c| c == "spec_root_id") {
1259        conn.execute(
1260            "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
1261            [],
1262        )?;
1263    }
1264    Ok(())
1265}
1266
1267fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
1268    let ts = unix_ts();
1269    conn.execute(
1270        r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
1271          ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
1272        rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
1273    )?;
1274    let id: i64 = conn.query_row(
1275        "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
1276        [&spec.spec_dir, &spec.root_yaml],
1277        |r| r.get(0),
1278    )?;
1279    Ok(id)
1280}
1281
1282fn log_invocation(
1283    db_path: &Path,
1284    branch: &str,
1285    cwd: &Path,
1286    command_path: &str,
1287    argv: &[String],
1288    exit_code: i32,
1289    spec_root: &SpecRootIdentity,
1290) -> Result<()> {
1291    if let Some(parent) = db_path.parent() {
1292        std::fs::create_dir_all(parent).ok();
1293    }
1294    let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
1295    conn.execute_batch(
1296        r"
1297        CREATE TABLE IF NOT EXISTS spec_roots (
1298            id INTEGER PRIMARY KEY AUTOINCREMENT,
1299            spec_dir TEXT NOT NULL,
1300            root_yaml TEXT NOT NULL,
1301            last_used_ts TEXT NOT NULL,
1302            UNIQUE(spec_dir, root_yaml)
1303        );
1304        CREATE TABLE IF NOT EXISTS invocations (
1305            id INTEGER PRIMARY KEY AUTOINCREMENT,
1306            ts TEXT NOT NULL,
1307            git_branch TEXT NOT NULL,
1308            cwd TEXT NOT NULL,
1309            command_path TEXT NOT NULL,
1310            argv_json TEXT NOT NULL,
1311            exit_code INTEGER NOT NULL,
1312            spec_root_id INTEGER
1313        );
1314        ",
1315    )?;
1316    ensure_invocations_spec_root_column(&conn)?;
1317    let spec_root_id = upsert_spec_root(&conn, spec_root)?;
1318    let ts = unix_ts();
1319    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
1320    let cwd_s = cwd.to_string_lossy();
1321    conn.execute(
1322        "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
1323         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1324        rusqlite::params![
1325            ts,
1326            branch,
1327            cwd_s.as_ref(),
1328            command_path,
1329            argv_json,
1330            exit_code,
1331            spec_root_id
1332        ],
1333    )?;
1334    Ok(())
1335}
1336
1337fn unix_ts() -> String {
1338    use std::time::SystemTime;
1339    SystemTime::now()
1340        .duration_since(std::time::UNIX_EPOCH)
1341        .unwrap_or_default()
1342        .as_secs()
1343        .to_string()
1344}
1345
1346#[derive(Debug)]
1347pub struct MatchOutcome<'a> {
1348    pub chain: Vec<String>,
1349    pub node: Option<&'a CommandNode>,
1350    pub trailing: Vec<OsString>,
1351    pub wants_help: bool,
1352}
1353
1354pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
1355    let mut chain = Vec::new();
1356    let mut node: Option<&'a CommandNode> = None;
1357    let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
1358    let mut i = 0usize;
1359    let len = args.len();
1360    while i < len {
1361        let raw = &args[i];
1362        if raw == "--help" || raw == "-h" {
1363            return MatchOutcome {
1364                chain,
1365                node,
1366                trailing: args[i + 1..].to_vec(),
1367                wants_help: true,
1368            };
1369        }
1370        let key = raw.to_string_lossy();
1371        if let Some(next) = map.get(key.as_ref()) {
1372            chain.push(key.into_owned());
1373            node = Some(next);
1374            map = &next.commands;
1375            i += 1;
1376            continue;
1377        }
1378        break;
1379    }
1380    MatchOutcome {
1381        chain,
1382        node,
1383        trailing: args[i..].to_vec(),
1384        wants_help: false,
1385    }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use std::io::Write;
1392
1393    #[test]
1394    fn examples_default_spec_validates() {
1395        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
1396        load_spec(&path).unwrap();
1397    }
1398
1399    #[test]
1400    fn merge_specs_adds_and_replaces_leaves() {
1401        let mut base = load_spec_from_str(
1402            r"
1403commands:
1404  a:
1405    about: base
1406    commands:
1407      x:
1408        about: old
1409        exec:
1410          argv: [echo, old]
1411",
1412            None,
1413        )
1414        .unwrap();
1415        let overlay = load_spec_from_str(
1416            r"
1417commands:
1418  a:
1419    commands:
1420      x:
1421        about: new leaf
1422        exec:
1423          argv: [echo, new]
1424  b:
1425    about: added top
1426    exec:
1427      argv: [echo, b]
1428",
1429            None,
1430        )
1431        .unwrap();
1432        merge_specs_into(&mut base, overlay).unwrap();
1433        base.commands["a"].commands["x"].validate("a x").unwrap();
1434        assert_eq!(
1435            base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
1436            vec!["echo", "new"]
1437        );
1438        assert_eq!(
1439            base.commands["b"].exec.as_ref().unwrap().argv,
1440            vec!["echo", "b"]
1441        );
1442    }
1443
1444    #[test]
1445    fn validate_rejects_exec_with_children() {
1446        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
1447        write!(
1448            tmp,
1449            r"
1450commands:
1451  x:
1452    exec:
1453      argv: [echo]
1454    commands:
1455      child:
1456        about: nested
1457"
1458        )
1459        .unwrap();
1460        let err = load_spec(tmp.path()).unwrap_err();
1461        assert!(err.to_string().contains("cannot define both"));
1462    }
1463
1464    #[test]
1465    fn shell_inline_c_needs_argv0_detects_bash_lc() {
1466        let argv = vec![
1467            "bash".into(),
1468            "-lc".into(),
1469            "case \"$1\" in create) ;; esac".into(),
1470        ];
1471        assert!(shell_inline_c_needs_argv0(&argv));
1472        let with_placeholder = vec!["zsh".into(), "-c".into(), "echo".into(), "issue".into()];
1473        assert!(!shell_inline_c_needs_argv0(&with_placeholder));
1474        assert!(!shell_inline_c_needs_argv0(&[
1475            "echo".into(),
1476            "start".into()
1477        ]));
1478        assert!(!shell_inline_c_needs_argv0(&[
1479            "python3".into(),
1480            "-c".into(),
1481            "print(1)".into()
1482        ]));
1483    }
1484
1485    #[test]
1486    fn shell_passthrough_argv0_skips_run_leaf() {
1487        assert_eq!(
1488            shell_passthrough_argv0(&[
1489                "scripts".into(),
1490                "misc".into(),
1491                "issue".into(),
1492                "run".into()
1493            ]),
1494            "issue"
1495        );
1496        assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
1497    }
1498}
1499
1500pub fn default_db_path() -> PathBuf {
1501    if let Ok(p) = std::env::var("JAN_DB") {
1502        return PathBuf::from(p);
1503    }
1504    dirs::data_local_dir()
1505        .unwrap_or_else(|| PathBuf::from("."))
1506        .join("jan-cli")
1507        .join("audit.db")
1508}