use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use kanade_shared::manifest::{JobOrigin, Manifest};
use tracing::{info, warn};
#[derive(Args, Debug)]
pub struct JobArgs {
#[command(subcommand)]
pub sub: JobSub,
}
#[derive(Subcommand, Debug)]
pub enum JobSub {
Create {
yaml: PathBuf,
},
List,
Delete { id: String },
}
pub async fn execute(backend_url: &str, args: JobArgs) -> Result<()> {
let base = backend_url.trim_end_matches('/');
match args.sub {
JobSub::Create { yaml } => create(base, &yaml).await,
JobSub::List => list(base).await,
JobSub::Delete { id } => delete(base, &id).await,
}
}
async fn create(base: &str, yaml: &PathBuf) -> Result<()> {
let raw = std::fs::read_to_string(yaml).with_context(|| format!("read {yaml:?}"))?;
let mut job: Manifest = kanade_shared::strict::from_yaml_str(&raw)
.map_err(|e| anyhow::anyhow!("parse {yaml:?}: {e}"))?;
if let Err(e) = job.validate() {
anyhow::bail!("{yaml:?}: {e}");
}
let origin = detect_repo_origin(yaml, job.execute.script_file.as_deref());
let (body, sent_raw) = if let Some(path) = job.execute.script_file.as_deref() {
let file_path = resolve_script_file_path(yaml, path);
let script_body = std::fs::read_to_string(&file_path).with_context(|| {
format!(
"read script_file {} (referenced from {yaml:?})",
file_path.display(),
)
})?;
info!(
script_file = %file_path.display(),
size = script_body.len(),
"inlined script_file into execute.script",
);
job.execute.script = Some(script_body);
job.execute.script_file = None;
job.origin = origin;
let serialized = manifest_to_block_scalar_yaml(&job)
.context("re-serialize manifest after script_file inlining")?;
(serialized, false)
} else {
let mut out = raw.clone();
if let Some(o) = &origin {
if has_top_level_origin(&out) {
warn!(
job_id = %job.id,
"origin: already present in source YAML; preserving it. \
If the repo / remote changed, delete + recreate the job \
to refresh provenance",
);
} else {
append_origin_yaml(&mut out, o).context("append origin provenance")?;
}
}
(out, origin.is_none())
};
info!(
job_id = %job.id,
version = %job.version,
sent_raw_yaml = sent_raw,
"upserting job",
);
let url = format!("{base}/api/jobs");
let resp = crate::http_client::authed_client()?
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/yaml")
.body(body)
.send()
.await
.with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("create rejected: {status} — {body}");
}
let payload: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
fn resolve_script_file_path(yaml: &std::path::Path, script_file: &str) -> PathBuf {
let p = PathBuf::from(script_file);
if p.is_absolute() {
return p;
}
match yaml.parent() {
Some(parent) => parent.join(p),
None => p,
}
}
fn manifest_to_block_scalar_yaml(job: &Manifest) -> Result<String> {
const SENTINEL: &str = "__KANADE_SCRIPT_BLOCK_SENTINEL__";
let body = job
.execute
.script
.clone()
.unwrap_or_default()
.replace("\r\n", "\n");
let mut stub = job.clone();
stub.execute.script = Some(SENTINEL.to_string());
let serialized = serde_yaml::to_string(&stub).context("serialize manifest")?;
let mut out = String::with_capacity(serialized.len() + body.len());
let mut spliced = false;
for line in serialized.lines() {
if !spliced && line.contains(SENTINEL) {
let indent: String = line.chars().take_while(|c| *c == ' ').collect();
let (chomp, content) = match body.strip_suffix('\n') {
Some(stripped) => ("|", stripped),
None => ("|-", body.as_str()),
};
out.push_str(&indent);
out.push_str("script: ");
out.push_str(chomp);
out.push('\n');
let body_indent = format!("{indent} ");
for bl in content.split('\n') {
if bl.is_empty() {
out.push('\n');
} else {
out.push_str(&body_indent);
out.push_str(bl);
out.push('\n');
}
}
spliced = true;
} else {
out.push_str(line);
out.push('\n');
}
}
if !spliced {
anyhow::bail!("script sentinel vanished during YAML serialization");
}
Ok(out)
}
fn append_origin_yaml(yaml: &mut String, origin: &JobOrigin) -> Result<()> {
#[derive(serde::Serialize)]
struct Wrap<'a> {
origin: &'a JobOrigin,
}
let block = serde_yaml::to_string(&Wrap { origin }).context("serialize origin")?;
if !yaml.ends_with('\n') {
yaml.push('\n');
}
yaml.push_str(&block);
Ok(())
}
fn has_top_level_origin(yaml: &str) -> bool {
yaml.lines().any(|l| {
!l.starts_with(char::is_whitespace) && l.split(':').next().map(str::trim) == Some("origin")
})
}
fn detect_repo_origin(yaml: &std::path::Path, script_file: Option<&str>) -> Option<JobOrigin> {
let dir = yaml
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let (toplevel, git_backed) = match vcs_output(&dir, "git", &["rev-parse", "--show-toplevel"]) {
Some(t) => (t, true),
None => (vcs_output(&dir, "jj", &["root"])?, false),
};
let toplevel = PathBuf::from(toplevel.trim());
let path = repo_relative(&toplevel, yaml)?;
let repo = git_backed
.then(|| vcs_output(&dir, "git", &["remote", "get-url", "origin"]))
.flatten()
.and_then(|s| sanitize_repo_remote(&s));
let script_file = script_file
.map(|sf| resolve_script_file_path(yaml, sf))
.and_then(|sf| repo_relative(&toplevel, &sf));
Some(JobOrigin {
path,
repo,
script_file,
})
}
fn sanitize_repo_remote(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(mut url) = reqwest::Url::parse(trimmed) {
let _ = url.set_username("");
let _ = url.set_password(None);
return Some(url.to_string());
}
Some(trimmed.to_string())
}
fn vcs_output(dir: &std::path::Path, prog: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(prog)
.current_dir(dir)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn repo_relative(toplevel: &std::path::Path, file: &std::path::Path) -> Option<String> {
let top = toplevel.canonicalize().ok()?;
let abs = file.canonicalize().ok()?;
let rel = abs.strip_prefix(&top).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
async fn list(base: &str) -> Result<()> {
let url = format!("{base}/api/jobs");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
anyhow::bail!("list failed: {}", resp.status());
}
let payload: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
async fn delete(base: &str, id: &str) -> Result<()> {
let url = format!("{base}/api/jobs/{id}");
let resp = crate::http_client::authed_client()?
.delete(&url)
.send()
.await
.with_context(|| format!("DELETE {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("delete failed: {status} — {body}");
}
println!("deleted: {id}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_script_file_resolves_under_yaml_parent() {
let yaml = std::path::Path::new("/repo/jobs/cleanup.yaml");
assert_eq!(
resolve_script_file_path(yaml, "scripts/cleanup.ps1"),
std::path::PathBuf::from("/repo/jobs/scripts/cleanup.ps1"),
);
}
#[test]
fn absolute_script_file_passes_through_unchanged() {
let yaml = std::path::Path::new("/repo/jobs/cleanup.yaml");
let abs = if cfg!(windows) {
"C:/shared/templates/cleanup.ps1"
} else {
"/shared/templates/cleanup.ps1"
};
assert_eq!(
resolve_script_file_path(yaml, abs),
std::path::PathBuf::from(abs),
);
}
#[test]
fn manifest_with_both_script_and_script_file_fails_validation() {
let yaml = r#"
id: ambiguous
version: 1.0.0
execute:
shell: powershell
script: "echo inline"
script_file: scripts/cleanup.ps1
timeout: 30s
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().expect_err("validate should reject");
assert!(
err.contains("only one of"),
"expected exclusivity error, got: {err}",
);
}
#[test]
fn bare_yaml_filename_keeps_script_file_relative_to_cwd() {
let yaml = std::path::Path::new("manifest.yaml");
assert_eq!(
resolve_script_file_path(yaml, "script.ps1"),
std::path::PathBuf::from("script.ps1"),
);
}
fn inline_manifest() -> Manifest {
serde_yaml::from_str(
"id: j\nversion: 1.0.0\nexecute:\n shell: powershell\n script: \"x\"\n timeout: 30s\n",
)
.expect("parse base manifest")
}
#[test]
fn block_scalar_yaml_roundtrips_multiline_script() {
let mut m = inline_manifest();
let script = "#requires -Version 5.1\nWrite-Output 'hi'\n\nGet-Date\n";
m.execute.script = Some(script.to_string());
m.origin = Some(JobOrigin {
path: "configs/jobs/j.yaml".into(),
repo: Some("git@github.com:o/r.git".into()),
script_file: Some("configs/jobs/scripts/j.ps1".into()),
});
let yaml = manifest_to_block_scalar_yaml(&m).expect("serialize");
assert!(
yaml.contains("script: |"),
"expected a block scalar, got:\n{yaml}"
);
assert!(
!yaml.contains("script: \""),
"script must be a block scalar, not a quoted blob:\n{yaml}"
);
let back: Manifest = serde_yaml::from_str(&yaml).expect("re-parse");
assert_eq!(back.execute.script.as_deref(), Some(script));
assert_eq!(
back.origin.as_ref().map(|o| o.path.as_str()),
Some("configs/jobs/j.yaml"),
);
back.validate().expect("spliced manifest still validates");
}
#[test]
fn block_scalar_yaml_preserves_no_trailing_newline() {
let mut m = inline_manifest();
let script = "line1\nline2";
m.execute.script = Some(script.to_string());
let yaml = manifest_to_block_scalar_yaml(&m).expect("serialize");
assert!(
yaml.contains("script: |-"),
"expected strip-chomp block, got:\n{yaml}"
);
let back: Manifest = serde_yaml::from_str(&yaml).expect("re-parse");
assert_eq!(back.execute.script.as_deref(), Some(script));
}
#[test]
fn detects_top_level_origin_key() {
assert!(has_top_level_origin("id: j\norigin:\n path: x\n"));
assert!(has_top_level_origin("origin: {}\n"));
assert!(!has_top_level_origin("execute:\n origin: nope\n"));
assert!(!has_top_level_origin("id: j\nversion: 1.0.0\n"));
}
#[test]
fn detect_repo_origin_resolves_in_repo_checkout() {
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
if let Some(origin) = detect_repo_origin(&here, None) {
assert!(
origin.path.ends_with("crates/kanade/Cargo.toml"),
"unexpected repo-relative path: {}",
origin.path,
);
}
}
#[test]
fn install_kanade_client_yaml_renders_clean() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("crates/kanade has a repo root two levels up");
let yaml_path = root.join("configs/jobs/installers/install-kanade-client.yaml");
let ps1_path = root.join("configs/jobs/installers/scripts/install-kanade-client.ps1");
if !yaml_path.exists() || !ps1_path.exists() {
return;
}
let raw = std::fs::read_to_string(&yaml_path).expect("read manifest");
let mut job: Manifest = kanade_shared::strict::from_yaml_str(&raw).expect("parse manifest");
let body = std::fs::read_to_string(&ps1_path).expect("read script");
job.execute.script = Some(body.clone());
job.execute.script_file = None;
let out = manifest_to_block_scalar_yaml(&job).expect("serialize");
assert!(out.contains("script: |"), "expected block scalar:\n{out}");
assert!(
!out.contains("script: \""),
"script must be a block scalar, not a quoted blob:\n{out}"
);
let back: Manifest = serde_yaml::from_str(&out).expect("re-parse");
assert_eq!(
back.execute.script.as_deref(),
Some(body.replace("\r\n", "\n").as_str()),
);
back.validate()
.expect("round-tripped manifest still validates");
}
#[test]
fn appends_parseable_origin_block() {
let mut yaml = String::from(
"id: j\nversion: 1.0.0\nexecute:\n shell: powershell\n script: |\n echo hi\n timeout: 30s\n",
);
append_origin_yaml(
&mut yaml,
&JobOrigin {
path: "configs/jobs/j.yaml".into(),
repo: Some("https://github.com/o/r".into()),
script_file: None,
},
)
.expect("append");
assert!(has_top_level_origin(&yaml));
let m: Manifest = serde_yaml::from_str(&yaml).expect("parse appended");
assert_eq!(
m.origin.expect("origin present").path,
"configs/jobs/j.yaml"
);
}
#[test]
fn sanitize_repo_remote_strips_credentials() {
assert_eq!(
sanitize_repo_remote("https://ghp_secret@github.com/o/r.git").as_deref(),
Some("https://github.com/o/r.git"),
);
assert_eq!(
sanitize_repo_remote("https://user:pass@example.com/o/r").as_deref(),
Some("https://example.com/o/r"),
);
assert_eq!(
sanitize_repo_remote("git@github.com:o/r.git").as_deref(),
Some("git@github.com:o/r.git"),
);
assert_eq!(
sanitize_repo_remote("https://github.com/o/r").as_deref(),
Some("https://github.com/o/r"),
);
assert_eq!(sanitize_repo_remote(" ").as_deref(), None);
}
}