Lightweight decentralized “knot” server prototype using Iroh + ATProto.
1use std::path::PathBuf;
2
3use bpaf::{Bpaf, ParseFailure};
4
5use crate::config::Config;
6use crate::registry::AtProtoLocator;
7use crate::{Error, Result};
8
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct AtProtoOverrides {
11 pub pds: Option<String>,
12 pub repo: Option<String>,
13 pub collection: Option<String>,
14 pub rkey: Option<String>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Command {
19 Sync {
20 repo_id: String,
21 record_path: PathBuf,
22 cache_dir: PathBuf,
23 },
24 Register {
25 path: Option<String>,
26 repo_id: Option<String>,
27 handle: Option<String>,
28 repo_name: Option<String>,
29 record_path: Option<PathBuf>,
30 registry_path: PathBuf,
31 atproto_host: Option<String>,
32 atproto_overrides: AtProtoOverrides,
33 data_dir: PathBuf,
34 },
35 Daemon {
36 addr: String,
37 registry_path: PathBuf,
38 cache_root: PathBuf,
39 },
40 Serve {
41 addr: String,
42 cache_dir: PathBuf,
43 },
44 Publish {
45 repo_id: String,
46 repo_path: PathBuf,
47 git_path: String,
48 record_path: PathBuf,
49 registry_path: PathBuf,
50 cache_root: PathBuf,
51 atproto: Option<AtProtoLocator>,
52 },
53 Seed {
54 record_path: PathBuf,
55 cache_root: PathBuf,
56 },
57}
58
59#[derive(Debug, Clone, Bpaf, PartialEq, Eq)]
60#[bpaf(options, version)]
61enum CommandArgs {
62 #[bpaf(command("sync"))]
63 Sync {
64 #[bpaf(long("repo"))]
65 repo_id: String,
66 #[bpaf(long("record"))]
67 record_path: PathBuf,
68 #[bpaf(long("cache-dir"), argument("PATH"))]
69 cache_dir: Option<PathBuf>,
70 },
71 #[bpaf(command("register"))]
72 Register {
73 #[bpaf(long("path"))]
74 path: Option<String>,
75 #[bpaf(long("repo"))]
76 repo_id: Option<String>,
77 #[bpaf(long("handle"))]
78 handle: Option<String>,
79 #[bpaf(long("repo-name"))]
80 repo_name: Option<String>,
81 #[bpaf(long("record"))]
82 record_path: Option<PathBuf>,
83 #[bpaf(long("registry"), argument("PATH"))]
84 registry_path: Option<PathBuf>,
85 #[bpaf(long("atproto-host"))]
86 atproto_host: Option<String>,
87 #[bpaf(long("atproto-pds"))]
88 atproto_pds: Option<String>,
89 #[bpaf(long("atproto-repo"))]
90 atproto_repo: Option<String>,
91 #[bpaf(long("atproto-collection"))]
92 atproto_collection: Option<String>,
93 #[bpaf(long("atproto-rkey"))]
94 atproto_rkey: Option<String>,
95 },
96 #[bpaf(command("daemon"))]
97 Daemon {
98 #[bpaf(long("addr"), argument("ADDR"))]
99 addr: Option<String>,
100 #[bpaf(long("registry"), argument("PATH"))]
101 registry_path: Option<PathBuf>,
102 #[bpaf(long("cache-root"), argument("PATH"))]
103 cache_root: Option<PathBuf>,
104 },
105 #[bpaf(command("serve"))]
106 Serve {
107 #[bpaf(long("addr"), argument("ADDR"))]
108 addr: Option<String>,
109 #[bpaf(long("cache-dir"), argument("PATH"))]
110 cache_dir: Option<PathBuf>,
111 },
112 #[bpaf(command("publish"))]
113 Publish {
114 #[bpaf(long("repo-id"))]
115 repo_id: String,
116 #[bpaf(long("repo-path"), argument("PATH"))]
117 repo_path: PathBuf,
118 #[bpaf(long("git-path"), argument("PATH"))]
119 git_path: Option<String>,
120 #[bpaf(long("record-path"), argument("PATH"))]
121 record_path: Option<PathBuf>,
122 #[bpaf(long("registry"), argument("PATH"))]
123 registry_path: Option<PathBuf>,
124 #[bpaf(long("cache-root"), argument("PATH"))]
125 cache_root: Option<PathBuf>,
126 #[bpaf(long("atproto-pds"))]
127 atproto_pds: Option<String>,
128 #[bpaf(long("atproto-repo"))]
129 atproto_repo: Option<String>,
130 #[bpaf(long("atproto-collection"))]
131 atproto_collection: Option<String>,
132 #[bpaf(long("atproto-rkey"))]
133 atproto_rkey: Option<String>,
134 },
135 #[bpaf(command("seed"))]
136 Seed {
137 #[bpaf(long("record-path"), argument("PATH"))]
138 record_path: PathBuf,
139 #[bpaf(long("cache-root"), argument("PATH"))]
140 cache_root: Option<PathBuf>,
141 },
142}
143
144impl CommandArgs {
145 fn resolve(self, config: &Config) -> Result<Command> {
146 let command = match self {
147 CommandArgs::Sync {
148 repo_id,
149 record_path,
150 cache_dir,
151 } => Command::Sync {
152 repo_id,
153 record_path,
154 cache_dir: cache_dir.unwrap_or_else(|| config.cache_dir.clone()),
155 },
156 CommandArgs::Register {
157 path,
158 repo_id,
159 handle,
160 repo_name,
161 record_path,
162 registry_path,
163 atproto_host,
164 atproto_pds,
165 atproto_repo,
166 atproto_collection,
167 atproto_rkey,
168 } => Command::Register {
169 path,
170 record_path,
171 registry_path: registry_path.unwrap_or_else(|| config.registry_path.clone()),
172 repo_id,
173 handle,
174 repo_name,
175 atproto_host,
176 atproto_overrides: AtProtoOverrides {
177 pds: atproto_pds,
178 repo: atproto_repo,
179 collection: atproto_collection,
180 rkey: atproto_rkey,
181 },
182 data_dir: config.data_dir.clone(),
183 },
184 CommandArgs::Daemon {
185 addr,
186 registry_path,
187 cache_root,
188 } => Command::Daemon {
189 addr: addr.unwrap_or_else(|| config.daemon_addr.clone()),
190 registry_path: registry_path.unwrap_or_else(|| config.registry_path.clone()),
191 cache_root: cache_root.unwrap_or_else(|| config.cache_dir.clone()),
192 },
193 CommandArgs::Serve { addr, cache_dir } => Command::Serve {
194 addr: addr.unwrap_or_else(|| config.http_addr.clone()),
195 cache_dir: cache_dir.unwrap_or_else(|| config.cache_dir.clone()),
196 },
197 CommandArgs::Publish {
198 repo_id,
199 repo_path,
200 git_path,
201 record_path,
202 registry_path,
203 cache_root,
204 atproto_pds,
205 atproto_repo,
206 atproto_collection,
207 atproto_rkey,
208 } => Command::Publish {
209 git_path: git_path.unwrap_or_else(|| default_git_path(&repo_id)),
210 record_path: record_path
211 .unwrap_or_else(|| default_record_path(&config.data_dir, &repo_id)),
212 registry_path: registry_path.unwrap_or_else(|| config.registry_path.clone()),
213 cache_root: cache_root.unwrap_or_else(|| config.cache_dir.clone()),
214 repo_id: repo_id.clone(),
215 repo_path,
216 atproto: build_atproto_locator_strict(
217 &repo_id,
218 atproto_pds,
219 atproto_repo,
220 atproto_collection,
221 atproto_rkey,
222 )?,
223 },
224 CommandArgs::Seed {
225 record_path,
226 cache_root,
227 } => Command::Seed {
228 record_path,
229 cache_root: cache_root.unwrap_or_else(|| config.cache_dir.clone()),
230 },
231 };
232 Ok(command)
233 }
234}
235
236pub fn default_git_path(repo_id: &str) -> String {
237 let name = repo_id
238 .split('/')
239 .last()
240 .unwrap_or("repo");
241 format!("/{name}.git")
242}
243
244pub fn default_record_path(data_dir: &std::path::Path, repo_id: &str) -> PathBuf {
245 let sanitized: String = repo_id
246 .chars()
247 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
248 .collect();
249 data_dir.join("records").join(format!("{sanitized}.json"))
250}
251
252pub fn default_rkey(repo_id: &str) -> String {
253 let raw = repo_id
254 .split('/')
255 .last()
256 .unwrap_or("repo");
257 raw.chars()
258 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
259 .collect()
260}
261
262fn build_atproto_locator_strict(
263 repo_id: &str,
264 pds: Option<String>,
265 repo: Option<String>,
266 collection: Option<String>,
267 rkey: Option<String>,
268) -> Result<Option<AtProtoLocator>> {
269 let any = pds.is_some() || repo.is_some() || collection.is_some() || rkey.is_some();
270 if !any {
271 return Ok(None);
272 }
273 let pds = pds.ok_or_else(|| Error::Invalid("missing --atproto-pds".into()))?;
274 let repo = repo.ok_or_else(|| Error::Invalid("missing --atproto-repo".into()))?;
275 let collection =
276 collection.ok_or_else(|| Error::Invalid("missing --atproto-collection".into()))?;
277 let rkey = rkey.unwrap_or_else(|| default_rkey(repo_id));
278 Ok(Some(AtProtoLocator {
279 pds,
280 repo,
281 collection,
282 rkey,
283 }))
284}
285
286pub fn parse_args(args: &[String]) -> Result<Command> {
287 let config = Config::from_env();
288 parse_args_with_config(args, &config)
289}
290
291pub fn parse_args_with_config(args: &[String], config: &Config) -> Result<Command> {
292 let args: &[String] = if args.len() > 1 { &args[1..] } else { &[] };
293 match command_args().run_inner(args) {
294 Ok(cmd) => Ok(cmd.resolve(config)?),
295 Err(err) => {
296 let msg = match err {
297 ParseFailure::Stderr(doc) => doc.monochrome(true),
298 ParseFailure::Stdout(doc, full) => doc.monochrome(full),
299 ParseFailure::Completion(text) => text,
300 };
301 Err(Error::Invalid(msg))
302 }
303 }
304}