solid_pod_rs_server/cli/
install.rs1use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use clap::Args;
23
24#[derive(Debug, Args, Clone)]
26pub struct InstallArgs {
27 #[arg(required = true)]
34 pub apps: Vec<String>,
35
36 #[arg(long, env = "JSS_POD", default_value = "http://localhost:4443")]
38 pub pod: String,
39
40 #[arg(long, env = "NOSTR_PRIVKEY")]
43 pub nostr_privkey: Option<String>,
44
45 #[arg(long, env = "JSS_BEARER_TOKEN")]
48 pub token: Option<String>,
49
50 #[arg(long, value_delimiter = ',', default_value = "main,gh-pages")]
53 pub branches: Vec<String>,
54
55 #[arg(long)]
57 pub dry_run: bool,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AppSpec {
64 pub source_url: String,
66 pub dest: String,
68 pub git_ref: Option<String>,
70}
71
72pub 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
128fn 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 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
175fn 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 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
229pub 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}