use assert_cmd::Command;
use predicates::prelude::*;
use rusqlite::Connection;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command as SysCommand;
use tempfile::tempdir;
use zip::write::FileOptions;
fn jan() -> Command {
Command::cargo_bin("jan").unwrap()
}
fn jan_cli_manifest_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn portable_scripts_fixture() -> PathBuf {
jan_cli_manifest_dir().join("tests/fixtures/portable-scripts")
}
fn prefer_tree(tree: &Path, cfg: &Path) {
jan()
.env("JAN_CONFIG_DIR", cfg)
.args(["use", tree.to_str().unwrap()])
.assert()
.success();
}
fn write_scripts_spec(dir: &Path, body: &str) {
fs::write(dir.join("scripts.spec.yaml"), body).unwrap();
}
fn write_test_zip(path: &Path, entries: &[(&str, &[u8])]) {
let file = fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = FileOptions::<'_, ()>::default();
for (name, content) in entries {
zip.start_file(*name, options).unwrap();
zip.write_all(content).unwrap();
}
zip.finish().unwrap();
}
#[test]
fn help_root_lists_android() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
description: Root description line.
commands:
android:
about: Android tools
commands:
list:
about: List
exec:
argv: ["echo", "ok"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("android"))
.stdout(predicate::str::contains("Root description"))
.stdout(predicate::str::contains("Built-in commands"));
}
#[test]
fn nested_exec_runs() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
a:
about: A
commands:
b:
about: B
commands:
c:
about: C
exec:
argv: ["echo", "leaf"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "a", "b", "c"])
.assert()
.success()
.stdout(predicate::str::contains("leaf"));
}
#[test]
fn passthrough_forwards_args() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
run:
about: Run
exec:
argv: ["echo", "start"]
passthrough: true
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "run", "extra", "tokens"])
.assert()
.success()
.stdout(predicate::str::contains("start extra tokens"));
}
#[test]
fn shell_c_passthrough_puts_args_in_dollar1() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
issue:
about: note app
commands:
run:
about: run
exec:
argv:
- bash
- -lc
- |
case "$1" in
create) echo "got-create argc=$#" ;;
*) echo "miss arg1=${1-} argc=$#" ;;
esac
passthrough: true
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "issue", "run", "create"])
.assert()
.success()
.stdout(predicate::str::contains("got-create argc=1"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "issue", "run", "--", "create"])
.assert()
.success()
.stdout(predicate::str::contains("got-create argc=1"));
}
#[test]
fn preferred_spec_help_lists_commands() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
metadata:
name: demo
commands:
alpha:
about: Alpha tool
exec:
argv: ["echo", "alpha"]
beta:
about: Beta tool
exec:
argv: ["echo", "beta"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_OS", "linux")
.args(["--no-log", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("alpha"))
.stdout(predicate::str::contains("beta"));
}
#[test]
fn examples_default_git_r_passthrough() {
let dir = tempdir().unwrap();
let examples = jan_cli_manifest_dir().join("examples");
let cfg = tempdir().unwrap();
SysCommand::new("git")
.args(["init"])
.current_dir(dir.path())
.status()
.expect("git init");
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"use",
examples.to_str().unwrap(),
"--root",
"default.spec.yaml",
])
.assert()
.success();
jan()
.current_dir(dir.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "git", "r", "rev-parse", "--is-inside-work-tree"])
.assert()
.success()
.stdout(predicate::str::contains("true"));
}
#[test]
fn spec_include_resolves_subtree_from_disk() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(
sub.join("leaf.yaml"),
r#"
about: From include
exec:
argv: ["echo", "included"]
"#,
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: inc-demo
commands:
outer:
include: sub/leaf.yaml
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.success()
.stdout(predicate::str::contains("included"));
}
#[test]
fn script_include_runs_with_passthrough() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/greet.sh"),
"#!/bin/sh\nprintf 'hi:%s\\n' \"$1\"\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: script-inc
commands:
greet:
about: Greet
include:
path: scripts/greet.sh
argv: [bash]
passthrough: true
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "greet", "world"])
.assert()
.success()
.stdout(predicate::str::contains("hi:world"));
}
#[test]
fn local_include_sha256_mismatch_fails_load() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("sub/leaf.yaml"),
"about: x\nexec:\n argv: [echo, ok]\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pin-demo
commands:
outer:
include:
path: sub/leaf.yaml
sha256: 0000000000000000000000000000000000000000000000000000000000000000
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.failure()
.stderr(predicate::str::contains("SHA256 mismatch"));
}
#[test]
fn local_include_optional_sha256_ok() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
let body = "about: pinned\nexec:\n argv: [echo, pinned-ok]\n";
fs::write(dir.path().join("sub/leaf.yaml"), body).unwrap();
let digest = "bea2991fe347b4490607fccecc8fa838af970c69797303ad0299f4862aa21a22";
write_scripts_spec(
dir.path(),
&format!(
r#"
metadata:
name: pin-ok
commands:
outer:
include:
path: sub/leaf.yaml
sha256: {digest}
"#
),
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.success()
.stdout(predicate::str::contains("pinned-ok"));
}
#[test]
fn exec_file_leaf_runs() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("bin")).unwrap();
fs::write(
dir.path().join("bin/tool.sh"),
"#!/bin/sh\necho from-file\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: file-exec
commands:
tool:
about: file leaf
exec:
file: bin/tool.sh
argv: [bash]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "tool"])
.assert()
.success()
.stdout(predicate::str::contains("from-file"));
}
#[test]
fn bundle_includes_script_files() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hi.sh"), "echo hi\n").unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: bundle-script
commands:
hi:
include:
path: scripts/hi.sh
argv: [bash]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let out = dir.path().join("out.zip");
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["bundle", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("scripts/hi.sh"));
let _ = out;
}
#[test]
fn os_filter_hides_command_on_mismatch() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: os-demo
commands:
only_win:
os: [windows]
about: Hidden on Linux
exec:
argv: ["echo", "win"]
always:
about: Shown
exec:
argv: ["echo", "ok"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let assert = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_OS", "linux")
.args(["--no-log", "--help"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("always"));
assert!(!stdout.contains("only_win"));
}
#[test]
fn sqlite_log_written() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
leaf:
about: Leaf
exec:
argv: ["echo", "x"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let db = dir.path().join("audit.db");
SysCommand::new("git")
.args(["init"])
.current_dir(dir.path())
.status()
.expect("git init");
jan()
.current_dir(dir.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--db",
db.to_str().unwrap(),
"--branch",
"test-branch-2120",
"leaf",
])
.assert()
.success();
let conn = Connection::open(&db).unwrap();
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM invocations WHERE git_branch = ?1",
["test-branch-2120"],
|r| r.get(0),
)
.unwrap();
assert!(n >= 1);
let spec_dir: String = conn
.query_row(
"SELECT s.spec_dir FROM invocations i JOIN spec_roots s ON i.spec_root_id = s.id \
WHERE i.git_branch = ?1 ORDER BY i.id DESC LIMIT 1",
["test-branch-2120"],
|r| r.get(0),
)
.unwrap();
let expected_dir = dir
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
assert_eq!(spec_dir, expected_dir);
}
#[test]
fn preferred_dir_loads_includes() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(
sub.join("leaf.yaml"),
r#"
about: From include
exec:
argv: ["echo", "from-preferred-dir"]
"#,
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: dir-demo
commands:
outer:
include: sub/leaf.yaml
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.success()
.stdout(predicate::str::contains("from-preferred-dir"));
}
#[test]
fn script_dependencies_prepended_to_path() {
let dir = tempdir().unwrap();
let helper = dir.path().join("helper-bin");
fs::create_dir_all(&helper).unwrap();
let helper_script = helper.join("helper-cmd");
fs::write(&helper_script, "#!/bin/sh\nprintf 'from-helper'\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&helper_script).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&helper_script, perms).unwrap();
}
let consumer = dir.path().join("consumer");
fs::create_dir_all(&consumer).unwrap();
let helper_s = helper.to_string_lossy();
let consumer_s = consumer.to_string_lossy();
write_scripts_spec(
dir.path(),
&format!(
r#"
metadata:
name: demo
commands:
helper:
path: {helper_s}
commands:
run:
exec:
argv: ["echo", "unused"]
consumer:
path: {consumer_s}
dependencies: [helper]
commands:
run:
exec:
argv: ["bash", "-lc", "helper-cmd"]
"#
),
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "consumer", "run"])
.assert()
.success()
.stdout(predicate::str::contains("from-helper"));
}
#[cfg(unix)]
#[test]
fn spec_path_cannot_shadow_system_interpreter() {
let dir = tempdir().unwrap();
let evil = dir.path().join("evil-bin");
fs::create_dir_all(&evil).unwrap();
let fake_bash = evil.join("bash");
fs::write(&fake_bash, "#!/bin/sh\nprintf 'shadowed'\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&fake_bash).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_bash, perms).unwrap();
}
let evil_s = evil.to_string_lossy();
write_scripts_spec(
dir.path(),
&format!(
r#"
metadata:
name: demo
commands:
victim:
path: {evil_s}
commands:
run:
exec:
argv: ["bash", "-lc", "printf real"]
"#
),
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "victim", "run"])
.assert()
.success()
.stdout(predicate::str::contains("real"))
.stdout(predicate::str::contains("shadowed").not());
}
#[test]
fn env_vars_passed_to_child() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
outer:
env:
OUTER: outer-value
SHARED: from-outer
commands:
inner:
env:
INNER: inner-value
SHARED: from-inner
exec:
argv: ["bash", "-lc", "printf '%s %s %s' \"$OUTER\" \"$INNER\" \"$SHARED\""]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer", "inner"])
.assert()
.success()
.stdout(predicate::str::contains(
"outer-value inner-value from-inner",
));
}
#[test]
fn env_private_required_and_restricts_leakage() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
secure:
env:
public:
VISIBLE: from-yaml
private:
- JAN_TEST_PRIVATE_TOKEN
exec:
argv:
- bash
- -lc
- |
printf 'vis=%s tok=%s leak=%s' "$VISIBLE" "$JAN_TEST_PRIVATE_TOKEN" "${JAN_TEST_LEAK_MARKER:-unset}"
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env_remove("JAN_TEST_PRIVATE_TOKEN")
.args(["--no-log", "secure"])
.assert()
.failure()
.stderr(predicate::str::contains(
"missing required environment variables",
));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_TEST_PRIVATE_TOKEN", "s3cret")
.env("JAN_TEST_LEAK_MARKER", "should-not-pass")
.args(["--no-log", "secure"])
.assert()
.success()
.stdout(predicate::str::contains(
"vis=from-yaml tok=s3cret leak=unset",
));
}
#[test]
fn typed_inputs_flags_and_interpolation() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
backup:
about: Backup demo
inputs:
path:
description: Source directory
required: true
dest:
default: ~/Backups
env:
public:
SRC: ${{ inputs.path }}
commands:
run:
exec:
argv:
- bash
- -lc
- printf 'src=%s dest=%s' "$SRC" "${{ inputs.dest }}"
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "run"])
.assert()
.failure()
.stderr(predicate::str::contains("missing required input"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "run", "--path", "/tmp/vault"])
.assert()
.success()
.stdout(predicate::str::contains("src=/tmp/vault dest=~/Backups"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--no-log",
"backup",
"run",
"--path=/data",
"--dest",
"/mnt/b",
])
.assert()
.success()
.stdout(predicate::str::contains("src=/data dest=/mnt/b"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "run", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("Inputs:"))
.stdout(predicate::str::contains("--path"))
.stdout(predicate::str::contains("--dest"));
}
#[test]
fn requires_blocks_when_utility_missing() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
needs-fzf:
about: Needs fzf
requires: [definitely-not-a-real-binary-xyz-jan-test]
exec:
argv: ["echo", "should-not-run"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "needs-fzf"])
.assert()
.failure()
.stderr(predicate::str::contains("missing required utilities"));
}
#[test]
fn preferred_spec_logs_spec_root() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
commands:
leaf:
exec:
argv: ["echo", "logged"]
"#,
);
let cfg = tempdir().unwrap();
let cwd = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
let db = cwd.path().join("audit.db");
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--db",
db.to_str().unwrap(),
"--branch",
"pref-branch",
"leaf",
])
.assert()
.success();
let conn = Connection::open(&db).unwrap();
let (spec_dir, root_yaml): (String, String) = conn
.query_row(
"SELECT s.spec_dir, s.root_yaml FROM invocations i JOIN spec_roots s ON i.spec_root_id = s.id \
WHERE i.git_branch = ?1 ORDER BY i.id DESC LIMIT 1",
["pref-branch"],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)
.unwrap();
assert_eq!(
spec_dir,
tree.path().canonicalize().unwrap().to_string_lossy()
);
assert_eq!(root_yaml, "scripts.spec.yaml");
}
#[test]
fn scripts_bundle_dry_run_lists_portable_artifacts() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let assert = jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "bundle", "--dry-run"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("scripts.spec.yaml"));
assert!(stdout.contains("scripts.yaml"));
assert!(stdout.contains("env.sh"));
assert!(stdout.contains("manifest.json"));
}
#[test]
fn inspect_list_and_show_script_leaves() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "list"])
.assert()
.success()
.stdout(predicate::str::contains("sum"))
.stdout(predicate::str::contains("basename"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "show", "sum"])
.assert()
.success()
.stdout(predicate::str::contains("scripts misc sum"))
.stdout(predicate::str::contains("category: misc"))
.stdout(predicate::str::contains("cron:"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "search", "--keyword", "comma"])
.assert()
.success()
.stdout(predicate::str::contains("sum"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "validate"])
.assert()
.success()
.stdout(predicate::str::contains("ok:"));
}
#[test]
fn cron_lists_and_runs_matching_scripts() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: cron-demo
commands:
scripts:
commands:
misc:
commands:
tick:
about: Runs on the half hour
cron: "30 10 * * *"
commands:
run:
exec:
argv: ["echo", "tick-ok"]
silent:
about: Never matches this test clock
cron: "0 0 1 1 *"
commands:
run:
exec:
argv: ["echo", "silent"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--list"])
.assert()
.success()
.stdout(predicate::str::contains("scripts misc tick"))
.stdout(predicate::str::contains("30 10 * * *"))
.stdout(predicate::str::contains("scripts misc silent"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--dry-run", "--at", "2026-08-05 10:30"])
.assert()
.success()
.stdout(predicate::str::contains("match: scripts misc tick"))
.stdout(predicate::str::contains("silent").not());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--at", "2026-08-05 10:30"])
.assert()
.success()
.stdout(predicate::str::contains("tick-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--at", "2026-08-05 10:31", "-v"])
.assert()
.success()
.stdout(predicate::str::contains("(no scripts match)"));
}
#[test]
fn scripts_alias_emits_bare_jan_and_sum() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let assert = jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "alias"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(!stdout.contains("--spec-dir"));
assert!(!stdout.contains("--spec-root"));
assert!(stdout.contains("alias sum="));
assert!(stdout.contains("scripts"));
assert!(stdout.contains("misc"));
assert!(stdout.contains("sum"));
assert!(stdout.contains("run"));
}
#[test]
fn alias_rejects_shell_metacharacters_in_alias_name() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
commands:
"safe-parent":
commands:
"x; touch /tmp/jan-alias-injection":
exec:
argv: ["echo", "never"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "alias"])
.assert()
.failure()
.stderr(predicate::str::contains("refusing unsafe alias name"));
}
#[test]
fn scripts_inlined_basename_run() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--no-log",
"scripts",
"files",
"basename",
"run",
"--",
"/tmp/foo/bar.txt",
])
.assert()
.success()
.stdout(predicate::str::contains("bar.txt"));
}
#[test]
fn scripts_path_demo_help_lists_dependency() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let assert = jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "scripts", "system", "path-demo", "help"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("path-demo-impl"));
}
#[test]
fn packages_uv_list_installs_and_imports() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-demo
commands:
check:
about: Import pyyaml from packages.uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "import yaml; print('yaml-ok')"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("yaml-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.env("PATH", "/usr/bin:/bin")
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("yaml-ok"));
}
#[test]
fn packages_uv_requirements_file() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps")).unwrap();
fs::write(dir.path().join("deps/req.txt"), "pyyaml==6.0.2\n").unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-req
commands:
check:
about: Import via requirements
packages:
uv:
requirements: deps/req.txt
exec:
argv: ["python3", "-c", "import yaml; print('req-ok')"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("req-ok"));
}
#[test]
fn packages_pnpm_unpinned_list_rejected() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pnpm-unpinned
commands:
check:
about: Unpinned npm dep
packages:
pnpm: [lodash, "zx@^8"]
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("must pin a version"));
}
#[test]
fn packages_pnpm_project_requires_lock() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps/web")).unwrap();
fs::write(
dir.path().join("deps/web/package.json"),
"{\n \"name\": \"web\",\n \"private\": true\n}\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pnpm-unlocked
commands:
check:
about: Project without pnpm-lock.yaml
packages:
pnpm:
project: deps/web
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("no pnpm-lock.yaml"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "validate"])
.assert()
.failure()
.stdout(predicate::str::contains("no pnpm-lock.yaml"));
}
#[test]
fn packages_pnpm_missing_binary_errors() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: no-pnpm
commands:
check:
about: needs pnpm
packages:
pnpm: [lodash@4.17.21]
exec:
argv: ["node", "-e", "console.log(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("PATH", "/usr/bin:/bin") .args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("pnpm"));
}
fn pnpm_available() -> bool {
std::process::Command::new("pnpm")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
fn packages_pnpm_list_installs_and_requires() {
if !pnpm_available() {
eprintln!("skipping: pnpm not on PATH");
return;
}
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pnpm-demo
commands:
check:
about: Require lodash from packages.pnpm
packages:
pnpm: [lodash@4.17.21]
exec:
argv: ["node", "-e", "console.log(require('lodash').VERSION)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("4.17.21"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.env("PATH", "/usr/bin:/bin")
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("4.17.21"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "show", "check"])
.assert()
.success()
.stdout(predicate::str::contains("manager: pnpm"))
.stdout(predicate::str::contains("lodash@4.17.21"));
let assert = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "path", "check", "--modules"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("packages/pnpm/"));
assert!(stdout.trim().ends_with("node_modules"));
}
#[test]
fn packages_uv_missing_binary_errors() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: no-uv
commands:
check:
about: needs uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("PATH", "/usr/bin:/bin") .args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("uv"));
}
#[test]
fn packages_uv_unpinned_list_rejected() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: unpinned
commands:
check:
about: Unpinned dep
packages:
uv: [pyyaml, "requests>=2"]
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("must pin a version"));
}
#[test]
fn packages_uv_unpinned_requirements_rejected() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps")).unwrap();
fs::write(
dir.path().join("deps/req.txt"),
"# deps\npyyaml==6.0.2\nrequests\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: unpinned-req
commands:
check:
about: Unpinned requirements file
packages:
uv:
requirements: deps/req.txt
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("req.txt:3"))
.stderr(predicate::str::contains("must pin a version"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "validate"])
.assert()
.failure()
.stdout(predicate::str::contains("must pin a version"));
}
#[test]
fn packages_uv_project_requires_lock() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps/ml")).unwrap();
fs::write(
dir.path().join("deps/ml/pyproject.toml"),
"[project]\nname = \"ml\"\nversion = \"0.1.0\"\ndependencies = []\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: unlocked-project
commands:
check:
about: Project without uv.lock
packages:
uv:
project: deps/ml
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("no uv.lock"));
}
#[test]
fn packages_gradle_unpinned_list_rejected() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: gradle-unpinned
commands:
check:
about: Unpinned maven coord
packages:
gradle: ["com.google.guava:guava:31.+"]
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("exact version"));
}
#[test]
fn packages_gradle_project_requires_lock() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps/jvm")).unwrap();
fs::write(
dir.path().join("deps/jvm/build.gradle"),
"plugins { id 'java-library' }\nrepositories { mavenCentral() }\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: gradle-unlocked
commands:
check:
about: Project without gradle.lockfile
packages:
gradle:
project: deps/jvm
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("no gradle.lockfile"));
}
fn gradle_available() -> bool {
std::process::Command::new("gradle")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
fn packages_gradle_list_resolves_classpath() {
if !gradle_available() {
eprintln!("skipping: gradle not on PATH");
return;
}
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: gradle-demo
commands:
check:
about: Guava on CLASSPATH from packages.gradle
packages:
gradle: [com.google.guava:guava:33.3.1-jre]
exec:
argv:
- bash
- -lc
- |
case "$CLASSPATH" in
*guava*) echo classpath-ok ;;
*) echo "missing guava in CLASSPATH=$CLASSPATH" >&2; exit 1 ;;
esac
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("classpath-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.env("PATH", "/usr/bin:/bin")
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("classpath-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "show", "check", "--gradle"])
.assert()
.success()
.stdout(predicate::str::contains("manager: gradle"))
.stdout(predicate::str::contains("guava"));
}
#[test]
fn packages_list_reports_declared_but_unbuilt() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-list
commands:
check:
about: Import pyyaml from packages.uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "import yaml"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "list"])
.assert()
.success()
.stdout(predicate::str::contains("declared but not built yet"))
.stdout(predicate::str::contains("check"));
}
#[test]
fn packages_list_show_and_path_after_build() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-inspect
commands:
check:
about: Import pyyaml from packages.uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "import yaml; print('yaml-ok')"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "list"])
.assert()
.success()
.stdout(predicate::str::contains("USED BY"))
.stdout(predicate::str::contains("check"))
.stdout(predicate::str::contains("list"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "show", "check"])
.assert()
.success()
.stdout(predicate::str::contains("declaration: pyyaml==6.0.2"))
.stdout(predicate::str::contains("ready: yes"))
.stdout(predicate::str::contains("PyYAML==").or(predicate::str::contains("pyyaml==")));
let assert = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "path", "check", "--python"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.trim().ends_with("python3") || stdout.trim().ends_with("python"));
assert!(stdout.contains("packages/uv/"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "list", "--orphans"])
.assert()
.success()
.stdout(predicate::str::contains("no orphaned envs"));
}
#[test]
fn use_saves_preference_and_loads_without_flags() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
metadata:
name: demo
commands:
hello:
about: Hello
exec:
argv: ["echo", "from-preferred"]
"#,
);
let cfg = tempdir().unwrap();
let cwd = tempdir().unwrap();
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", tree.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("preferred jan directory saved"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "hello"])
.assert()
.success()
.stdout(predicate::str::contains("from-preferred"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--help"])
.assert()
.success()
.stdout(predicate::str::contains("hello"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("scripts.spec.yaml"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "--clear"])
.assert()
.success();
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "hello"])
.assert()
.failure()
.stderr(predicate::str::contains("jan use"));
}
#[test]
fn use_works_before_any_spec_exists() {
let cfg = tempdir().unwrap();
let cwd = tempdir().unwrap();
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("no preferred jan directory"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--help"])
.assert()
.success()
.stdout(predicate::str::contains("No preferred jan directory"))
.stdout(predicate::str::contains("Built-in commands"));
}
#[test]
fn use_help_documents_remote_bundle() {
jan()
.args(["use", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--sha256"))
.stdout(predicate::str::contains("HTTPS_URL"))
.stdout(predicate::str::contains("--allow-http"));
}
#[test]
fn remote_use_requires_sha256() {
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "https://example.com/tree.zip"])
.assert()
.failure()
.stderr(predicate::str::contains("--sha256"));
}
#[test]
fn installer_rejects_zip_slip() {
let tmp = tempdir().unwrap();
let archive = tmp.path().join("malicious.zip");
let install = tmp.path().join("install");
let escaped = tmp.path().join("escaped.yaml");
write_test_zip(
&archive,
&[
("../escaped.yaml", b"commands: {}\n"),
("manifest.json", br#"{"files":{}}"#),
("env.sh", b""),
],
);
let status = SysCommand::new("bash")
.arg(jan_cli_manifest_dir().join("scripts/jan-install.sh"))
.arg(&archive)
.arg(&install)
.status()
.expect("run installer");
assert!(!status.success(), "zip-slip archive must be rejected");
assert!(!escaped.exists());
assert!(!install.exists());
}
#[test]
fn installer_rejects_manifest_hash_mismatch() {
let tmp = tempdir().unwrap();
let archive = tmp.path().join("tampered.zip");
let install = tmp.path().join("install");
let manifest = br#"{
"root_yaml": "scripts.spec.yaml",
"files": {
"scripts.spec.yaml": {
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"size": 13
}
}
}"#;
write_test_zip(
&archive,
&[
("scripts.spec.yaml", b"commands: {}\n"),
("manifest.json", manifest),
("env.sh", b""),
],
);
let status = SysCommand::new("bash")
.arg(jan_cli_manifest_dir().join("scripts/jan-install.sh"))
.arg(&archive)
.arg(&install)
.status()
.expect("run installer");
assert!(!status.success());
assert!(!install.exists());
}
#[test]
fn installer_verifies_bundle_without_changing_jan_use() {
let root = portable_scripts_fixture();
let tmp = tempdir().unwrap();
let cfg = tmp.path().join("config");
let archive = tmp.path().join("bundle.zip");
let install = tmp.path().join("install");
let install_sh = jan_cli_manifest_dir().join("scripts/jan-install.sh");
jan()
.env("JAN_CONFIG_DIR", &cfg)
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let config_before = fs::read(cfg.join("config.json")).unwrap();
jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", &cfg)
.args(["--no-log", "bundle", "-o", archive.to_str().unwrap()])
.assert()
.success();
let output = SysCommand::new("bash")
.arg(&install_sh)
.arg(&archive)
.arg(&install)
.env("JAN_CONFIG_DIR", &cfg)
.output()
.expect("run installer");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(install.join("scripts.spec.yaml").is_file());
assert!(install.join("manifest.json").is_file());
assert_eq!(fs::read(cfg.join("config.json")).unwrap(), config_before);
assert!(String::from_utf8_lossy(&output.stdout).contains("Run: jan use"));
}