use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde_json::json;
use sha2::{Digest, Sha256};
use zip::write::FileOptions;
use zip::CompressionMethod;
use crate::yaml_closure::ordered_yaml_closure;
use crate::{RootSpec, SpecRootIdentity};
pub fn is_builtin_reserved(token: &str) -> bool {
matches!(
token,
"bundle"
| "alias"
| "config"
| "use"
| "computer"
| "list"
| "search"
| "show"
| "validate"
| "audit"
| "cron"
| "packages"
| "test"
| "ps"
)
}
pub fn is_pre_spec_builtin(token: &str) -> bool {
matches!(token, "use" | "computer" | "ps")
}
#[derive(Debug, Default)]
struct BundleCli {
output: PathBuf,
dry_run: bool,
}
fn parse_bundle_args(args: &[OsString]) -> Result<BundleCli> {
let mut out = BundleCli {
output: PathBuf::from("jan-spec-bundle.zip"),
dry_run: false,
};
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--dry-run" => {
out.dry_run = true;
}
"-o" | "--output" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `{}`", s))?;
out.output = PathBuf::from(next);
i += 1;
}
"--help" | "-h" => {
print_bundle_help();
return Err(anyhow::anyhow!("help"));
}
other if other.starts_with('-') && other != "-" => {
bail!("unknown bundle flag `{}`", other);
}
_ => {
bail!(
"unexpected bundle argument `{}` (try `jan bundle --help`)",
s
);
}
}
i += 1;
}
Ok(out)
}
fn parse_alias_args(args: &[OsString]) -> Result<AliasCli> {
let mut out = AliasCli::default();
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"-o" | "--output" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `{}`", s))?;
out.output = Some(PathBuf::from(next));
i += 1;
}
"--jan-bin" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `--jan-bin`"))?;
out.jan_bin = next.to_string_lossy().into_owned();
i += 1;
}
"--shell" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `--shell`"))?;
let sh = next.to_string_lossy().into_owned().to_ascii_lowercase();
if sh != "sh" && sh != "zsh" && sh != "bash" {
bail!("--shell expects sh | bash | zsh");
}
out.shell = sh;
i += 1;
}
"--help" | "-h" => {
print_alias_help();
return Err(anyhow::anyhow!("help"));
}
other if other.starts_with('-') && other != "-" => {
bail!("unknown alias flag `{}`", other);
}
_ => {
bail!("unexpected alias argument `{}` (try `jan alias --help`)", s);
}
}
i += 1;
}
Ok(out)
}
#[derive(Debug)]
struct AliasCli {
output: Option<PathBuf>,
jan_bin: String,
shell: String,
}
impl Default for AliasCli {
fn default() -> Self {
Self {
output: None,
jan_bin: "jan".into(),
shell: "zsh".into(),
}
}
}
fn print_bundle_help() {
print!(
"\
bundle — pack all reachable YAML specs into a ZIP under the preferred jan directory
USAGE:
jan bundle [OPTIONS]
OPTIONS:
--dry-run List files instead of creating an archive
-o, --output <FILE> Output zip path (default: jan-spec-bundle.zip)
-h, --help Prints help
DESCRIPTION:
Validates that every transitive `include:` target canonicalizes beneath the preferred
jan directory, then creates a ZIP with paths relative to that directory. Also writes
`env.sh` and `manifest.json` into the archive. After unpacking, run `jan use <DIR>`.
"
);
}
fn print_alias_help() {
print!(
"\
alias — emit shell aliases for executable leaves in the preferred tree
USAGE:
jan alias [OPTIONS]
OPTIONS:
--jan-bin <NAME> Program name/path used on the RHS (default: jan)
--shell <sh|bash|zsh> Shell dialect for the header (default: zsh)
-o, --output <FILE> Write to FILE instead of stdout
-h, --help Prints help
DESCRIPTION:
For each script `run` leaf, prints `alias <name>='jan … scripts <cat> <name> run'`.
Also emits extra names and traditional shell aliases declared in YAML `aliases:`.
Aliases assume the preferred directory from `jan use` is already configured.
Alias names must match `[A-Za-z_][A-Za-z0-9_-]*`; unsafe names are rejected.
Host env fragments / symlinks / git config belong in `config:` (`jan config`), not here.
"
);
}
fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\"'\"'"))
}
fn is_safe_alias_name(name: &str) -> bool {
crate::is_safe_alias_name(name)
}
fn shell_command(argv: &[String]) -> String {
argv.iter()
.map(|arg| shell_single_quote(arg))
.collect::<Vec<_>>()
.join(" ")
}
fn unix_ts() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
fn git_sha_for_dir(dir: &Path) -> Option<String> {
let out = Command::new("git")
.args(["-C", dir.to_str()?, "rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn hash_file_sha256(path: &Path) -> Result<String> {
let mut f = File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0_u8; 64 * 1024];
loop {
let read = f.read(&mut buf)?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(hex_encode(&hasher.finalize()))
}
fn write_zip_entry(
zip: &mut zip::ZipWriter<File>,
name: &str,
content: &[u8],
opts: FileOptions<'_, ()>,
) -> Result<()> {
zip.start_file(name.replace('\\', "/"), opts)?;
zip.write_all(content)?;
Ok(())
}
pub fn bundle_spec_zip(
spec_identity: &SpecRootIdentity,
args: &[OsString],
verbose: bool,
) -> Result<i32> {
let cli = match parse_bundle_args(args) {
Ok(c) => c,
Err(e) => {
if e.to_string() == "help" {
return Ok(0);
}
return Err(e);
}
};
let anchor = PathBuf::from(&spec_identity.spec_dir)
.canonicalize()
.with_context(|| format!("canonicalize jan dir {}", spec_identity.spec_dir))?;
let entry_yaml = anchor.join(&spec_identity.root_yaml);
if verbose {
eprintln!("jan bundle: anchor={}", anchor.display());
eprintln!("jan bundle: entry={}", entry_yaml.display());
}
let mut merged = ordered_yaml_closure(&entry_yaml, &anchor)?;
for rel in ["manifest.json", "generated/scripts/manifest.json"] {
let cand = anchor.join(rel);
if cand.is_file() && !merged.iter().any(|p| p == &cand) {
merged.push(cand);
}
}
merged.sort();
let yaml_count = merged.len();
if cli.dry_run {
for p in &merged {
match p.strip_prefix(&anchor) {
Ok(rel) => println!("{}", rel.display()),
Err(_) => {
unreachable!("paths were forced under anchor when collected");
}
}
}
println!("env.sh");
println!("manifest.json");
return Ok(0);
}
let opts = FileOptions::<'_, ()>::default().compression_method(CompressionMethod::Deflated);
let file =
File::create(&cli.output).with_context(|| format!("create {}", cli.output.display()))?;
let mut zip = zip::ZipWriter::new(file);
let mut manifest_files = serde_json::Map::new();
for p in &merged {
let rel = p.strip_prefix(&anchor).with_context(|| {
format!("strip prefix `{}` from `{}`", anchor.display(), p.display())
})?;
let arc_name = rel.to_string_lossy().replace('\\', "/");
zip.start_file(arc_name.clone(), opts)?;
std::io::copy(&mut File::open(p)?, &mut zip)?;
let size = p.metadata().map(|m| m.len()).unwrap_or(0);
let sha256 = hash_file_sha256(p)?;
manifest_files.insert(arc_name.clone(), json!({ "sha256": sha256, "size": size }));
}
let env_sh = format!(
"# Generated by `jan bundle` — after unzip, point jan at this tree:\n\
# jan use \"$(cd \"$(dirname \"${{BASH_SOURCE[0]:-$0}}\")\" && pwd)\"\n\
# Entry YAML: {}\n",
spec_identity.root_yaml
);
write_zip_entry(&mut zip, "env.sh", env_sh.as_bytes(), opts)?;
let bundle_manifest = json!({
"jan_cli_version": env!("CARGO_PKG_VERSION"),
"bundled_at": unix_ts(),
"root_yaml": spec_identity.root_yaml,
"git_sha": git_sha_for_dir(&anchor),
"files": manifest_files,
});
let manifest_bytes = serde_json::to_vec_pretty(&bundle_manifest)?;
write_zip_entry(&mut zip, "manifest.json", &manifest_bytes, opts)?;
zip.finish()?;
if verbose {
eprintln!(
"jan bundle: wrote {} with {} YAML file(s), env.sh, manifest.json",
cli.output.display(),
yaml_count,
);
}
Ok(0)
}
struct JanAliasTarget {
chain: Vec<String>,
extra_names: Vec<String>,
}
fn collect_alias_decls(spec: &RootSpec) -> (Vec<JanAliasTarget>, BTreeMap<String, String>) {
let mut jan = Vec::new();
let mut shell = BTreeMap::new();
crate::shell_emit::visit_command_tree(&spec.commands, &[], &mut |chain, node| {
for (an, rhs) in &node.aliases.shell {
shell.insert(an.clone(), rhs.clone());
}
if chain.last().map(String::as_str) == Some("run") {
return;
}
if let Some(run) = node.commands.get("run") {
if run.exec.is_some() {
let mut run_chain = chain.to_vec();
run_chain.push("run".into());
jan.push(JanAliasTarget {
extra_names: node.aliases.names.clone(),
chain: run_chain,
});
return;
}
}
if node.exec.is_some() && node.commands.is_empty() {
jan.push(JanAliasTarget {
extra_names: node.aliases.names.clone(),
chain: chain.to_vec(),
});
}
});
(jan, shell)
}
fn alias_key_for_chain(chain: &[String]) -> Option<String> {
if chain.is_empty() {
return None;
}
if chain.last().map(|s| s.as_str()) == Some("run") && chain.len() >= 2 {
return Some(chain[chain.len() - 2].clone());
}
chain.last().cloned()
}
fn jan_invocation_prefix(cli: &AliasCli) -> Vec<String> {
vec![cli.jan_bin.clone()]
}
pub fn emit_shell_aliases(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let cli = match parse_alias_args(args) {
Ok(c) => c,
Err(e) => {
if e.to_string() == "help" {
return Ok(0);
}
return Err(e);
}
};
let mut winners: HashMap<String, Vec<String>> = HashMap::new();
let mut collisions: Vec<(String, usize)> = Vec::new();
let register = |winners: &mut HashMap<String, Vec<String>>,
collisions: &mut Vec<(String, usize)>,
key: String,
chain: Vec<String>|
-> Result<()> {
if !is_safe_alias_name(&key) {
bail!("refusing unsafe alias name `{key}`; names must match [A-Za-z_][A-Za-z0-9_-]*");
}
match winners.entry(key.clone()) {
Entry::Vacant(v) => {
v.insert(chain);
}
Entry::Occupied(mut o) => {
let old_len = o.get().len();
if chain.len() > old_len {
collisions.push((key, old_len));
o.insert(chain);
} else if chain.len() == old_len && chain != *o.get() {
collisions.push((key, old_len));
}
}
}
Ok(())
};
let (targets, shell_aliases) = collect_alias_decls(spec);
for target in targets {
let Some(key) = alias_key_for_chain(&target.chain) else {
continue;
};
register(&mut winners, &mut collisions, key, target.chain.clone())?;
for extra in target.extra_names {
register(&mut winners, &mut collisions, extra, target.chain.clone())?;
}
}
let mut names: Vec<String> = winners.keys().cloned().collect();
names.sort();
let mut body = crate::shell_emit::generated_shell_header(
"jan alias",
&cli.shell,
"# requires: jan use <DIR>",
);
let prefix = jan_invocation_prefix(&cli);
for name in &names {
let chain = winners.get(name).expect("key");
let mut rhs_parts = prefix.clone();
rhs_parts.extend(chain.iter().cloned());
let rhs = shell_command(&rhs_parts);
body.push_str(&format!("alias {}={}\n", name, shell_single_quote(&rhs)));
}
let mut skipped_shell: Vec<String> = Vec::new();
let mut shell_emit: Vec<(String, String)> = Vec::new();
for (name, rhs) in shell_aliases {
if !is_safe_alias_name(&name) {
bail!("refusing unsafe alias name `{name}`; names must match [A-Za-z_][A-Za-z0-9_-]*");
}
if winners.contains_key(&name) {
skipped_shell.push(name);
continue;
}
shell_emit.push((name, rhs));
}
if !shell_emit.is_empty() {
body.push('\n');
body.push_str("# shell aliases from spec\n");
for (name, rhs) in &shell_emit {
body.push_str(&format!("alias {}={}\n", name, shell_single_quote(rhs)));
}
}
if !collisions.is_empty() {
body.push('\n');
body.push_str(
"# duplicate leaf names resolved by preferring the longest subcommand chain\n",
);
for (n, len) in collisions {
body.push_str(&format!("# noted collision on `{n}` (tied length {len})\n"));
}
}
if !skipped_shell.is_empty() {
body.push('\n');
body.push_str("# shell aliases skipped because the name is already a jan command alias\n");
for n in &skipped_shell {
body.push_str(&format!("# skipped `{n}`\n"));
}
}
if let Some(path) = &cli.output {
let mut f = File::create(path).with_context(|| format!("create {}", path.display()))?;
f.write_all(body.as_bytes())?;
} else {
print!("{body}");
}
Ok(0)
}
fn print_use_help() {
print!(
"\
use — set, show, or clear the preferred jan directory (XDG config)
USAGE:
jan use <DIR> [--root <NAME>]
jan use <HTTPS_URL> --sha256 <HEX> [--root <NAME>] [--allow-http]
jan use --show
jan use --clear
jan use --help
OPTIONS:
--root <NAME> Entry YAML file name inside DIR (auto-detected if omitted)
--sha256 <HEX> Required for remote URLs: SHA256 of the bundle zip
--allow-http Allow plain HTTP for remote URLs (insecure)
--show Print the saved preference and config path
--clear Remove the saved preference
-h, --help Prints help
DESCRIPTION:
Saves DIR to `$XDG_CONFIG_HOME/jan-cli/config.json` (typically
`~/.config/jan-cli/config.json`). Later `jan` invocations load that tree.
After `jan use`, run `jan --help` to list live subcommands.
DIR must contain `scripts.spec.yaml`, `jan.spec.yaml`, or `jan.yaml`
(or the file named by `--root`).
A remote HTTPS URL must point at a jan bundle zip (as produced by
`jan bundle`). The zip is downloaded, verified against `--sha256`,
unpacked under `~/.cache/jan/trees/<sha256>/`, and that directory is
preferred. Hashes check integrity only; they do not authenticate the
publisher.
EXAMPLES:
jan use ~/.config/jan/scripts
jan use /path/to/dotfiles/jan
jan use https://example.com/scripts-jan.zip --sha256 <64-hex>
jan use --show
jan use --clear
"
);
}
pub fn run_use(args: &[OsString]) -> Result<i32> {
let mut show = false;
let mut clear = false;
let mut root: Option<String> = None;
let mut dir: Option<String> = None;
let mut sha256: Option<String> = None;
let mut allow_http = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--show" => show = true,
"--clear" => clear = true,
"--allow-http" => allow_http = true,
"--root" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value after `--root`"))?;
root = Some(next.to_string_lossy().into_owned());
i += 1;
}
"--sha256" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value after `--sha256`"))?;
sha256 = Some(next.to_string_lossy().into_owned());
i += 1;
}
"--help" | "-h" => {
print_use_help();
return Ok(0);
}
other if other.starts_with('-') => {
bail!("unknown use flag `{other}` (try `jan use --help`)");
}
_ => {
if dir.is_some() {
bail!("unexpected argument `{s}` (try `jan use --help`)");
}
dir = Some(s.into_owned());
}
}
i += 1;
}
if show && clear {
bail!("use either --show or --clear, not both");
}
if (show || clear) && dir.is_some() {
bail!("do not pass a directory with --show or --clear");
}
if show {
let cfg = crate::config::load_user_config()?;
let path = crate::config::config_path();
println!("config: {}", path.display());
match (&cfg.jan_dir, &cfg.spec_root) {
(Some(d), Some(r)) => {
println!("jan-dir: {d}");
println!("spec-root: {r}");
}
(Some(d), None) => {
println!("jan-dir: {d}");
println!("spec-root: (auto)");
}
_ => println!("(no preferred jan directory set)"),
}
if let Some(url) = &cfg.jan_dir_source_url {
println!("source-url: {url}");
}
if let Some(h) = &cfg.jan_dir_sha256 {
println!("source-sha256: {h}");
}
return Ok(0);
}
if clear {
crate::config::clear_user_config()?;
println!(
"cleared preferred jan directory ({})",
crate::config::config_path().display()
);
return Ok(0);
}
let Some(dir) = dir else {
print_use_help();
bail!("missing directory (try `jan use <DIR>` or `jan use --show`)");
};
let cfg = if crate::remote::looks_like_remote_url(&dir) {
let Some(ref hash) = sha256 else {
bail!("remote `jan use` requires `--sha256 <HEX>` (try `jan use --help`)");
};
let opts = crate::remote::FetchOpts::new().with_allow_http(allow_http);
let (tree_dir, detected_root) = crate::remote::fetch_and_install_bundle(&dir, hash, &opts)?;
let explicit = root.as_deref().or(Some(detected_root.as_str()));
crate::config::set_preferred_jan_dir_remote(&tree_dir, explicit, Some(&dir), Some(hash))?
} else {
if sha256.is_some() {
bail!("`--sha256` is only valid with a remote URL");
}
if allow_http {
bail!("`--allow-http` is only valid with a remote URL");
}
crate::config::set_preferred_jan_dir(Path::new(&dir), root.as_deref())?
};
println!(
"preferred jan directory saved:\n jan-dir: {}\n spec-root: {}\n config: {}",
cfg.jan_dir.as_deref().unwrap_or("?"),
cfg.spec_root.as_deref().unwrap_or("?"),
crate::config::config_path().display()
);
if let Some(url) = &cfg.jan_dir_source_url {
println!(" source-url: {url}");
}
Ok(0)
}
fn print_computer_help() {
print!(
"\
computer — register, show, or clear the host computer id for `computer:` filtering
USAGE:
jan computer set <ID>
jan computer show
jan computer clear
jan computer --help
DESCRIPTION:
Saves the computer id to `$XDG_CONFIG_HOME/jan-cli/config.json` (same file as
`jan use`). Commands and config nodes may declare `computer: [id, …]` in YAML;
when the list is non-empty, the node is ignored unless the registered id matches.
An empty `computer:` list (or omitting the field) applies on all computers.
Override for tests: `JAN_COMPUTER=<id>`.
EXAMPLES:
jan computer set framework
jan computer show
jan computer clear
"
);
}
pub fn run_computer(args: &[OsString]) -> Result<i32> {
let mut show = false;
let mut clear = false;
let mut positional: Vec<String> = Vec::new();
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--show" => show = true,
"--clear" => clear = true,
"--help" | "-h" => {
print_computer_help();
return Ok(0);
}
other if other.starts_with('-') => {
bail!("unknown computer flag `{other}` (try `jan computer --help`)");
}
_ => positional.push(s.into_owned()),
}
i += 1;
}
if show && clear {
bail!("use either --show or --clear, not both");
}
let action = if show {
"show"
} else if clear {
"clear"
} else {
positional.first().map(String::as_str).unwrap_or("")
};
match action {
"set" => {
let computer_id = positional
.get(1)
.ok_or_else(|| anyhow::anyhow!("missing computer id after `set`"))?;
let cfg = crate::config::set_computer_id(computer_id)?;
println!(
"computer id saved: {}\n config: {}",
cfg.computer_id.as_deref().unwrap_or("?"),
crate::config::config_path().display()
);
Ok(0)
}
"show" => {
let detected = crate::spec_load::HostComputer::detect();
let cfg = crate::config::load_user_config()?;
println!("config: {}", crate::config::config_path().display());
match cfg.computer_id.as_deref() {
Some(id) => println!("computer-id: {id}"),
None => println!("computer-id: (not set — run `jan computer set <ID>`)"),
}
if let Some(id) = detected.id.as_ref() {
println!("effective: {id}");
} else {
println!("effective: (none — `computer:`-filtered nodes are hidden)");
}
Ok(0)
}
"clear" => {
crate::config::clear_computer_id()?;
println!(
"cleared computer id ({})",
crate::config::config_path().display()
);
Ok(0)
}
"" => {
print_computer_help();
bail!("missing subcommand (try `jan computer set <ID>` or `jan computer show`)");
}
other => {
let cfg = crate::config::set_computer_id(other)?;
println!(
"computer id saved: {}\n config: {}",
cfg.computer_id.as_deref().unwrap_or("?"),
crate::config::config_path().display()
);
Ok(0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alias_names_use_a_conservative_shell_identifier() {
for valid in ["sum", "_private", "apply-copyright", "isMac", "name_2"] {
assert!(is_safe_alias_name(valid), "{valid}");
}
for invalid in [
"",
"-option",
"2fast",
"has space",
"x;touch /tmp/pwn",
"x$(id)",
"x`id`",
"x/y",
"x\nid",
] {
assert!(!is_safe_alias_name(invalid), "{invalid}");
}
}
#[test]
fn alias_command_quotes_every_argument() {
let argv = vec![
"jan".to_string(),
"parent; touch /tmp/pwn".to_string(),
"name with spaces".to_string(),
"it's-safe".to_string(),
];
assert_eq!(
shell_command(&argv),
"'jan' 'parent; touch /tmp/pwn' 'name with spaces' 'it'\"'\"'s-safe'"
);
}
}