use std::path::Path;
use anyhow::{anyhow, bail};
use crate::{paths, widget};
fn parse_source(s: &str) -> anyhow::Result<(String, String, Option<String>)> {
let parts: Vec<&str> = s.splitn(3, '/').collect();
match parts.as_slice() {
[owner, repo] => Ok((owner.to_string(), repo.to_string(), None)),
[owner, repo, name] => Ok((owner.to_string(), repo.to_string(), Some(name.to_string()))),
_ => bail!("invalid source '{s}': expected owner/repo or owner/repo/name"),
}
}
#[derive(Debug)]
struct RepoFile {
name: String,
path: String,
}
fn list_repo_widgets(owner: &str, repo: &str) -> anyhow::Result<Vec<RepoFile>> {
let entries =
try_list_dir_raw(owner, repo, "").or_else(|_| try_list_dir_raw(owner, repo, "plugins"))?;
let mut files: Vec<RepoFile> = entries
.iter()
.filter_map(|item: &serde_json::Value| {
let name = item.get("name")?.as_str()?.to_string();
let path = item.get("path")?.as_str()?.to_string();
let kind = item.get("type")?.as_str().unwrap_or("");
if kind != "file" {
return None;
}
if !name.ends_with(".sh") && !name.ends_with(".py") {
return None;
}
Some(RepoFile { name, path })
})
.collect();
if files.is_empty() {
for item in &entries {
let kind = item
.get("type")
.and_then(|v: &serde_json::Value| v.as_str())
.unwrap_or("");
if kind != "dir" {
continue;
}
let dirname = match item
.get("name")
.and_then(|v: &serde_json::Value| v.as_str())
{
Some(n) => n,
None => continue,
};
let ext = "sh";
files.push(RepoFile {
name: format!("{dirname}.{ext}"),
path: format!("{dirname}/{dirname}.{ext}"),
});
}
}
Ok(files)
}
fn try_list_dir_raw(
owner: &str,
repo: &str,
subdir: &str,
) -> anyhow::Result<Vec<serde_json::Value>> {
let url = if subdir.is_empty() {
format!("https://api.github.com/repos/{owner}/{repo}/contents")
} else {
format!("https://api.github.com/repos/{owner}/{repo}/contents/{subdir}")
};
let bytes = crate::http::curl_fetch(&url)?;
let arr: serde_json::Value = serde_json::from_slice(&bytes)?;
arr.as_array()
.ok_or_else(|| anyhow!("expected array from GitHub API"))
.cloned()
}
pub(crate) fn install_one_in(
dir: &Path,
name: &str,
ext: &str,
script: &[u8],
toml_opt: Option<Vec<u8>>,
force: bool,
) -> anyhow::Result<()> {
if name.contains('/') || name.contains('\\') || name.starts_with('.') || name.contains('\0') {
bail!("unsafe widget name: '{name}'");
}
std::fs::create_dir_all(dir)?;
let script_path = dir.join(format!("{name}.{ext}"));
let sh_path = dir.join(format!("{name}.sh"));
let py_path = dir.join(format!("{name}.py"));
let bare_path = dir.join(name);
let already_exists =
script_path.exists() || sh_path.exists() || py_path.exists() || bare_path.exists();
if already_exists && !force {
bail!("widget '{name}' already installed (use --force to overwrite)");
}
if already_exists && force {
widget::delete_widget_in(dir, name)?;
}
std::fs::write(&script_path, script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
}
let toml_path = dir.join(format!("{name}.toml"));
if let Some(toml_bytes) = toml_opt {
std::fs::write(&toml_path, toml_bytes)?;
} else {
std::fs::write(&toml_path, format!("description = \"Installed: {name}\"\n"))?;
}
Ok(())
}
pub(crate) fn raw_url(owner: &str, repo: &str, path: &str) -> String {
format!("https://raw.githubusercontent.com/{owner}/{repo}/main/{path}")
}
fn fetch_optional(url: &str) -> Option<Vec<u8>> {
crate::http::curl_fetch(url).ok().filter(|b| !b.is_empty())
}
pub fn run(source: &str, force: bool) -> anyhow::Result<()> {
if !source.contains('/') {
return crate::marketplace::resolve_and_install(source, force);
}
let (owner, repo, name_opt) = parse_source(source)?;
let widgets_dir = paths::widgets_dir();
match name_opt {
Some(name) => install_single(&owner, &repo, &name, &widgets_dir, force),
None => install_all(&owner, &repo, &widgets_dir, force),
}
}
fn install_single(
owner: &str,
repo: &str,
name: &str,
dir: &Path,
force: bool,
) -> anyhow::Result<()> {
let (script, ext, prefix) = if let Some(bytes) =
fetch_optional(&raw_url(owner, repo, &format!("{name}.sh")))
{
(bytes, "sh", "")
} else if let Some(bytes) = fetch_optional(&raw_url(owner, repo, &format!("{name}/{name}.sh")))
{
(bytes, "sh", name)
} else if let Some(bytes) = fetch_optional(&raw_url(owner, repo, &format!("{name}.py"))) {
(bytes, "py", "")
} else if let Some(bytes) = fetch_optional(&raw_url(owner, repo, &format!("{name}/{name}.py")))
{
(bytes, "py", name)
} else {
bail!("widget '{name}' not found in {owner}/{repo} (tried root and {name}/ subdir)");
};
let toml_path = if prefix.is_empty() {
format!("{name}.toml")
} else {
format!("{prefix}/{name}.toml")
};
let toml_opt = fetch_optional(&raw_url(owner, repo, &toml_path));
install_one_in(dir, name, ext, &script, toml_opt, force)?;
println!("installed {name}");
Ok(())
}
fn install_all(owner: &str, repo: &str, dir: &Path, force: bool) -> anyhow::Result<()> {
let files = list_repo_widgets(owner, repo)?;
if files.is_empty() {
bail!("no .sh/.py widgets found in {owner}/{repo}");
}
let mut installed = 0usize;
let mut errors = 0usize;
for file in &files {
let stem = Path::new(&file.name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&file.name)
.to_string();
let ext = Path::new(&file.name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("sh")
.to_string();
let script = match crate::http::curl_fetch(&raw_url(owner, repo, &file.path)) {
Ok(b) => b,
Err(e) => {
eprintln!("skip {stem}: {e}");
errors += 1;
continue;
}
};
let toml_path = {
let parent = Path::new(&file.path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("");
if parent.is_empty() {
format!("{stem}.toml")
} else {
format!("{parent}/{stem}.toml")
}
};
let toml_opt = fetch_optional(&raw_url(owner, repo, &toml_path));
match install_one_in(dir, &stem, &ext, &script, toml_opt, force) {
Ok(()) => {
println!("installed {stem}");
installed += 1;
}
Err(e) => {
eprintln!("skip {stem}: {e}");
errors += 1;
}
}
}
if installed == 0 {
bail!("no widgets were installed ({errors} errors)");
}
if errors > 0 {
println!("{installed} widget(s) installed, {errors} skipped");
} else {
println!("{installed} widget(s) installed");
}
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::{install_one_in, parse_source};
#[test]
fn parse_source_two_segments() {
let (owner, repo, name) = parse_source("alice/widgets").unwrap();
assert_eq!(owner, "alice");
assert_eq!(repo, "widgets");
assert!(name.is_none());
}
#[test]
fn parse_source_three_segments() {
let (owner, repo, name) = parse_source("alice/widgets/mywidget").unwrap();
assert_eq!(owner, "alice");
assert_eq!(repo, "widgets");
assert_eq!(name.as_deref(), Some("mywidget"));
}
#[test]
fn parse_source_invalid() {
assert!(parse_source("onlyone").is_err());
assert!(parse_source("").is_err());
}
#[test]
fn install_one_rejects_path_traversal() {
let dir = TempDir::new().unwrap();
assert!(
install_one_in(dir.path(), "../../.bashrc", "sh", b"#!/bin/sh", None, false)
.unwrap_err()
.to_string()
.contains("unsafe widget name")
);
assert!(
install_one_in(dir.path(), ".hidden", "sh", b"#!/bin/sh", None, false)
.unwrap_err()
.to_string()
.contains("unsafe widget name")
);
}
#[test]
fn install_one_writes_script_and_sidecar() {
let dir = TempDir::new().unwrap();
install_one_in(
dir.path(),
"mywidget",
"sh",
b"#!/bin/sh\necho hi",
None,
false,
)
.unwrap();
assert!(dir.path().join("mywidget.sh").exists());
assert!(dir.path().join("mywidget.toml").exists());
let content = std::fs::read_to_string(dir.path().join("mywidget.sh")).unwrap();
assert_eq!(content, "#!/bin/sh\necho hi");
}
#[test]
fn install_one_accepts_any_name() {
let dir = TempDir::new().unwrap();
install_one_in(dir.path(), "git", "sh", b"#!/bin/sh", None, false).unwrap();
assert!(dir.path().join("git.sh").exists());
}
#[test]
fn install_one_rejects_duplicate_without_force() {
let dir = TempDir::new().unwrap();
install_one_in(
dir.path(),
"mywidget",
"sh",
b"#!/bin/sh\necho v1",
None,
false,
)
.unwrap();
let err = install_one_in(
dir.path(),
"mywidget",
"sh",
b"#!/bin/sh\necho v2",
None,
false,
)
.unwrap_err();
assert!(err.to_string().contains("already installed"));
}
#[test]
fn install_one_overwrites_with_force() {
let dir = TempDir::new().unwrap();
install_one_in(
dir.path(),
"mywidget",
"sh",
b"#!/bin/sh\necho v1",
None,
false,
)
.unwrap();
install_one_in(
dir.path(),
"mywidget",
"sh",
b"#!/bin/sh\necho v2",
None,
true,
)
.unwrap();
let content = std::fs::read_to_string(dir.path().join("mywidget.sh")).unwrap();
assert_eq!(content, "#!/bin/sh\necho v2");
}
}