Skip to main content

ordinary/
lib.rs

1#![doc = include_str!("../docs/cli-reference.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc)]
4
5// Copyright (C) 2026 Ordinary Labs, LLC.
6//
7// SPDX-License-Identifier: AGPL-3.0-only
8
9mod cmds;
10mod permission;
11mod units;
12
13use clap::{Parser, Subcommand};
14use clap_verbosity_flag::{Verbosity, WarnLevel};
15use clio::ClioPath;
16use std::path::Path;
17use tracing::{Level, instrument};
18use tracing_subscriber::fmt::format::FmtSpan;
19use tracing_subscriber::layer::SubscriberExt;
20use tracing_subscriber::util::SubscriberInitExt;
21
22use crate::cmds::accounts::get_current_account;
23use crate::cmds::secrets::Secrets;
24
25use crate::cmds::root::Root;
26use crate::cmds::ssg::Ssg;
27use crate::cmds::utils::Utils;
28pub use cmds::{
29    accounts::Accounts, actions::Actions, app::App, assets::Assets, content::Content,
30    integrations::Integrations, models::Models, templates::Templates,
31};
32use ordinary_api::client::OrdinaryApiClient;
33use ordinary_config::OrdinaryConfig;
34use ordinaryd::{AppApi, GlobalArgs};
35pub use permission::Permission;
36
37pub(crate) static USER_AGENT: &str =
38    concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
39
40pub(crate) static GENERATOR: &str = concat!("Ordinary CLI ", env!("CARGO_PKG_VERSION"));
41
42pub(crate) fn add_http(domain: &str, insecure: bool) -> String {
43    if insecure {
44        format!("http://{domain}")
45    } else {
46        format!("https://{domain}")
47    }
48}
49
50#[derive(Parser, Debug)]
51#[command(version, about, long_about = None)]
52#[command(propagate_version = true)]
53pub struct Cli {
54    #[command(subcommand)]
55    pub commands: Commands,
56
57    /// project path
58    #[arg(short, long, global = true, value_parser = clap::value_parser!(ClioPath).exists().is_dir(), default_value = ".")]
59    pub project: ClioPath,
60
61    /// should only be necessary with localhost or when addressing by IP
62    #[arg(long, global = true)]
63    pub api_domain: Option<String>,
64
65    /// use HTTP instead of HTTPS
66    #[arg(long, global = true, default_value_t = false)]
67    pub insecure: bool,
68
69    /// DANGER: only use when working with self-signed localhost certs
70    #[arg(long, global = true, default_value_t = false)]
71    pub danger_accept_invalid_certs: bool,
72
73    #[command(flatten)]
74    pub verbosity: Verbosity<WarnLevel>,
75
76    /// whether to pretty print events to stdio
77    #[arg(long, global = true, default_value_t = false)]
78    pub pretty: bool,
79}
80
81#[derive(Subcommand, Debug)]
82pub enum Commands {
83    /// create a new Ordinary project
84    New {
85        /// project domain
86        domain: String,
87        /// project path
88        #[arg(long, default_value = ".")]
89        path: String,
90    },
91    /// manage static site configuration
92    Ssg {
93        #[command(subcommand)]
94        ssg: Ssg,
95    },
96    /// build your Ordinary project
97    ///
98    /// Note: will load environment variables from `.env`
99    Build {
100        /// build project without checking the cache
101        #[arg(short, long, default_value_t = false)]
102        ignore_cache: bool,
103    },
104    /// start the app, locally
105    Start {
106        #[command(flatten)]
107        app_api: AppApi,
108
109        #[command(flatten)]
110        global_args: GlobalArgs,
111
112        /// disables these defaults set for development:
113        /// `--stdio-logs`
114        /// `--stdio-logs-fmt concise`
115        /// `--insecure`
116        /// `--insecure-cookies`
117        #[arg(short, long, default_value_t = false)]
118        disable_defaults: bool,
119    },
120    /// combines `build`, `content update`, `assets write`,
121    /// `templates upload`, `actions install`
122    Publish,
123
124    /// manage templates in your Ordinary project
125    Templates {
126        #[command(subcommand)]
127        templates: Templates,
128    },
129    /// manage content in your Ordinary project
130    Content {
131        #[command(subcommand)]
132        content: Content,
133    },
134    /// manage assets in your Ordinary project
135    Assets {
136        #[command(subcommand)]
137        assets: Assets,
138    },
139
140    /// manage models in your Ordinary project
141    Models {
142        #[command(subcommand)]
143        models: Models,
144    },
145    /// manage actions in your Ordinary project
146    Actions {
147        #[command(subcommand)]
148        actions: Actions,
149    },
150    /// manage integrations in your Ordinary project
151    Integrations {
152        #[command(subcommand)]
153        integrations: Integrations,
154    },
155
156    /// manage accounts connected to `ordinaryd`
157    Accounts {
158        #[command(subcommand)]
159        accounts: Accounts,
160    },
161    /// manage applications running on `ordinaryd`
162    App {
163        #[command(subcommand)]
164        app: App,
165    },
166    /// manage secrets in your Ordinary application
167    Secrets {
168        #[command(subcommand)]
169        secrets: Secrets,
170    },
171    Root {
172        #[command(subcommand)]
173        root: Root,
174    },
175    /// ensure that all the correct system components are installed
176    Doctor {
177        /// auto fix installs
178        #[arg(short, long, value_delimiter = ',', num_args = 1..)]
179        fix: Option<Vec<ordinary_doctor::Fix>>,
180    },
181    /// utility functions for aiding project development
182    Utils {
183        #[command(subcommand)]
184        utils: Utils,
185    },
186}
187
188pub fn setup(cli: &Cli) -> anyhow::Result<()> {
189    let pretty_layer = if cli.pretty {
190        Some(
191            tracing_subscriber::fmt::layer()
192                .pretty()
193                .with_span_events(FmtSpan::CLOSE)
194                .with_writer(std::io::stderr),
195        )
196    } else {
197        None
198    };
199
200    let ugly_layer = if cli.pretty {
201        None
202    } else {
203        Some(
204            tracing_subscriber::fmt::layer()
205                .with_span_events(FmtSpan::CLOSE)
206                .with_target(false)
207                .with_writer(std::io::stderr),
208        )
209    };
210
211    let log_level_str = cli
212        .verbosity
213        .tracing_level()
214        .unwrap_or(Level::INFO)
215        .as_str()
216        .to_ascii_lowercase();
217
218    let directives = [
219        ("ordinaryd", &log_level_str),            // daemon
220        ("ordinary_modify", &log_level_str),      // modify
221        ("ordinary_build", &log_level_str),       // build
222        ("ordinary_doctor", &log_level_str),      // doctor
223        ("ordinary_studio", &log_level_str),      // studio
224        ("ordinary_utils", &log_level_str),       // utils
225        ("ordinary_auth", &log_level_str),        // auth
226        ("ordinary_api", &log_level_str),         // api
227        ("ordinary_app", &log_level_str),         // app
228        ("ordinary_template", &log_level_str),    // templates
229        ("ordinary_action", &log_level_str),      // actions
230        ("ordinary_integration", &log_level_str), // integrations
231        ("ordinary_storage", &log_level_str),     // storage
232        ("ordinary_monitor", &log_level_str),     // monitor
233        ("ordinary_config", &log_level_str),      // config
234        ("tower_http", &log_level_str),           // http
235    ];
236
237    let mut directives_string = format!("ordinary={}", &log_level_str);
238
239    for (lib, lvl) in directives {
240        directives_string = format!("{directives_string},{lib}={lvl}");
241    }
242
243    tracing_subscriber::registry()
244        .with(
245            tracing_subscriber::EnvFilter::try_from_default_env()
246                .unwrap_or_else(|_| directives_string.into()),
247        )
248        .with(pretty_layer)
249        .with(ugly_layer)
250        .init();
251
252    std::panic::set_hook(Box::new(|info| {
253        if let Some(msg) = info.payload_as_str()
254            && let Some(loc) = info.location()
255        {
256            tracing::error!(%loc, msg, "panic");
257        } else if let Some(loc) = info.location() {
258            tracing::error!(%loc, "panic");
259        }
260    }));
261
262    Ok(())
263}
264
265#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
266#[instrument(name = "ordinary", skip_all, err)]
267pub async fn run(cli: &Cli) -> anyhow::Result<()> {
268    let api_domain = cli.api_domain.as_deref();
269
270    let project = cli
271        .project
272        .to_str()
273        .expect("failed to get string from path");
274
275    match &cli.commands {
276        Commands::New { path, domain } => ordinary_modify::project::new(path, domain)?,
277        Commands::Ssg { ssg } => {
278            ssg.handle(project)?;
279        }
280        Commands::Build { ignore_cache } => {
281            let env_file = Path::new(project).join(".env");
282            if env_file.exists() {
283                dotenv::from_path(env_file)?;
284            }
285
286            ordinary_build::build(project, *ignore_cache, "ordinary")?;
287        }
288        Commands::Publish => {
289            let env_file = Path::new(project).join(".env");
290            if env_file.exists() {
291                dotenv::from_path(env_file)?;
292            }
293
294            ordinary_build::build(project, true, GENERATOR)?;
295
296            let account = get_current_account(cli.insecure)?;
297            let client = OrdinaryApiClient::new(
298                &account.host,
299                &account.name,
300                api_domain,
301                cli.danger_accept_invalid_certs,
302                USER_AGENT,
303                true,
304            )?;
305
306            let config = OrdinaryConfig::get(project)?;
307
308            client.deploy(project).await?;
309
310            if config.content.is_some() {
311                client.update(project).await?;
312            }
313
314            if config.assets.is_some() {
315                client.write_all(project).await?;
316            }
317
318            if config.templates.is_some() {
319                client.upload_all(project).await?;
320            }
321
322            if config.actions.is_some() {
323                client.install_all(project).await?;
324            }
325        }
326        Commands::Templates { templates } => {
327            templates
328                .handle(
329                    api_domain,
330                    cli.danger_accept_invalid_certs,
331                    project,
332                    cli.insecure,
333                )
334                .await?;
335        }
336        Commands::Content { content } => {
337            content
338                .handle(
339                    api_domain,
340                    cli.danger_accept_invalid_certs,
341                    project,
342                    cli.insecure,
343                )
344                .await?;
345        }
346        Commands::Assets { assets } => {
347            assets
348                .handle(
349                    api_domain,
350                    cli.danger_accept_invalid_certs,
351                    project,
352                    cli.insecure,
353                )
354                .await?;
355        }
356        Commands::Models { models } => {
357            models
358                .handle(
359                    api_domain,
360                    cli.danger_accept_invalid_certs,
361                    project,
362                    cli.insecure,
363                )
364                .await?;
365        }
366        Commands::Actions { actions } => {
367            actions
368                .handle(
369                    api_domain,
370                    cli.danger_accept_invalid_certs,
371                    project,
372                    cli.insecure,
373                )
374                .await?;
375        }
376        Commands::Integrations { integrations } => {
377            integrations.handle(project)?;
378        }
379        Commands::Accounts { accounts } => {
380            accounts
381                .handle(api_domain, cli.danger_accept_invalid_certs, cli.insecure)
382                .await?;
383        }
384        Commands::App { app } => {
385            let env_file = Path::new(project).join(".env");
386            if env_file.exists() {
387                dotenv::from_path(env_file)?;
388            }
389
390            app.handle(
391                api_domain,
392                cli.danger_accept_invalid_certs,
393                project,
394                cli.insecure,
395            )
396            .await?;
397        }
398        Commands::Secrets { secrets } => {
399            secrets
400                .handle(
401                    api_domain,
402                    cli.danger_accept_invalid_certs,
403                    project,
404                    cli.insecure,
405                )
406                .await?;
407        }
408        Commands::Root { root } => {
409            root.handle(api_domain, cli.danger_accept_invalid_certs, cli.insecure)
410                .await?;
411        }
412        Commands::Doctor { fix } => {
413            ordinary_doctor::doctor(&fix.clone().unwrap_or(vec![]))?;
414        }
415        Commands::Utils { utils } => {
416            utils.handle()?;
417        }
418        Commands::Start { .. } => unreachable!("checked in main.rs"),
419    }
420
421    Ok(())
422}