use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
const META_FILE: &str = "meta.json";
const CURRENT_LINK: &str = "current";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LabMeta {
id: String,
tree: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
root: Option<String>,
config_dir: String,
runtime_dir: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
unifier_home: Option<String>,
created_unix: u64,
}
fn print_lab_help() {
print!(
"\
lab — isolated jan + cron environment for trying a command tree
USAGE:
jan lab <DIR> [--root <NAME>] [--name <ID>] [--with-unifier] [--start] [--shell]
jan lab env [<ID>]
jan lab status [<ID>]
jan lab stop [<ID>]
jan lab ls
jan lab clean
jan lab --help
DESCRIPTION:
Spins up a private preferred tree + cron daemon so you can exercise agent
systems (for example `jobs/` scout → analyst → responder) without changing
your daily `jan use` preference or the user systemd unit.
Under the hood this sets the same two env vars the integration tests use:
JAN_CONFIG_DIR — own config.json / preferred tree / disabled agents
XDG_RUNTIME_DIR — own cron.sock (second daemon can coexist)
UNIFIER_HOME — optional (`--with-unifier`) private Unifier board
Labs live under `$XDG_CACHE_HOME/jan/lab/<ID>/` (fallback `~/.cache/jan/lab`).
QUICK START (agent tree):
# Print exports; eval them into your current shell, start cron:
eval \"$(jan lab /path/to/dotfiles/jan --start)\"
jan cron --list
jan jobs demo run
jan lab stop
# Or drop into a subshell already configured:
jan lab /path/to/dotfiles/jan --start --shell
# … try agents …
exit
OPTIONS:
--root <NAME> Entry YAML inside DIR (same as `jan use --root`)
--name <ID> Lab id (default: derived from DIR basename)
--with-unifier Also isolate Unifier under the lab (`UNIFIER_HOME`)
--start Start `jan cron` in this lab (never touches systemd)
--shell Exec $SHELL with lab env set (after setup)
-h, --help Prints help
SUBCOMMANDS:
env [<ID>] Print `export …` lines for eval (default: current lab)
status [<ID>] Show lab paths + cron daemon status
stop [<ID>] Stop the lab cron daemon (and unifier daemon if isolated)
ls List labs
clean Stop all labs and delete their cache dirs
NOTES:
`jan cron start` skips systemd automatically when `JAN_CONFIG_DIR` is set,
so a lab never hijacks `~/.config/systemd/user/jan-cron.service`.
After editing cron YAML in the tree, run `jan cron refresh` inside the lab.
"
);
}
fn labs_root() -> PathBuf {
if let Ok(p) = env::var("JAN_LAB_ROOT") {
let p = p.trim();
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("jan")
.join("lab")
}
fn lab_dir(id: &str) -> PathBuf {
labs_root().join(id)
}
fn current_path() -> PathBuf {
labs_root().join(CURRENT_LINK)
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn slug_from_tree(tree: &Path) -> String {
let base = tree
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("lab")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let base = base.trim_matches('-');
let base = if base.is_empty() { "lab" } else { base };
let stamp = now_unix() % 100_000;
format!("{base}-{stamp}")
}
fn validate_lab_id(id: &str) -> Result<()> {
if id.is_empty()
|| id == CURRENT_LINK
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
bail!("invalid lab id `{id}` (use letters, digits, `-`, `_`)");
}
Ok(())
}
fn write_meta(meta: &LabMeta) -> Result<()> {
let dir = lab_dir(&meta.id);
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join(META_FILE);
let text = serde_json::to_string_pretty(meta).context("serialize lab meta")?;
fs::write(&path, format!("{text}\n")).with_context(|| format!("write {}", path.display()))?;
let link = current_path();
let _ = fs::remove_file(&link);
#[cfg(unix)]
{
if std::os::unix::fs::symlink(&meta.id, &link).is_err() {
fs::write(&link, format!("{}\n", meta.id)).ok();
}
}
#[cfg(not(unix))]
{
fs::write(&link, format!("{}\n", meta.id)).ok();
}
Ok(())
}
fn read_meta_at(dir: &Path) -> Result<LabMeta> {
let path = dir.join(META_FILE);
let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
fn resolve_lab_id(explicit: Option<&str>) -> Result<String> {
if let Some(id) = explicit {
validate_lab_id(id)?;
if !lab_dir(id).join(META_FILE).is_file() {
bail!("lab `{id}` not found (try `jan lab ls`)");
}
return Ok(id.to_string());
}
let cur = current_path();
if cur.is_symlink() || cur.is_file() {
if let Ok(target) = fs::read_link(&cur) {
if let Some(id) = target.file_name().and_then(|s| s.to_str()) {
return Ok(id.to_string());
}
if let Some(id) = target.to_str() {
return Ok(id.to_string());
}
}
if let Ok(text) = fs::read_to_string(&cur) {
let id = text.trim();
if !id.is_empty() {
return Ok(id.to_string());
}
}
}
bail!("no current lab; pass an id or create one with `jan lab <DIR>`");
}
fn load_meta(explicit: Option<&str>) -> Result<LabMeta> {
let id = resolve_lab_id(explicit)?;
read_meta_at(&lab_dir(&id))
}
fn exports_for(meta: &LabMeta) -> String {
let mut out = String::new();
out.push_str(&format!(
"export JAN_CONFIG_DIR={}\n",
sh_single_quote(&meta.config_dir)
));
out.push_str(&format!(
"export XDG_RUNTIME_DIR={}\n",
sh_single_quote(&meta.runtime_dir)
));
if let Some(u) = &meta.unifier_home {
out.push_str(&format!("export UNIFIER_HOME={}\n", sh_single_quote(u)));
}
out
}
fn sh_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\"'\"'"))
}
fn jan_bin() -> Result<PathBuf> {
let exe = env::current_exe().context("resolve jan binary path")?;
exe.canonicalize()
.with_context(|| format!("canonicalize {}", exe.display()))
}
fn run_jan_in_lab(meta: &LabMeta, args: &[&str]) -> Result<()> {
let jan = jan_bin()?;
let mut cmd = Command::new(&jan);
cmd.args(["--no-log"]);
cmd.args(args);
cmd.env("JAN_CONFIG_DIR", &meta.config_dir);
cmd.env("XDG_RUNTIME_DIR", &meta.runtime_dir);
if let Some(u) = &meta.unifier_home {
cmd.env("UNIFIER_HOME", u);
}
let status = cmd
.status()
.with_context(|| format!("run `{} {}`", jan.display(), args.join(" ")))?;
if !status.success() {
bail!(
"`jan {}` failed in lab `{}` (status {status})",
args.join(" "),
meta.id
);
}
Ok(())
}
fn start_cron_in_lab(meta: &LabMeta) -> Result<()> {
run_jan_in_lab(meta, &["cron", "start", "--no-systemd"])?;
Ok(())
}
fn stop_cron_in_lab(meta: &LabMeta) -> Result<()> {
let jan = jan_bin()?;
let mut cmd = Command::new(&jan);
cmd.args(["--no-log", "cron", "stop", "--no-systemd"]);
cmd.env("JAN_CONFIG_DIR", &meta.config_dir);
cmd.env("XDG_RUNTIME_DIR", &meta.runtime_dir);
if let Some(u) = &meta.unifier_home {
cmd.env("UNIFIER_HOME", u);
}
let _ = cmd.status();
if meta.unifier_home.is_some() {
let mut u = Command::new("unifier");
u.args(["daemon", "stop"]);
u.env("UNIFIER_HOME", meta.unifier_home.as_ref().unwrap());
let _ = u.status();
}
Ok(())
}
fn create_lab(
tree: &Path,
root: Option<&str>,
name: Option<&str>,
with_unifier: bool,
) -> Result<LabMeta> {
let tree = tree
.canonicalize()
.with_context(|| format!("canonicalize tree {}", tree.display()))?;
if !tree.is_dir() {
bail!("tree is not a directory: {}", tree.display());
}
let id = match name {
Some(n) => {
validate_lab_id(n)?;
n.to_string()
}
None => slug_from_tree(&tree),
};
let base = lab_dir(&id);
if base.exists() {
bail!(
"lab `{id}` already exists at {} (pick `--name`, or `jan lab clean` / `jan lab stop {id}`)",
base.display()
);
}
let config_dir = base.join("config");
let runtime_dir = base.join("run");
fs::create_dir_all(&config_dir)?;
fs::create_dir_all(&runtime_dir)?;
let unifier_home = if with_unifier {
let u = base.join("unifier");
fs::create_dir_all(&u)?;
Some(u.to_string_lossy().into_owned())
} else {
None
};
let meta = LabMeta {
id: id.clone(),
tree: tree.to_string_lossy().into_owned(),
root: root.map(|s| s.to_string()),
config_dir: config_dir.to_string_lossy().into_owned(),
runtime_dir: runtime_dir.to_string_lossy().into_owned(),
unifier_home,
created_unix: now_unix(),
};
write_meta(&meta)?;
let mut use_args: Vec<String> = vec!["use".into(), meta.tree.clone()];
if let Some(r) = &meta.root {
use_args.push("--root".into());
use_args.push(r.clone());
}
let use_refs: Vec<&str> = use_args.iter().map(String::as_str).collect();
run_jan_in_lab(&meta, &use_refs)?;
if with_unifier {
let mut u = Command::new("unifier");
u.args(["daemon", "start"]);
u.env("UNIFIER_HOME", meta.unifier_home.as_ref().unwrap());
match u.status() {
Ok(st) if st.success() => {}
Ok(st) => eprintln!(
"jan lab: warning: `unifier daemon start` exited {st} (is unifier on PATH?)"
),
Err(e) => eprintln!("jan lab: warning: could not start unifier ({e})"),
}
}
Ok(meta)
}
fn print_human_card(meta: &LabMeta, cron_started: bool) {
eprintln!("jan lab `{}` ready", meta.id);
eprintln!(" tree: {}", meta.tree);
if let Some(r) = &meta.root {
eprintln!(" root: {r}");
}
eprintln!(" config: {}", meta.config_dir);
eprintln!(" runtime: {}", meta.runtime_dir);
if let Some(u) = &meta.unifier_home {
eprintln!(" unifier: {u}");
}
if cron_started {
eprintln!(" cron: started (no systemd)");
} else {
eprintln!(" cron: not started (pass --start, or run `jan cron start` after eval)");
}
eprintln!();
eprintln!("In this shell (exports are on stdout for eval):");
eprintln!(" jan cron --list");
eprintln!(" jan list --format names");
eprintln!(" jan lab stop");
}
fn list_labs() -> Result<i32> {
let root = labs_root();
if !root.is_dir() {
println!("(no labs)");
return Ok(0);
}
let mut ids: Vec<String> = fs::read_dir(&root)
.with_context(|| format!("read {}", root.display()))?
.filter_map(|e| e.ok())
.filter(|e| e.path().join(META_FILE).is_file())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
ids.sort();
if ids.is_empty() {
println!("(no labs)");
return Ok(0);
}
let current = resolve_lab_id(None).ok();
for id in &ids {
let mark = if current.as_deref() == Some(id.as_str()) {
"*"
} else {
" "
};
match read_meta_at(&lab_dir(id)) {
Ok(m) => println!("{mark} {id}\t{}", m.tree),
Err(_) => println!("{mark} {id}\t(unreadable meta)"),
}
}
Ok(0)
}
fn clean_labs() -> Result<i32> {
let root = labs_root();
if !root.is_dir() {
println!("(no labs)");
return Ok(0);
}
let mut n = 0usize;
for e in fs::read_dir(&root)? {
let e = e?;
let path = e.path();
if path.join(META_FILE).is_file() {
if let Ok(meta) = read_meta_at(&path) {
stop_cron_in_lab(&meta)?;
}
fs::remove_dir_all(&path)
.with_context(|| format!("remove {}", path.display()))?;
n += 1;
}
}
let _ = fs::remove_file(current_path());
println!("cleaned {n} lab(s) under {}", root.display());
Ok(0)
}
fn exec_shell(meta: &LabMeta) -> Result<i32> {
let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let mut cmd = Command::new(&shell);
cmd.env("JAN_CONFIG_DIR", &meta.config_dir);
cmd.env("XDG_RUNTIME_DIR", &meta.runtime_dir);
if let Some(u) = &meta.unifier_home {
cmd.env("UNIFIER_HOME", u);
}
cmd.env("JAN_LAB_ID", &meta.id);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
eprintln!(
"jan lab: entering {} (JAN_LAB_ID={}; type `exit` when done)",
shell, meta.id
);
let status = cmd.status().with_context(|| format!("exec {shell}"))?;
Ok(status.code().unwrap_or(1))
}
pub fn run_lab(args: &[std::ffi::OsString]) -> Result<i32> {
if args.is_empty() {
print_lab_help();
return Ok(0);
}
let first = args[0].to_string_lossy();
match first.as_ref() {
"--help" | "-h" => {
print_lab_help();
return Ok(0);
}
"env" => {
let id = args.get(1).map(|s| s.to_string_lossy().into_owned());
let meta = load_meta(id.as_deref())?;
print!("{}", exports_for(&meta));
return Ok(0);
}
"status" => {
let id = args.get(1).map(|s| s.to_string_lossy().into_owned());
let meta = load_meta(id.as_deref())?;
println!("id: {}", meta.id);
println!("tree: {}", meta.tree);
if let Some(r) = &meta.root {
println!("root: {r}");
}
println!("config: {}", meta.config_dir);
println!("runtime: {}", meta.runtime_dir);
if let Some(u) = &meta.unifier_home {
println!("unifier: {u}");
}
let _ = run_jan_in_lab(&meta, &["cron", "status"]);
return Ok(0);
}
"stop" => {
let id = args.get(1).map(|s| s.to_string_lossy().into_owned());
let meta = load_meta(id.as_deref())?;
stop_cron_in_lab(&meta)?;
println!("stopped lab `{}`", meta.id);
return Ok(0);
}
"ls" | "list" => return list_labs(),
"clean" => return clean_labs(),
other if other.starts_with('-') => {
if other == "--start" || other == "--shell" || other == "--with-unifier" {
print_lab_help();
bail!("missing directory (try `jan lab <DIR> --start`)");
}
bail!("unknown lab flag `{other}` (try `jan lab --help`)");
}
_ => {}
}
let mut dir: Option<PathBuf> = None;
let mut root: Option<String> = None;
let mut name: Option<String> = None;
let mut with_unifier = false;
let mut start = false;
let mut shell = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_lab_help();
return Ok(0);
}
"--with-unifier" => with_unifier = true,
"--start" => start = true,
"--shell" => shell = 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;
}
"--name" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value after `--name`"))?;
name = Some(next.to_string_lossy().into_owned());
i += 1;
}
other if other.starts_with('-') => {
bail!("unknown lab flag `{other}` (try `jan lab --help`)");
}
_ => {
if dir.is_some() {
bail!("unexpected argument `{s}` (try `jan lab --help`)");
}
dir = Some(PathBuf::from(s.as_ref()));
}
}
i += 1;
}
let Some(dir) = dir else {
print_lab_help();
bail!("missing directory (try `jan lab <DIR>`)");
};
let meta = create_lab(&dir, root.as_deref(), name.as_deref(), with_unifier)?;
if start {
start_cron_in_lab(&meta)?;
}
if shell {
print_human_card(&meta, start);
return exec_shell(&meta);
}
print_human_card(&meta, start);
print!("{}", exports_for(&meta));
Ok(0)
}