Skip to main content

solid_pod_rs_server/cli/
install.rs

1//! `install` operator subcommand — clone a Solid app and push it into a
2//! pod over the git smart protocol, authenticated with a single NIP-98
3//! token.
4//!
5//! Mirrors JSS `src/cli/install.js`. The app-spec grammar, the GitHub
6//! shorthands, the `#ref` pin / `=rename` suffixes, and the dual
7//! `HEAD:main` + `HEAD:gh-pages` push all match JSS. Authentication uses
8//! the pod's own NIP-98 scheme: a token minted by
9//! [`solid_pod_rs::auth::nip98::mint`] over the destination repo URL with
10//! method `*`, injected via git's `http.extraHeader` so it covers every
11//! request of the multi-step smart protocol (the server accepts this with
12//! [`MatchPolicy::GitLenient`](solid_pod_rs::auth::nip98::MatchPolicy)).
13//!
14//! NIP-98 minting requires the `install` cargo feature
15//! (`solid-pod-rs/nip98-schnorr`); without it the `--nostr-privkey` path
16//! returns an actionable error and only `--token` (bearer) remains.
17
18use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use clap::Args;
23
24/// Arguments for `install`.
25#[derive(Debug, Args, Clone)]
26pub struct InstallArgs {
27    /// One or more app specs. Each is a bare app name
28    /// (`https://github.com/solid-apps/<name>`), an `org/repo`
29    /// shorthand (`https://github.com/org/repo`), or a full git URL.
30    /// Append `#<ref>` to pin a branch/tag/commit and `=<dest>` to
31    /// rename the pod directory. Example:
32    /// `solid-apps/profile#v2=me-profile`.
33    #[arg(required = true)]
34    pub apps: Vec<String>,
35
36    /// Target pod base URL.
37    #[arg(long, env = "JSS_POD", default_value = "http://localhost:4443")]
38    pub pod: String,
39
40    /// 64-hex Nostr secret key used to mint NIP-98 push tokens.
41    /// Required unless `--token` is supplied.
42    #[arg(long, env = "NOSTR_PRIVKEY")]
43    pub nostr_privkey: Option<String>,
44
45    /// Bearer token alternative to NIP-98 (for IdP-authenticated,
46    /// non-Nostr deployments).
47    #[arg(long, env = "JSS_BEARER_TOKEN")]
48    pub token: Option<String>,
49
50    /// Destination branches that `HEAD` is pushed to. JSS publishes both
51    /// `main` (data) and `gh-pages` (static hosting).
52    #[arg(long, value_delimiter = ',', default_value = "main,gh-pages")]
53    pub branches: Vec<String>,
54
55    /// Resolve and clone, print the push plan, but do not push.
56    #[arg(long)]
57    pub dry_run: bool,
58}
59
60/// A parsed app spec: the resolved git source, the pod destination
61/// directory, and an optional git ref to check out before pushing.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AppSpec {
64    /// Fully-resolved clone source (a git URL).
65    pub source_url: String,
66    /// Destination directory name under the pod root.
67    pub dest: String,
68    /// Optional branch / tag / commit to clone.
69    pub git_ref: Option<String>,
70}
71
72/// Resolve a raw app spec into an [`AppSpec`]. Mirrors JSS `parseAppSpec`.
73///
74/// Grammar (suffixes are optional and order-independent here, parsed
75/// rename-first then ref): `<source>[#<ref>][=<dest>]`.
76///
77/// `<source>` resolution:
78/// - full URL (`http(s)://`, `git@`, `ssh://`) → used verbatim
79/// - `org/repo` → `https://github.com/org/repo`
80/// - bare `name` → `https://github.com/solid-apps/name`
81pub fn parse_app_spec(spec: &str) -> anyhow::Result<AppSpec> {
82    let (left, rename) = match spec.split_once('=') {
83        Some((l, r)) if !r.is_empty() => (l, Some(r.to_string())),
84        _ => (spec, None),
85    };
86    let (source, git_ref) = match left.split_once('#') {
87        Some((s, r)) if !r.is_empty() => (s, Some(r.to_string())),
88        _ => (left, None),
89    };
90    let source = source.trim();
91    if source.is_empty() {
92        anyhow::bail!("empty app spec: {spec:?}");
93    }
94
95    let source_url = if source.starts_with("http://")
96        || source.starts_with("https://")
97        || source.starts_with("git@")
98        || source.starts_with("ssh://")
99    {
100        source.to_string()
101    } else if source.contains('/') {
102        format!("https://github.com/{source}")
103    } else {
104        format!("https://github.com/solid-apps/{source}")
105    };
106
107    let dest = match rename {
108        Some(d) => d,
109        None => source_url
110            .trim_end_matches('/')
111            .rsplit('/')
112            .next()
113            .unwrap_or("app")
114            .trim_end_matches(".git")
115            .to_string(),
116    };
117    if dest.is_empty() {
118        anyhow::bail!("could not derive destination name from {spec:?}");
119    }
120
121    Ok(AppSpec {
122        source_url,
123        dest,
124        git_ref,
125    })
126}
127
128/// Build the `Authorization` header value injected via
129/// `http.extraHeader`. Prefers NIP-98 (minted from `--nostr-privkey`),
130/// falling back to a bearer `--token`.
131fn build_auth_header(dest_url: &str, args: &InstallArgs) -> anyhow::Result<String> {
132    if let Some(privkey) = args.nostr_privkey.as_deref() {
133        let now = SystemTime::now()
134            .duration_since(UNIX_EPOCH)
135            .map(|d| d.as_secs())
136            .unwrap_or(0);
137        // Method `*` + repo base URL: one token, every smart-protocol
138        // request. The server verifies it under MatchPolicy::GitLenient.
139        let token = solid_pod_rs::auth::nip98::mint(dest_url, "*", privkey, now).map_err(|e| {
140            anyhow::anyhow!(
141                "mint NIP-98 token: {e}. Rebuild the binary with \
142                 `--features solid-pod-rs-server/install` to enable signing."
143            )
144        })?;
145        Ok(format!("Nostr {token}"))
146    } else if let Some(bearer) = args.token.as_deref() {
147        Ok(format!("Bearer {bearer}"))
148    } else {
149        anyhow::bail!("install needs either --nostr-privkey or --token")
150    }
151}
152
153fn run_git(cmd: &mut Command) -> anyhow::Result<()> {
154    let status = cmd
155        .status()
156        .map_err(|e| anyhow::anyhow!("spawn git: {e} (is `git` on PATH?)"))?;
157    if !status.success() {
158        anyhow::bail!("git exited with {status}");
159    }
160    Ok(())
161}
162
163fn temp_checkout_dir(dest: &str) -> PathBuf {
164    let unique = format!(
165        "solid-install-{}-{}-{dest}",
166        std::process::id(),
167        SystemTime::now()
168            .duration_since(UNIX_EPOCH)
169            .map(|d| d.as_nanos())
170            .unwrap_or(0)
171    );
172    std::env::temp_dir().join(unique)
173}
174
175/// Clone one app and push it to the pod. Cleans up the temp checkout on
176/// both success and failure.
177fn install_one(spec: &AppSpec, args: &InstallArgs) -> anyhow::Result<()> {
178    let pod = args.pod.trim_end_matches('/');
179    let dest_url = format!("{pod}/{}", spec.dest);
180    let workdir = temp_checkout_dir(&spec.dest);
181
182    let result = install_into(spec, args, &dest_url, &workdir);
183    // Best-effort cleanup regardless of outcome.
184    let _ = std::fs::remove_dir_all(&workdir);
185    result
186}
187
188fn install_into(
189    spec: &AppSpec,
190    args: &InstallArgs,
191    dest_url: &str,
192    workdir: &Path,
193) -> anyhow::Result<()> {
194    println!("→ {} → {dest_url}", spec.source_url);
195
196    let mut clone = Command::new("git");
197    clone.arg("clone").arg("--quiet");
198    if let Some(git_ref) = &spec.git_ref {
199        clone.arg("--branch").arg(git_ref);
200    }
201    clone.arg(&spec.source_url).arg(workdir);
202    run_git(&mut clone)?;
203
204    let header = build_auth_header(dest_url, args)?;
205
206    let mut push = Command::new("git");
207    push.arg("-C").arg(workdir);
208    push.arg("-c")
209        .arg(format!("http.extraHeader=Authorization: {header}"));
210    push.arg("push").arg("--quiet").arg(dest_url);
211    for branch in &args.branches {
212        push.arg(format!("HEAD:refs/heads/{branch}"));
213    }
214
215    if args.dry_run {
216        let scheme = header.split_whitespace().next().unwrap_or("");
217        println!(
218            "  dry-run: would push HEAD to [{}] on {dest_url} (auth: {scheme})",
219            args.branches.join(", ")
220        );
221        return Ok(());
222    }
223
224    run_git(&mut push)?;
225    println!("  pushed HEAD to [{}]", args.branches.join(", "));
226    Ok(())
227}
228
229/// Run `install` for every spec in `args.apps`. Validates that at least
230/// one auth method is present, then clones+pushes each app in order.
231pub async fn run_install(args: &InstallArgs) -> anyhow::Result<()> {
232    if args.nostr_privkey.is_none() && args.token.is_none() {
233        anyhow::bail!(
234            "install requires authentication: pass --nostr-privkey (NIP-98, env NOSTR_PRIVKEY) \
235             or --token (bearer, env JSS_BEARER_TOKEN)"
236        );
237    }
238    for raw in &args.apps {
239        let spec = parse_app_spec(raw)?;
240        install_one(&spec, args)?;
241    }
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn bare_name_resolves_to_solid_apps_org() {
251        let s = parse_app_spec("profile").unwrap();
252        assert_eq!(s.source_url, "https://github.com/solid-apps/profile");
253        assert_eq!(s.dest, "profile");
254        assert_eq!(s.git_ref, None);
255    }
256
257    #[test]
258    fn org_repo_resolves_to_github() {
259        let s = parse_app_spec("acme/widgets").unwrap();
260        assert_eq!(s.source_url, "https://github.com/acme/widgets");
261        assert_eq!(s.dest, "widgets");
262    }
263
264    #[test]
265    fn full_url_is_verbatim_and_strips_dot_git() {
266        let s = parse_app_spec("https://example.com/x/cool-app.git").unwrap();
267        assert_eq!(s.source_url, "https://example.com/x/cool-app.git");
268        assert_eq!(s.dest, "cool-app");
269    }
270
271    #[test]
272    fn ref_pin_and_rename_parse() {
273        let s = parse_app_spec("solid-apps/profile#v2=me-profile").unwrap();
274        assert_eq!(s.source_url, "https://github.com/solid-apps/profile");
275        assert_eq!(s.git_ref.as_deref(), Some("v2"));
276        assert_eq!(s.dest, "me-profile");
277    }
278
279    #[test]
280    fn ref_only_keeps_default_dest() {
281        let s = parse_app_spec("widgets#main").unwrap();
282        assert_eq!(s.source_url, "https://github.com/solid-apps/widgets");
283        assert_eq!(s.git_ref.as_deref(), Some("main"));
284        assert_eq!(s.dest, "widgets");
285    }
286
287    #[test]
288    fn empty_spec_errors() {
289        assert!(parse_app_spec("").is_err());
290        assert!(parse_app_spec("#ref").is_err());
291    }
292}