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::{CommandNode, RootSpec, SpecRootIdentity};
pub fn is_builtin_reserved(token: &str) -> bool {
matches!(
token,
"bundle"
| "alias"
| "use"
| "list"
| "search"
| "show"
| "validate"
| "audit"
| "cron"
| "packages"
| "test"
)
}
pub fn is_pre_spec_builtin(token: &str) -> bool {
matches!(token, "use")
}
#[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: "sh".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: sh)
-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'`.
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.
"
);
}
fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\"'\"'"))
}
fn is_safe_alias_name(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
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)
}
fn collect_script_alias_chains(spec: &RootSpec) -> Vec<Vec<String>> {
let mut out = Vec::new();
fn walk(prefix: &[String], map: &BTreeMap<String, CommandNode>, out: &mut Vec<Vec<String>>) {
for (name, node) in map {
let mut chain = prefix.to_vec();
chain.push(name.clone());
if let Some(run) = node.commands.get("run") {
if run.exec.is_some() {
let mut run_chain = chain.clone();
run_chain.push("run".into());
out.push(run_chain);
continue;
}
}
if node.exec.is_some() && node.commands.is_empty() {
out.push(chain);
} else if !node.commands.is_empty() {
walk(&chain, &node.commands, out);
}
}
}
walk(&[], &spec.commands, &mut out);
out
}
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 chains = collect_script_alias_chains(spec);
let mut collisions: Vec<(String, usize)> = Vec::new();
for chain in chains {
let Some(key) = alias_key_for_chain(&chain) else {
continue;
};
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.clone(), old_len));
o.insert(chain);
} else if chain.len() == old_len && chain != *o.get() {
collisions.push((key.clone(), old_len));
}
}
}
}
let mut names: Vec<String> = winners.keys().cloned().collect();
names.sort();
let mut body = String::new();
let header = match cli.shell.as_str() {
"zsh" => "# generated by `jan alias` (zsh)\n# requires: jan use <DIR>\n",
"bash" => "# generated by `jan alias` (bash)\n# requires: jan use <DIR>\n",
_ => "# generated by `jan alias` (POSIX sh)\n# requires: jan use <DIR>\n",
};
body.push_str(header);
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)));
}
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 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)
}
#[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'"
);
}
}