Skip to main content

speccoder_runtime/extensions/
add.rs

1//! `speccoder extensions add` and `remove`: an extension's folder copied
2//! into the project, or deleted from it. Adding runs nothing of the
3//! extension — no repository hooks, no submodules, no install scripts —
4//! and a repository is taken only at a named commit.
5
6use std::path::{Path, PathBuf};
7
8use super::{DIR, Loaded};
9
10/// Copies the extension at `source` — a folder, or `URL@<commit>` — into
11/// the project at `root`, and returns it as loaded.
12///
13/// # Errors
14///
15/// The source cannot be read or fetched, is not loadable (the problems
16/// named), or its name is taken.
17pub fn add(root: &Path, source: &str) -> Result<Loaded, String> {
18    let project = super::project(root);
19    let fetched;
20    let (from, origin) = if Path::new(source).is_dir() {
21        (PathBuf::from(source), None)
22    } else {
23        if source.starts_with('-') {
24            return Err(format!("`{source}` is not a folder or a repository URL"));
25        }
26        let (url, commit) = source.rsplit_once('@').ok_or_else(|| {
27            format!("{source} is neither a folder nor URL@<commit> (a full 40-character commit)")
28        })?;
29        if commit.len() != 40 || !commit.bytes().all(|b| b.is_ascii_hexdigit()) {
30            return Err(format!(
31                "`{commit}` is not a full commit: give URL@<40 hex characters>, so what you trust is what was fetched"
32            ));
33        }
34        fetched = fetch(url, commit)?;
35        (
36            fetched.path().join("tree"),
37            Some((url.to_owned(), commit.to_owned())),
38        )
39    };
40    // Bounded before anything is copied: files only, within the limits.
41    let mut bytes: u64 = 0;
42    let listed = super::walk(&from, true)?;
43    if listed.len() > speccoder_core::extensions::MAX_FILES {
44        return Err(format!(
45            "more than {} files: an extension holds at most that many",
46            speccoder_core::extensions::MAX_FILES
47        ));
48    }
49    for rel in &listed {
50        let meta = std::fs::symlink_metadata(from.join(rel)).map_err(|e| format!("{rel}: {e}"))?;
51        if !meta.file_type().is_file() {
52            return Err(format!("{rel} is not a regular file"));
53        }
54        bytes += meta.len();
55        if bytes > speccoder_core::extensions::MAX_BYTES {
56            return Err("more than 20 MB: an extension holds at most that much".to_owned());
57        }
58    }
59    let staged = tempfile::tempdir().map_err(|e| e.to_string())?;
60    let name_dir = staged.path().join("x");
61    copy(&from, &name_dir)?;
62    // Where it came from is speccoder's to say, never the folder's own claim.
63    let _ = std::fs::remove_file(name_dir.join(super::NAMESPACE).join("source.toml"));
64    if let Some((url, commit)) = &origin {
65        let part = name_dir.join(super::NAMESPACE);
66        std::fs::create_dir_all(&part).map_err(|e| e.to_string())?;
67        let text = format!(
68            "# Where `speccoder extensions add` took this extension from.\nurl = {url:?}\ncommit = {commit:?}\nadded = {:?}\n",
69            crate::attest::now_rfc3339()
70        );
71        std::fs::write(part.join("source.toml"), text).map_err(|e| e.to_string())?;
72    }
73    // The name is plugin.json's: read it before choosing the folder.
74    let name = serde_json::from_str::<serde_json::Value>(
75        &std::fs::read_to_string(name_dir.join("plugin.json")).map_err(|_| {
76            format!("{source} has no plugin.json: an extension is an Agent Plugins 1.0 folder")
77        })?,
78    )
79    .ok()
80    .and_then(|v| v["name"].as_str().map(str::to_owned))
81    .ok_or_else(|| format!("{source}: plugin.json names no extension"))?;
82    if !speccoder_core::extensions::valid_name(&name) {
83        return Err(format!("`{name}` is not a valid extension name"));
84    }
85    let target = project.join(DIR).join(&name);
86    if target.exists() || super::load(&project).iter().any(|e| e.name == name) {
87        return Err(format!(
88            "`{name}` is already here: `speccoder extensions remove {name}` first"
89        ));
90    }
91    std::fs::create_dir_all(project.join(DIR)).map_err(|e| e.to_string())?;
92    let renamed = staged.path().join(&name);
93    std::fs::rename(&name_dir, &renamed).map_err(|e| e.to_string())?;
94    let loaded = super::one(
95        &project,
96        &renamed,
97        speccoder_core::extensions::ExtensionSource::AgentPlugins,
98    );
99    if !loaded.usable() {
100        return Err(format!(
101            "{name} cannot be loaded: {}",
102            loaded.problems.join("; ")
103        ));
104    }
105    copy(&renamed, &target)?;
106    super::load(&project)
107        .into_iter()
108        .find(|e| e.name == name)
109        .ok_or_else(|| format!("{name} was copied but does not load"))
110}
111
112/// Deletes the extension `name` and its trust record.
113///
114/// # Errors
115///
116/// No such extension, or it is Spec Kit's (Spec Kit removes those).
117pub fn remove(root: &Path, state: &Path, name: &str) -> Result<(), String> {
118    let all = super::load(root);
119    let Some(e) = all.iter().find(|e| e.name == name) else {
120        return Err(format!("there is no extension `{name}`"));
121    };
122    if e.source == speccoder_core::extensions::ExtensionSource::SpecKit {
123        return Err(format!(
124            "{name} is a Spec Kit extension: `specify extension remove {name}` removes it"
125        ));
126    }
127    std::fs::remove_dir_all(&e.folder).map_err(|err| format!("{}: {err}", e.rel))?;
128    crate::hooks::trust::forget(&crate::hooks::dir(state, root), &super::trust::key(name));
129    Ok(())
130}
131
132/// `url` at `commit`, checked out in a temporary folder (`tree/`), with no
133/// hook of the repository and no submodule run.
134fn fetch(url: &str, commit: &str) -> Result<tempfile::TempDir, String> {
135    let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
136    let tree = tmp.path().join("tree");
137    let no_hooks = tmp.path().join("no-hooks");
138    std::fs::create_dir_all(&no_hooks).map_err(|e| e.to_string())?;
139    let local = !url.contains("://") && !url.contains('@');
140    let git = |args: &[&str]| -> Result<(), String> {
141        let mut c = std::process::Command::new("git");
142        c.arg("-c")
143            .arg(format!("core.hooksPath={}", no_hooks.display()))
144            .args(["-c", "submodule.recurse=false"]);
145        if !local {
146            c.args(["-c", "protocol.file.allow=never"]);
147        }
148        let out = c
149            .args(args)
150            .current_dir(&tree)
151            .env("GIT_CONFIG_NOSYSTEM", "1")
152            .env("GIT_TERMINAL_PROMPT", "0")
153            .output()
154            .map_err(|e| {
155                format!("git is needed to add from a repository ({e}); add a folder instead")
156            })?;
157        if out.status.success() {
158            Ok(())
159        } else {
160            Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
161        }
162    };
163    std::fs::create_dir_all(&tree).map_err(|e| e.to_string())?;
164    git(&["init", "--quiet"])?;
165    git(&["fetch", "--quiet", "--depth", "1", "--", url, commit])
166        .map_err(|e| format!("could not fetch {commit} from {url}: {e}"))?;
167    git(&["checkout", "--quiet", "FETCH_HEAD"])?;
168    Ok(tmp)
169}
170
171/// Copies regular files and folders from `from` to `to`, leaving `.git`
172/// out and refusing links.
173fn copy(from: &Path, to: &Path) -> Result<(), String> {
174    std::fs::create_dir_all(to).map_err(|e| e.to_string())?;
175    for entry in std::fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? {
176        let entry = entry.map_err(|e| e.to_string())?;
177        let path = entry.path();
178        if path.file_name().is_some_and(|n| n == ".git") {
179            continue;
180        }
181        let kind = entry.file_type().map_err(|e| e.to_string())?;
182        let dest = to.join(entry.file_name());
183        if kind.is_symlink() {
184            return Err(format!(
185                "{} is a link: an extension holds files only",
186                path.display()
187            ));
188        } else if kind.is_dir() {
189            copy(&path, &dest)?;
190        } else {
191            std::fs::copy(&path, &dest).map_err(|e| format!("{}: {e}", path.display()))?;
192        }
193    }
194    Ok(())
195}