1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5pub mod credentials;
6
7pub const MAX_PROJECT_NAME_LEN: usize = 100;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum NameError {
11 Empty,
12 TooLong { len: usize },
13 InvalidChar { ch: char },
14}
15
16impl std::fmt::Display for NameError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 NameError::Empty => write!(f, "name cannot be empty"),
20 NameError::TooLong { len } => write!(
21 f,
22 "name too long: {len} chars (max {MAX_PROJECT_NAME_LEN})"
23 ),
24 NameError::InvalidChar { ch } => write!(
25 f,
26 "name contains invalid character {ch:?}; allowed: letters, digits, '.', '_', '-'"
27 ),
28 }
29 }
30}
31
32impl std::error::Error for NameError {}
33
34pub fn validate_project_name(s: &str) -> std::result::Result<(), NameError> {
35 let len = s.chars().count();
36 if len == 0 {
37 return Err(NameError::Empty);
38 }
39 if len > MAX_PROJECT_NAME_LEN {
40 return Err(NameError::TooLong { len });
41 }
42 if let Some(ch) = s
43 .chars()
44 .find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '_' && *c != '-')
45 {
46 return Err(NameError::InvalidChar { ch });
47 }
48 Ok(())
49}
50
51pub fn is_valid_project_name(s: &str) -> bool {
52 validate_project_name(s).is_ok()
53}
54
55#[derive(Serialize)]
56struct NewProjectInput<'a> {
57 name: &'a str,
58}
59
60#[derive(Deserialize)]
61#[serde(tag = "t", rename_all_fields = "camelCase")]
62enum NewProject {
63 Ok { project_id: String },
64 NotLoggedIn,
65 InvalidName,
66 InternalError,
67}
68
69pub async fn ensure_project_id(
70 client: &reqwest::Client,
71 control_url: &str,
72 token: &str,
73 project_name: &str,
74 project_id: &mut Option<String>,
75) -> Result<String> {
76 if let Some(id) = project_id.as_ref() {
77 return Ok(id.clone());
78 }
79 let url = format!(
80 "{}/__forte_action/new_project",
81 control_url.trim_end_matches('/')
82 );
83 let resp = client
84 .post(&url)
85 .bearer_auth(token)
86 .json(&NewProjectInput { name: project_name })
87 .send()
88 .await?
89 .error_for_status()
90 .map_err(|e| anyhow!("new_project failed: {e}"))?;
91 let raw: NewProject = resp.json().await?;
92 let id = match raw {
93 NewProject::Ok { project_id } => project_id,
94 NewProject::NotLoggedIn => {
95 return Err(anyhow!("control rejected token; run `fn0 login` again."));
96 }
97 NewProject::InvalidName => {
98 return Err(anyhow!(
99 "control rejected project name '{project_name}': must be 1-{MAX_PROJECT_NAME_LEN} chars of letters, digits, '.', '_', '-'"
100 ));
101 }
102 NewProject::InternalError => {
103 return Err(anyhow!(
104 "new_project: server error; check fn0-control logs"
105 ));
106 }
107 };
108 *project_id = Some(id.clone());
109 Ok(id)
110}
111
112#[derive(Serialize)]
113struct DeployInput<'a> {
114 project_id: &'a str,
115 build_id: &'a str,
116 files: Vec<DeployFile>,
117 jobs: &'a [CronJob],
118 cron_updated_at: &'a str,
119}
120
121#[derive(Serialize, Deserialize, Clone, Debug)]
122pub struct CronJob {
123 pub function: String,
124 pub every_minutes: u32,
125}
126
127#[derive(Serialize)]
128struct DeployFile {
129 path: String,
130 size: u64,
131}
132
133#[derive(Deserialize)]
134#[serde(tag = "t", rename_all_fields = "camelCase")]
135enum Deploy {
136 Ok {
137 presigned_put_url: String,
138 object_key: String,
139 static_uploads: Vec<StaticUpload>,
140 },
141 QuotaExceeded {
142 reason: String,
143 },
144 NotLoggedIn,
145 NotFound,
146 InternalError,
147}
148
149#[derive(Deserialize)]
150struct StaticUpload {
151 path: String,
152 presigned_url: String,
153}
154
155#[derive(Serialize)]
156struct DeployStatusInput<'a> {
157 project_id: &'a str,
158 code_version: u64,
159}
160
161#[derive(Deserialize)]
162#[serde(tag = "t", rename_all_fields = "camelCase")]
163enum DeployStatus {
164 Done {
165 active_version: String,
166 pending_version: Option<String>,
167 pending_compiled: bool,
168 compiled_versions: Vec<String>,
169 },
170 Pending {
171 active_version: String,
172 pending_version: Option<String>,
173 pending_compiled: bool,
174 compiled_versions: Vec<String>,
175 },
176 NoActiveVersion,
177 NotLoggedIn,
178 NotFound,
179 InternalError,
180}
181
182#[allow(clippy::too_many_arguments)]
183pub async fn deploy_wasm(
184 control_url: &str,
185 token: &str,
186 project_id: &str,
187 build_id: &str,
188 bundle_tar_path: &Path,
189 jobs: &[CronJob],
190 cron_updated_at: &str,
191) -> Result<()> {
192 let client = reqwest::Client::new();
193 println!("project_id: {project_id}");
194
195 let DeployOk {
196 presigned_put_url,
197 object_key,
198 static_uploads: _,
199 } = request_deploy(
200 &client,
201 control_url,
202 token,
203 project_id,
204 build_id,
205 Vec::new(),
206 jobs,
207 cron_updated_at,
208 )
209 .await?;
210
211 println!("uploading bundle to {object_key}...");
212 let code_version = upload_bundle(&client, &presigned_put_url, bundle_tar_path).await?;
213 println!("uploaded. code_version={code_version}");
214
215 poll_deploy_status(&client, control_url, token, project_id, code_version).await?;
216 println!("Deploy complete!");
217 Ok(())
218}
219
220struct DeployOk {
221 presigned_put_url: String,
222 object_key: String,
223 static_uploads: Vec<StaticUpload>,
224}
225
226#[allow(clippy::too_many_arguments)]
227pub async fn deploy_forte(
228 control_url: &str,
229 token: &str,
230 project_id: &str,
231 build_id: &str,
232 fe_dist_dir: &Path,
233 bundle_tar_path: &Path,
234 jobs: &[CronJob],
235 cron_updated_at: &str,
236) -> Result<()> {
237 let client = reqwest::Client::new();
238 println!("project_id: {project_id}");
239
240 let static_files = collect_static_files(fe_dist_dir)?;
241 let deploy_files: Vec<DeployFile> = static_files
242 .iter()
243 .map(|f| DeployFile {
244 path: f.relative_path.clone(),
245 size: f.size,
246 })
247 .collect();
248 println!(
249 "Requesting deploy ({} static asset(s))...",
250 deploy_files.len()
251 );
252
253 let DeployOk {
254 presigned_put_url,
255 object_key,
256 static_uploads,
257 } = request_deploy(
258 &client,
259 control_url,
260 token,
261 project_id,
262 build_id,
263 deploy_files,
264 jobs,
265 cron_updated_at,
266 )
267 .await?;
268
269 if !static_files.is_empty() {
270 println!("Uploading {} static asset(s)...", static_files.len());
271 upload_static_assets(&client, &static_files, static_uploads).await?;
272 }
273
274 println!("uploading bundle to {object_key}...");
275 let code_version = upload_bundle(&client, &presigned_put_url, bundle_tar_path).await?;
276 println!("uploaded. code_version={code_version}");
277
278 poll_deploy_status(&client, control_url, token, project_id, code_version).await?;
279 println!("Deploy complete!");
280 Ok(())
281}
282
283#[allow(clippy::too_many_arguments)]
284async fn request_deploy(
285 client: &reqwest::Client,
286 control_url: &str,
287 token: &str,
288 project_id: &str,
289 build_id: &str,
290 files: Vec<DeployFile>,
291 jobs: &[CronJob],
292 cron_updated_at: &str,
293) -> Result<DeployOk> {
294 let deploy_url = format!(
295 "{}/__forte_action/deploy",
296 control_url.trim_end_matches('/')
297 );
298 let raw: Deploy = client
299 .post(&deploy_url)
300 .bearer_auth(token)
301 .json(&DeployInput {
302 project_id,
303 build_id,
304 files,
305 jobs,
306 cron_updated_at,
307 })
308 .send()
309 .await?
310 .error_for_status()
311 .map_err(|e| anyhow!("deploy failed: {e}"))?
312 .json()
313 .await?;
314 match raw {
315 Deploy::Ok {
316 presigned_put_url,
317 object_key,
318 static_uploads,
319 } => Ok(DeployOk {
320 presigned_put_url,
321 object_key,
322 static_uploads,
323 }),
324 Deploy::QuotaExceeded { reason } => Err(anyhow!("deploy quota exceeded: {reason}")),
325 Deploy::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
326 Deploy::NotFound => Err(anyhow!("project '{project_id}' not found or not owned by you.")),
327 Deploy::InternalError => Err(anyhow!("deploy: server error; check fn0-control logs")),
328 }
329}
330
331async fn upload_bundle(
332 client: &reqwest::Client,
333 presigned_put_url: &str,
334 bundle_tar_path: &Path,
335) -> Result<u64> {
336 let bundle_bytes = std::fs::read(bundle_tar_path)
337 .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
338 let put_resp = client
339 .put(presigned_put_url)
340 .body(bundle_bytes)
341 .send()
342 .await?
343 .error_for_status()
344 .map_err(|e| anyhow!("bundle upload failed: {e}"))?;
345 extract_code_version(&put_resp)
346}
347
348async fn upload_static_assets(
349 client: &reqwest::Client,
350 files: &[StaticFile],
351 uploads: Vec<StaticUpload>,
352) -> Result<()> {
353 use futures::StreamExt;
354 use std::collections::HashMap;
355
356 let mut url_for_path: HashMap<String, String> = HashMap::new();
357 for u in uploads {
358 url_for_path.insert(u.path, u.presigned_url);
359 }
360
361 let mut tasks = futures::stream::FuturesUnordered::new();
362 for file in files {
363 let url = url_for_path.remove(&file.relative_path).ok_or_else(|| {
364 anyhow!(
365 "control did not return presigned URL for {}",
366 file.relative_path
367 )
368 })?;
369 let bytes = std::fs::read(&file.absolute_path)
370 .map_err(|e| anyhow!("read {}: {}", file.absolute_path.display(), e))?;
371 let client = client.clone();
372 let content_type = file.content_type;
373 let path = file.relative_path.clone();
374 tasks.push(async move {
375 let resp = client
376 .put(&url)
377 .header("content-type", content_type)
378 .body(bytes)
379 .send()
380 .await
381 .map_err(|e| anyhow!("R2 PUT {}: {}", path, e))?;
382 resp.error_for_status()
383 .map_err(|e| anyhow!("R2 PUT {} HTTP error: {}", path, e))?;
384 Ok::<_, anyhow::Error>(())
385 });
386 }
387 while let Some(result) = tasks.next().await {
388 result?;
389 }
390 Ok(())
391}
392
393pub struct StaticFile {
394 pub relative_path: String,
395 pub absolute_path: PathBuf,
396 pub size: u64,
397 pub content_type: &'static str,
398}
399
400pub fn collect_static_files(dir: &Path) -> Result<Vec<StaticFile>> {
401 let mut out = Vec::new();
402 if !dir.exists() {
403 return Ok(out);
404 }
405 walk_collect(dir, dir, &mut out)?;
406 Ok(out)
407}
408
409fn walk_collect(base: &Path, dir: &Path, out: &mut Vec<StaticFile>) -> Result<()> {
410 for entry in std::fs::read_dir(dir)? {
411 let entry = entry?;
412 let path = entry.path();
413 if path.is_dir() {
414 if path.file_name().and_then(|s| s.to_str()) == Some("ssr")
415 && path.parent() == Some(base)
416 {
417 continue;
418 }
419 walk_collect(base, &path, out)?;
420 continue;
421 }
422 let metadata = entry.metadata()?;
423 let rel = path
424 .strip_prefix(base)
425 .map_err(|e| anyhow!("strip_prefix: {e}"))?
426 .to_string_lossy()
427 .replace('\\', "/");
428 out.push(StaticFile {
429 relative_path: rel,
430 absolute_path: path.clone(),
431 size: metadata.len(),
432 content_type: content_type_for(&path),
433 });
434 }
435 Ok(())
436}
437
438pub fn content_type_for(path: &Path) -> &'static str {
439 match path.extension().and_then(|e| e.to_str()) {
440 Some("html") => "text/html; charset=utf-8",
441 Some("css") => "text/css; charset=utf-8",
442 Some("js") | Some("mjs") | Some("cjs") => "application/javascript; charset=utf-8",
443 Some("json") => "application/json; charset=utf-8",
444 Some("map") => "application/json; charset=utf-8",
445 Some("png") => "image/png",
446 Some("jpg") | Some("jpeg") => "image/jpeg",
447 Some("gif") => "image/gif",
448 Some("svg") => "image/svg+xml",
449 Some("ico") => "image/x-icon",
450 Some("webp") => "image/webp",
451 Some("woff") => "font/woff",
452 Some("woff2") => "font/woff2",
453 Some("ttf") => "font/ttf",
454 Some("otf") => "font/otf",
455 Some("eot") => "application/vnd.ms-fontobject",
456 Some("txt") => "text/plain; charset=utf-8",
457 Some("xml") => "application/xml; charset=utf-8",
458 Some("pdf") => "application/pdf",
459 Some("mp4") => "video/mp4",
460 Some("webm") => "video/webm",
461 Some("mp3") => "audio/mpeg",
462 Some("wav") => "audio/wav",
463 _ => "application/octet-stream",
464 }
465}
466
467fn extract_code_version(resp: &reqwest::Response) -> Result<u64> {
468 let hv = resp
469 .headers()
470 .get(reqwest::header::LAST_MODIFIED)
471 .ok_or_else(|| anyhow!("R2 PUT response missing Last-Modified header"))?
472 .to_str()
473 .map_err(|e| anyhow!("Last-Modified not utf-8: {e}"))?;
474 let dt = chrono::DateTime::parse_from_rfc2822(hv)
475 .map_err(|e| anyhow!("Last-Modified parse: {e}; raw={hv}"))?;
476 let secs = dt.timestamp();
477 u64::try_from(secs).map_err(|_| anyhow!("Last-Modified before epoch: {secs}"))
478}
479
480async fn poll_deploy_status(
481 client: &reqwest::Client,
482 control_url: &str,
483 token: &str,
484 project_id: &str,
485 code_version: u64,
486) -> Result<()> {
487 let url = format!(
488 "{}/__forte_action/deploy_status",
489 control_url.trim_end_matches('/')
490 );
491 let timeout = std::time::Duration::from_secs(600);
492 let start = std::time::Instant::now();
493 let mut last_state: Option<String> = None;
494
495 loop {
496 let raw: DeployStatus = client
497 .post(&url)
498 .bearer_auth(token)
499 .json(&DeployStatusInput {
500 project_id,
501 code_version,
502 })
503 .send()
504 .await?
505 .error_for_status()
506 .map_err(|e| anyhow!("deploy_status failed: {e}"))?
507 .json()
508 .await?;
509
510 match raw {
511 DeployStatus::Done {
512 active_version,
513 pending_version,
514 pending_compiled,
515 compiled_versions,
516 } => {
517 log_status_line(
518 &active_version,
519 &compiled_versions,
520 &pending_version,
521 pending_compiled,
522 &mut last_state,
523 );
524 return Ok(());
525 }
526 DeployStatus::Pending {
527 active_version,
528 pending_version,
529 pending_compiled,
530 compiled_versions,
531 } => {
532 log_status_line(
533 &active_version,
534 &compiled_versions,
535 &pending_version,
536 pending_compiled,
537 &mut last_state,
538 );
539 if start.elapsed() > timeout {
540 return Err(anyhow!(
541 "deploy_status timed out after {}s",
542 timeout.as_secs()
543 ));
544 }
545 }
546 DeployStatus::NoActiveVersion => {
547 return Err(anyhow!("control has no active fn0-wasmtime version yet"));
548 }
549 DeployStatus::NotLoggedIn => {
550 return Err(anyhow!("control rejected token; run `fn0 login` again."));
551 }
552 DeployStatus::NotFound => {
553 return Err(anyhow!(
554 "project '{project_id}' not found or not owned by you."
555 ));
556 }
557 DeployStatus::InternalError => {
558 return Err(anyhow!(
559 "deploy_status: server error; check fn0-control logs"
560 ));
561 }
562 }
563 }
564}
565
566fn log_status_line(
567 active_version: &str,
568 compiled_versions: &[String],
569 pending_version: &Option<String>,
570 pending_compiled: bool,
571 last_state: &mut Option<String>,
572) {
573 let state = format!(
574 "active={active_version} compiled={compiled_versions:?} pending={pending_version:?} pending_compiled={pending_compiled}",
575 );
576 if last_state.as_deref() != Some(&state) {
577 println!(" {state}");
578 *last_state = Some(state);
579 }
580}
581
582pub fn read_env_yaml(project_dir: &Path) -> Result<Option<Vec<u8>>> {
583 let p = project_dir.join("env.yaml");
584 match std::fs::read(&p) {
585 Ok(content) => Ok(Some(content)),
586 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
587 Err(e) => Err(anyhow!("Failed to read {}: {}", p.display(), e)),
588 }
589}
590
591pub fn create_raw_bundle_wasm(
592 wasm_path: &Path,
593 env_yaml: Option<&[u8]>,
594 output_path: &Path,
595) -> Result<()> {
596 let file = std::fs::File::create(output_path)
597 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
598 let mut builder = tar::Builder::new(file);
599 append_bytes(&mut builder, "manifest.json", br#"{"kind":"wasm"}"#)?;
600 let wasm_bytes = std::fs::read(wasm_path)
601 .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
602 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
603 if let Some(env) = env_yaml {
604 append_bytes(&mut builder, "env.yaml", env)?;
605 }
606 builder.finish()?;
607 Ok(())
608}
609
610pub fn create_raw_bundle_forte(
611 dist_dir: &Path,
612 env_yaml: Option<&[u8]>,
613 output_path: &Path,
614) -> Result<()> {
615 let file = std::fs::File::create(output_path)
616 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
617 let mut builder = tar::Builder::new(file);
618 append_bytes(&mut builder, "manifest.json", br#"{"kind":"wasmjs"}"#)?;
619
620 let backend_wasm = dist_dir.join("backend.wasm");
621 let wasm_bytes = std::fs::read(&backend_wasm)
622 .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
623 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
624
625 let server_js = dist_dir.join("server.js");
626 let server_bytes = std::fs::read(&server_js)
627 .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
628 append_bytes(&mut builder, "entry.js", &server_bytes)?;
629
630 if let Some(env) = env_yaml {
631 append_bytes(&mut builder, "env.yaml", env)?;
632 }
633
634 builder.finish()?;
635 Ok(())
636}
637
638fn append_bytes<W: std::io::Write>(
639 builder: &mut tar::Builder<W>,
640 path: &str,
641 data: &[u8],
642) -> Result<()> {
643 let mut header = tar::Header::new_gnu();
644 header.set_size(data.len() as u64);
645 header.set_mode(0o644);
646 header.set_cksum();
647 builder
648 .append_data(&mut header, path, data)
649 .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
650 Ok(())
651}
652
653pub struct AdminRunOutput {
654 pub status: u16,
655 pub content_type: Option<String>,
656 pub body: Vec<u8>,
657}
658
659pub async fn admin_run(
660 _project_id: &str,
661 _task: &str,
662 _input_body: Vec<u8>,
663 _timeout_secs: u64,
664) -> Result<AdminRunOutput> {
665 Err(anyhow!(
666 "admin run is not yet migrated to control. See GitHub issue #4."
667 ))
668}
669
670#[derive(Serialize)]
671struct DomainAddInput<'a> {
672 project_id: &'a str,
673 domain: &'a str,
674}
675
676#[derive(Deserialize)]
677#[serde(tag = "t", rename_all_fields = "camelCase")]
678enum DomainAdd {
679 Ok,
680 NotLoggedIn,
681 NotFound,
682 InvalidDomain { message: String },
683 DomainTaken { existing_project_id: String },
684 AlreadyHasDomain { current_domain: String },
685 InternalError,
686}
687
688pub async fn domain_add(project_id: &str, domain: &str) -> Result<()> {
689 let creds = credentials::require()?;
690 let client = reqwest::Client::new();
691 let url = format!(
692 "{}/__forte_action/domain_add",
693 creds.control_url.trim_end_matches('/')
694 );
695 let resp = client
696 .post(&url)
697 .bearer_auth(&creds.token)
698 .json(&DomainAddInput { project_id, domain })
699 .send()
700 .await?
701 .error_for_status()?;
702 let raw: DomainAdd = resp.json().await?;
703 match raw {
704 DomainAdd::Ok => {
705 println!("domain '{domain}' attached to project '{project_id}'");
706 println!(
707 "Cloudflare hostname registration is queued; run `fn0 domain status` to check."
708 );
709 Ok(())
710 }
711 DomainAdd::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
712 DomainAdd::NotFound => Err(anyhow!(
713 "project '{project_id}' not found or not owned by you."
714 )),
715 DomainAdd::InvalidDomain { message } => Err(anyhow!("invalid domain: {message}")),
716 DomainAdd::DomainTaken {
717 existing_project_id,
718 } => Err(anyhow!(
719 "domain '{domain}' already in use by project '{existing_project_id}'"
720 )),
721 DomainAdd::AlreadyHasDomain { current_domain } => Err(anyhow!(
722 "project '{project_id}' already has domain '{current_domain}'; remove it first"
723 )),
724 DomainAdd::InternalError => Err(anyhow!("domain_add: server error; check fn0-control logs")),
725 }
726}
727
728#[derive(Serialize)]
729struct DomainProjectInput<'a> {
730 project_id: &'a str,
731}
732
733#[derive(Deserialize)]
734#[serde(tag = "t", rename_all_fields = "camelCase")]
735enum DomainRemove {
736 Ok { removed_domain: String },
737 NotLoggedIn,
738 NotFound,
739 NoDomain,
740 InternalError,
741}
742
743pub async fn domain_remove(project_id: &str) -> Result<()> {
744 let creds = credentials::require()?;
745 let client = reqwest::Client::new();
746 let url = format!(
747 "{}/__forte_action/domain_remove",
748 creds.control_url.trim_end_matches('/')
749 );
750 let resp = client
751 .post(&url)
752 .bearer_auth(&creds.token)
753 .json(&DomainProjectInput { project_id })
754 .send()
755 .await?
756 .error_for_status()?;
757 let raw: DomainRemove = resp.json().await?;
758 match raw {
759 DomainRemove::Ok { removed_domain } => {
760 println!("domain '{removed_domain}' detached from project '{project_id}'");
761 println!("Cloudflare hostname removal is queued.");
762 Ok(())
763 }
764 DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
765 DomainRemove::NotFound => Err(anyhow!(
766 "project '{project_id}' not found or not owned by you."
767 )),
768 DomainRemove::NoDomain => Err(anyhow!(
769 "no custom domain attached to project '{project_id}'."
770 )),
771 DomainRemove::InternalError => Err(anyhow!(
772 "domain_remove: server error; check fn0-control logs"
773 )),
774 }
775}
776
777#[derive(Deserialize)]
778#[serde(tag = "t", rename_all_fields = "camelCase")]
779enum DomainStatus {
780 NotConfigured,
781 Configured {
782 domain: String,
783 cloudflare_status: CloudflareStatus,
784 },
785 NotLoggedIn,
786 NotFound,
787 InternalError,
788}
789
790#[derive(Deserialize)]
791#[serde(tag = "t", rename_all_fields = "camelCase")]
792enum CloudflareStatus {
793 Active,
794 Pending,
795 Missing,
796 Other { value: String },
797}
798
799pub async fn domain_status(project_id: &str) -> Result<()> {
800 let creds = credentials::require()?;
801 let client = reqwest::Client::new();
802 let url = format!(
803 "{}/__forte_action/domain_status",
804 creds.control_url.trim_end_matches('/')
805 );
806 let resp = client
807 .post(&url)
808 .bearer_auth(&creds.token)
809 .json(&DomainProjectInput { project_id })
810 .send()
811 .await?
812 .error_for_status()?;
813 let raw: DomainStatus = resp.json().await?;
814 match raw {
815 DomainStatus::NotConfigured => {
816 println!("project '{project_id}' has no custom domain configured.");
817 Ok(())
818 }
819 DomainStatus::Configured {
820 domain,
821 cloudflare_status,
822 } => {
823 println!("project '{project_id}' custom domain: {domain}");
824 println!(
825 "cloudflare status: {}",
826 format_cloudflare_status(&cloudflare_status)
827 );
828 Ok(())
829 }
830 DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
831 DomainStatus::NotFound => Err(anyhow!(
832 "project '{project_id}' not found or not owned by you."
833 )),
834 DomainStatus::InternalError => Err(anyhow!(
835 "domain_status: server error; check fn0-control logs"
836 )),
837 }
838}
839
840fn format_cloudflare_status(status: &CloudflareStatus) -> String {
841 match status {
842 CloudflareStatus::Active => "active".to_string(),
843 CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
844 CloudflareStatus::Missing => {
845 "missing on Cloudflare (registration may still be in progress)".to_string()
846 }
847 CloudflareStatus::Other { value } => format!("other: {value}"),
848 }
849}
850
851#[derive(Serialize)]
852struct RenameProjectInput<'a> {
853 project_id: &'a str,
854 new_name: &'a str,
855}
856
857#[derive(Deserialize)]
858#[serde(tag = "t", rename_all_fields = "camelCase")]
859enum RenameProject {
860 Ok,
861 NotLoggedIn,
862 NotFound,
863 InvalidName,
864 InternalError,
865}
866
867pub async fn rename_project(project_id: &str, new_name: &str) -> Result<()> {
868 let creds = credentials::require()?;
869 let client = reqwest::Client::new();
870 let url = format!(
871 "{}/__forte_action/rename_project",
872 creds.control_url.trim_end_matches('/')
873 );
874 let resp = client
875 .post(&url)
876 .bearer_auth(&creds.token)
877 .json(&RenameProjectInput {
878 project_id,
879 new_name,
880 })
881 .send()
882 .await?
883 .error_for_status()?;
884 let raw: RenameProject = resp.json().await?;
885 match raw {
886 RenameProject::Ok => Ok(()),
887 RenameProject::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
888 RenameProject::NotFound => Err(anyhow!(
889 "project '{project_id}' not found or not owned by you."
890 )),
891 RenameProject::InvalidName => Err(anyhow!(
892 "control rejected name '{new_name}': must be 1-{MAX_PROJECT_NAME_LEN} chars of letters, digits, '.', '_', '-'"
893 )),
894 RenameProject::InternalError => Err(anyhow!(
895 "rename_project: server error; check fn0-control logs"
896 )),
897 }
898}