asimov_cli/commands/module/
config.rs1use crate::BoxError;
4use asimov_env::paths::asimov_root;
5use asimov_module::{ConfigurationVariable, ModuleManifest, ModuleName};
6use clientele::{
7 StandardOptions,
8 SysexitsError::*,
9 crates::clap::{Subcommand, builder::PossibleValuesParser},
10};
11use color_print::ceprintln;
12use std::{path::PathBuf, string::String, vec::Vec};
13
14#[derive(Debug, Subcommand)]
15pub enum ConfigCommand {
16 #[clap(alias = "list")]
18 Show {
19 name: ModuleName,
21
22 #[arg(value_name = "FORMAT", short = 'o', long)]
24 #[arg(value_parser = PossibleValuesParser::new(["cli", "json"]), hide_possible_values = true)]
25 output: Option<String>,
26 },
27
28 Get {
30 name: ModuleName,
32
33 key: String,
35
36 #[arg(long)]
38 stored: bool,
39 },
40
41 Set {
43 name: ModuleName,
45
46 #[arg(value_name = "KEY=VALUE", required_unless_present = "from_json")]
49 assignments: Vec<String>,
50
51 #[arg(long, conflicts_with = "from_json")]
54 stdin: bool,
55
56 #[arg(long, conflicts_with = "assignments")]
58 from_json: bool,
59 },
60
61 Unset {
63 name: ModuleName,
65
66 #[arg(required_unless_present = "all")]
68 keys: Vec<String>,
69
70 #[arg(long, conflicts_with = "keys")]
72 all: bool,
73 },
74
75 Setup {
77 name: ModuleName,
79 },
80}
81
82impl ConfigCommand {
83 pub async fn run(&self, flags: &StandardOptions) -> Result<(), BoxError> {
84 use ConfigCommand::*;
85 match self {
86 Show { name, output } => {
87 show::show(name, output.as_deref().unwrap_or("cli"), flags).await
88 },
89 Get { name, key, stored } => get::get(name, key, *stored, flags).await,
90 Set {
91 name,
92 assignments,
93 stdin,
94 from_json,
95 } => set::set(name, assignments, *stdin, *from_json, flags).await,
96 Unset { name, keys, all } => unset::unset(name, keys, *all, flags).await,
97 Setup { name } => setup::setup(name, flags).await,
98 }
99 }
100}
101
102mod get;
103mod set;
104mod setup;
105mod show;
106mod unset;
107
108pub(super) const MASK: &str = "******";
111
112pub(super) struct Module {
114 pub name: ModuleName,
115 pub manifest: ModuleManifest,
116 pub profile: &'static str,
117 pub conf_dir: PathBuf,
118}
119
120pub(super) async fn open(module_name: &ModuleName) -> Result<Module, BoxError> {
123 let manifest = asimov_registry::Registry::default()
124 .read_manifest(module_name)
125 .await
126 .map_err(|e| {
127 tracing::error!("failed to read manifest for module `{module_name}`: {e}");
128 if let asimov_registry::error::ManifestError::NotInstalled = e {
129 ceprintln!(
130 "<s,dim>hint:</> Check if the module is installed with: <s>asimov module list</>"
131 );
132 }
133 EX_UNAVAILABLE
134 })?
135 .manifest;
136
137 let is_valid_name = |name: &str| {
140 !name.is_empty()
141 && !name.starts_with('.')
142 && name
143 .chars()
144 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
145 };
146
147 let variables = manifest
148 .config
149 .as_ref()
150 .map(|c| c.variables.as_slice())
151 .unwrap_or_default();
152
153 if let Some(var) = variables.iter().find(|var| !is_valid_name(&var.name)) {
154 ceprintln!(
155 "<s,r>error:</> module <s>{module_name}</> declares an invalid configuration variable name: `{}`",
156 var.name
157 );
158 return Err(EX_DATAERR.into());
159 }
160
161 let profile = "default"; let conf_dir = asimov_root()
163 .join("configs")
164 .join(profile)
165 .join(module_name.as_str());
166
167 Ok(Module {
168 name: module_name.clone(),
169 manifest,
170 profile,
171 conf_dir,
172 })
173}
174
175impl Module {
176 pub fn variables(&self) -> &[ConfigurationVariable] {
177 self.manifest
178 .config
179 .as_ref()
180 .map(|c| c.variables.as_slice())
181 .unwrap_or_default()
182 }
183
184 pub fn variable(&self, key: &str) -> Result<&ConfigurationVariable, BoxError> {
186 self.variables()
187 .iter()
188 .find(|var| var.name == key)
189 .ok_or_else(|| {
190 ceprintln!(
191 "<s,r>error:</> `{key}` is not the name of a configuration variable for <s>{}</> module",
192 self.name
193 );
194 EX_USAGE.into()
195 })
196 }
197
198 pub fn require_variables(&self) -> Result<&[ConfigurationVariable], BoxError> {
201 let variables = self.variables();
202 if variables.is_empty() {
203 ceprintln!(
204 "<s,r>error:</> module <s>{}</> has no configuration variables",
205 self.name
206 );
207 return Err(EX_USAGE.into());
208 }
209 Ok(variables)
210 }
211
212 pub fn var_file(&self, key: &str) -> PathBuf {
213 self.conf_dir.join(key)
214 }
215
216 pub async fn source(&self, var: &ConfigurationVariable) -> Source {
219 if let Some(env_name) = var.environment.as_deref()
220 && std::env::var(env_name).is_ok()
221 {
222 return Source::Environment;
223 }
224 if tokio::fs::try_exists(self.var_file(&var.name))
225 .await
226 .unwrap_or(false)
227 {
228 return Source::Stored;
229 }
230 if var.default_value.is_some() {
231 return Source::Default;
232 }
233 Source::Unset
234 }
235
236 pub async fn create_conf_dir(&self) -> tokio::io::Result<()> {
237 tokio::fs::create_dir_all(&self.conf_dir)
238 .await
239 .inspect_err(|e| {
240 tracing::error!(
241 "failed to create configuration directory for module `{}`: {e}",
242 self.name
243 )
244 })
245 }
246
247 #[cfg(not(unix))]
248 pub async fn set_permissions(&self) -> tokio::io::Result<()> {
249 Ok(())
250 }
251
252 #[cfg(unix)]
253 pub async fn set_permissions(&self) -> tokio::io::Result<()> {
254 async {
255 use std::os::unix::fs::PermissionsExt;
256
257 let metadata = match tokio::fs::symlink_metadata(&self.conf_dir).await {
258 Ok(metadata) => metadata,
259 Err(e) if e.kind() == tokio::io::ErrorKind::NotFound => return Ok(()),
260 Err(e) => return Err(e),
261 };
262 if metadata.is_symlink() {
263 return Ok(());
264 }
265
266 let mut directories = vec![self.conf_dir.clone()];
267 while let Some(directory) = directories.pop() {
268 tokio::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))
269 .await?;
270
271 let mut entries = tokio::fs::read_dir(&directory).await?;
272 while let Some(entry) = entries.next_entry().await? {
273 let path = entry.path();
274 let metadata = tokio::fs::symlink_metadata(&path).await?;
275 if metadata.is_dir() {
276 directories.push(path);
277 } else if metadata.is_file() {
278 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
279 .await?;
280 }
281 }
282 }
283
284 Ok(())
285 }
286 .await
287 .inspect_err(|e| {
288 tracing::error!(
289 "failed to set configuration permissions for module `{}`: {e}",
290 self.name
291 )
292 })
293 }
294}
295
296#[derive(Clone, Copy, PartialEq, Eq)]
297pub(super) enum Source {
298 Environment,
299 Stored,
300 Default,
301 Unset,
302}
303
304impl Source {
305 pub fn as_str(self) -> &'static str {
306 match self {
307 Source::Environment => "environment",
308 Source::Stored => "stored",
309 Source::Default => "default",
310 Source::Unset => "unset",
311 }
312 }
313}
314
315pub(super) fn prompt_for_value(prompt: String, secret: bool) -> Result<String, BoxError> {
319 let input = if secret {
320 dialoguer::Password::new()
321 .with_prompt(prompt)
322 .allow_empty_password(true)
323 .interact()
324 } else {
325 dialoguer::Input::<String>::new()
326 .with_prompt(prompt)
327 .allow_empty(true)
328 .interact_text()
329 };
330
331 input.map_err(|e| {
332 tracing::error!("failed to read a value from the terminal: {e}");
333 EX_IOERR.into()
334 })
335}