mirror of
https://github.com/mii443/wasmer.git
synced 2025-12-08 21:58:20 +00:00
691 lines
22 KiB
Rust
691 lines
22 KiB
Rust
//! Create a new Edge app.
|
|
|
|
use crate::{
|
|
commands::AsyncCliCommand,
|
|
opts::{ApiOpts, ItemFormatOpts, WasmerEnv},
|
|
utils::load_package_manifest,
|
|
};
|
|
use anyhow::Context;
|
|
use colored::Colorize;
|
|
use dialoguer::{theme::ColorfulTheme, Confirm, Select};
|
|
use is_terminal::IsTerminal;
|
|
use std::{collections::HashMap, env, io::Cursor, path::PathBuf, str::FromStr};
|
|
use wasmer_api::{types::AppTemplate, WasmerClient};
|
|
use wasmer_config::{app::AppConfigV1, package::PackageSource};
|
|
|
|
use super::{deploy::CmdAppDeploy, util::login_user};
|
|
|
|
async fn write_app_config(app_config: &AppConfigV1, dir: Option<PathBuf>) -> anyhow::Result<()> {
|
|
let raw_app_config = app_config.clone().to_yaml()?;
|
|
|
|
let app_dir = match dir {
|
|
Some(dir) => dir,
|
|
None => std::env::current_dir()?,
|
|
};
|
|
|
|
let app_config_path = app_dir.join(AppConfigV1::CANONICAL_FILE_NAME);
|
|
std::fs::write(&app_config_path, raw_app_config).with_context(|| {
|
|
format!(
|
|
"could not write app config to '{}'",
|
|
app_config_path.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Create a new Edge app.
|
|
#[derive(clap::Parser, Debug)]
|
|
pub struct CmdAppCreate {
|
|
/// A reference to the template to use.
|
|
///
|
|
/// It can be either an URL to a github repository - like
|
|
/// `https://github.com/wasmer-examples/php-wasmer-starter` - or the name of a template that
|
|
/// will be searched for in the selected registry, like `astro-starter`.
|
|
#[clap(
|
|
long,
|
|
conflicts_with = "package",
|
|
conflicts_with = "use_local_manifest"
|
|
)]
|
|
pub template: Option<String>,
|
|
|
|
/// Name of the package to use.
|
|
#[clap(
|
|
long,
|
|
conflicts_with = "template",
|
|
conflicts_with = "use_local_manifest"
|
|
)]
|
|
pub package: Option<String>,
|
|
|
|
/// Whether or not to search (and use) a local manifest.
|
|
#[clap(long, conflicts_with = "template", conflicts_with = "package")]
|
|
pub use_local_manifest: bool,
|
|
|
|
/// Whether or not to deploy the application once it is created.
|
|
///
|
|
/// If selected, this might entail the step of publishing the package related to the
|
|
/// application. By default, the application is not deployed and the package is not published.
|
|
#[clap(long = "deploy")]
|
|
pub deploy_app: bool,
|
|
|
|
/// Skip local schema validation.
|
|
#[clap(long)]
|
|
pub no_validate: bool,
|
|
|
|
/// Do not prompt for user input.
|
|
#[clap(long, default_value_t = !std::io::stdin().is_terminal())]
|
|
pub non_interactive: bool,
|
|
|
|
/// Do not interact with any APIs.
|
|
#[clap(long)]
|
|
pub offline: bool,
|
|
|
|
/// The owner of the app.
|
|
#[clap(long)]
|
|
pub owner: Option<String>,
|
|
|
|
/// The name of the app (can be changed later)
|
|
#[clap(long = "name")]
|
|
pub app_name: Option<String>,
|
|
|
|
/// The path to the directory where the config file for the application will be written to.
|
|
#[clap(long = "path")]
|
|
pub app_dir_path: Option<PathBuf>,
|
|
|
|
/// Do not wait for the app to become reachable if deployed.
|
|
#[clap(long)]
|
|
pub no_wait: bool,
|
|
|
|
// Common args.
|
|
#[clap(flatten)]
|
|
#[allow(missing_docs)]
|
|
pub api: ApiOpts,
|
|
|
|
#[clap(flatten)]
|
|
pub env: WasmerEnv,
|
|
|
|
#[clap(flatten)]
|
|
#[allow(missing_docs)]
|
|
pub fmt: ItemFormatOpts,
|
|
|
|
/// Name to use when creating a new package from a template.
|
|
#[clap(long)]
|
|
pub new_package_name: Option<String>,
|
|
|
|
/// Don't print any message.
|
|
#[clap(long)]
|
|
pub quiet: bool,
|
|
}
|
|
|
|
impl CmdAppCreate {
|
|
#[inline]
|
|
fn get_app_config(&self, owner: &str, name: &str, package: &str) -> AppConfigV1 {
|
|
AppConfigV1 {
|
|
name: String::from(name),
|
|
owner: Some(String::from(owner)),
|
|
package: PackageSource::from_str(package).unwrap(),
|
|
app_id: None,
|
|
domains: None,
|
|
env: HashMap::new(),
|
|
cli_args: None,
|
|
capabilities: None,
|
|
scheduled_tasks: None,
|
|
volumes: None,
|
|
health_checks: None,
|
|
debug: None,
|
|
scaling: None,
|
|
extra: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
async fn get_app_name(&self) -> anyhow::Result<String> {
|
|
if let Some(name) = &self.app_name {
|
|
return Ok(name.clone());
|
|
}
|
|
|
|
if self.non_interactive {
|
|
// if not interactive we can't prompt the user to choose the owner of the app.
|
|
anyhow::bail!("No app name specified: use --name <app_name>");
|
|
}
|
|
|
|
let default_name = env::current_dir().ok().and_then(|dir| {
|
|
dir.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.map(|s| s.to_owned())
|
|
});
|
|
crate::utils::prompts::prompt_for_ident(
|
|
"What should be the name of the app?",
|
|
default_name.as_deref(),
|
|
)
|
|
}
|
|
|
|
async fn get_owner(&self, client: &WasmerClient) -> anyhow::Result<String> {
|
|
if let Some(owner) = &self.owner {
|
|
return Ok(owner.clone());
|
|
}
|
|
|
|
if self.non_interactive {
|
|
// if not interactive we can't prompt the user to choose the owner of the app.
|
|
anyhow::bail!("No owner specified: use --owner <owner>");
|
|
}
|
|
|
|
if !self.offline {
|
|
let user = wasmer_api::query::current_user_with_namespaces(&client, None).await?;
|
|
crate::utils::prompts::prompt_for_namespace(
|
|
"Who should own this app?",
|
|
None,
|
|
Some(&user),
|
|
)
|
|
} else {
|
|
anyhow::bail!(
|
|
"Please, user `wasmer login` before deploying an app or use the --owner <owner>
|
|
flag to specify the owner of the app to deploy."
|
|
)
|
|
}
|
|
}
|
|
|
|
async fn create_from_local_manifest(
|
|
&self,
|
|
owner: &str,
|
|
app_name: &str,
|
|
) -> anyhow::Result<bool> {
|
|
if !self.use_local_manifest && self.non_interactive {
|
|
return Ok(false);
|
|
}
|
|
|
|
let app_dir = match &self.app_dir_path {
|
|
Some(dir) => PathBuf::from(dir),
|
|
None => std::env::current_dir()?,
|
|
};
|
|
|
|
let (manifest_path, _) = if let Some(res) = load_package_manifest(&app_dir)? {
|
|
res
|
|
} else if self.use_local_manifest {
|
|
anyhow::bail!("The --use_local_manifest flag was passed, but path {} does not contain a valid package manifest.", app_dir.display())
|
|
} else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let ask_confirmation = || {
|
|
eprintln!(
|
|
"A package manifest was found in path {}.",
|
|
&manifest_path.display()
|
|
);
|
|
let theme = dialoguer::theme::ColorfulTheme::default();
|
|
Confirm::with_theme(&theme)
|
|
.with_prompt("Use it for the app?")
|
|
.interact()
|
|
};
|
|
|
|
if self.use_local_manifest || ask_confirmation()? {
|
|
let app_config = self.get_app_config(owner, app_name, ".");
|
|
write_app_config(&app_config, self.app_dir_path.clone()).await?;
|
|
self.try_deploy(owner, app_name).await?;
|
|
return Ok(true);
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
async fn create_from_package(&self, owner: &str, app_name: &str) -> anyhow::Result<bool> {
|
|
if self.template.is_some() {
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(pkg) = &self.package {
|
|
let app_config = self.get_app_config(owner, app_name, pkg);
|
|
write_app_config(&app_config, self.app_dir_path.clone()).await?;
|
|
self.try_deploy(owner, app_name).await?;
|
|
return Ok(true);
|
|
} else if !self.non_interactive {
|
|
let theme = ColorfulTheme::default();
|
|
let package_name: String = dialoguer::Input::with_theme(&theme)
|
|
.with_prompt("What is the name of the package?")
|
|
.interact()?;
|
|
|
|
let app_config = self.get_app_config(owner, app_name, &package_name);
|
|
write_app_config(&app_config, self.app_dir_path.clone()).await?;
|
|
self.try_deploy(owner, app_name).await?;
|
|
return Ok(true);
|
|
} else {
|
|
eprintln!(
|
|
"{}: the app creation process did not produce any local change.",
|
|
"Warning".bold().yellow()
|
|
);
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
// A utility function used to fetch the URL of the template to use.
|
|
async fn get_template_url(&self, client: &WasmerClient) -> anyhow::Result<url::Url> {
|
|
let mut url = if let Some(template) = &self.template {
|
|
if let Ok(url) = url::Url::parse(&template) {
|
|
url
|
|
} else if let Some(template) =
|
|
wasmer_api::query::fetch_app_template_from_slug(client, template.clone()).await?
|
|
{
|
|
url::Url::parse(&template.repo_url)?
|
|
} else {
|
|
anyhow::bail!("Template '{}' not found in the registry", template)
|
|
}
|
|
} else {
|
|
if self.non_interactive {
|
|
anyhow::bail!("No template selected")
|
|
}
|
|
|
|
let templates: Vec<AppTemplate> =
|
|
wasmer_api::query::fetch_app_templates(client, String::new(), 20)
|
|
.await?
|
|
.ok_or(anyhow::anyhow!("No template received from the backend"))?
|
|
.edges
|
|
.into_iter()
|
|
.filter(|v| v.is_some())
|
|
.map(|v| v.unwrap())
|
|
.filter(|v| v.node.is_some())
|
|
.map(|v| v.node.unwrap())
|
|
.collect();
|
|
|
|
let theme = ColorfulTheme::default();
|
|
let items = templates
|
|
.iter()
|
|
.map(|t| {
|
|
format!(
|
|
"{}{}{}\n",
|
|
t.name.bold(),
|
|
if t.language.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" {}", t.language.dimmed())
|
|
},
|
|
format!(
|
|
"\n {} {}",
|
|
"demo url:".bold().dimmed(),
|
|
t.demo_url.dimmed()
|
|
)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
// items.sort();
|
|
|
|
let dialog = dialoguer::Select::with_theme(&theme)
|
|
.with_prompt(format!("Select a template ({} available)", items.len()))
|
|
.items(&items)
|
|
.max_length(3)
|
|
.clear(true)
|
|
.report(false)
|
|
.default(0);
|
|
|
|
let selection = dialog.interact()?;
|
|
|
|
let selected_template = templates
|
|
.get(selection)
|
|
.ok_or(anyhow::anyhow!("Invalid selection!"))?;
|
|
|
|
if !self.quiet {
|
|
eprintln!(
|
|
"{} {} {} {} ({} {})",
|
|
"✔".green().bold(),
|
|
"Selected template".bold(),
|
|
"·".dimmed(),
|
|
selected_template.name.green().bold(),
|
|
"demo url".dimmed().bold(),
|
|
selected_template.demo_url.dimmed()
|
|
)
|
|
}
|
|
|
|
url::Url::parse(&selected_template.repo_url)?
|
|
};
|
|
|
|
if url.path().contains("archive/refs/heads") {
|
|
return Ok(url);
|
|
} else if url.path().contains("/zipball/") {
|
|
return Ok(url);
|
|
} else {
|
|
let old_path = url.path();
|
|
url.set_path(&format!("{old_path}/zipball/main"));
|
|
return Ok(url);
|
|
}
|
|
}
|
|
|
|
async fn create_from_template(
|
|
&self,
|
|
client: &WasmerClient,
|
|
owner: &str,
|
|
app_name: &str,
|
|
) -> anyhow::Result<bool> {
|
|
let url = self.get_template_url(client).await?;
|
|
|
|
tracing::info!("Downloading template from url {url}");
|
|
|
|
let output_path = if let Some(path) = &self.app_dir_path {
|
|
path.clone()
|
|
} else {
|
|
PathBuf::from(".").canonicalize()?
|
|
};
|
|
|
|
let pb = indicatif::ProgressBar::new_spinner();
|
|
|
|
pb.enable_steady_tick(std::time::Duration::from_millis(500));
|
|
pb.set_style(
|
|
indicatif::ProgressStyle::with_template("{spinner:.magenta} {msg}")
|
|
.unwrap()
|
|
.tick_strings(&["✶", "✸", "✹", "✺", "✹", "✷"]),
|
|
);
|
|
|
|
pb.set_message("Downloading package..");
|
|
|
|
let response = reqwest::get(url).await?;
|
|
let bytes = response.bytes().await?;
|
|
pb.set_message("Unpacking the template..");
|
|
let cursor = Cursor::new(bytes);
|
|
let mut archive = zip::ZipArchive::new(cursor)?;
|
|
|
|
// Extract the files to the output path
|
|
for entry in 0..archive.len() {
|
|
let mut entry = archive
|
|
.by_index(entry)
|
|
.context(format!("Getting the archive entry #{entry}"))?;
|
|
|
|
let path = entry.mangled_name();
|
|
|
|
let path: PathBuf = {
|
|
let mut components = path.components();
|
|
components.next();
|
|
components.collect()
|
|
};
|
|
|
|
if path.to_str().unwrap_or_default().contains(".github") {
|
|
continue;
|
|
}
|
|
|
|
let path = output_path.join(path);
|
|
|
|
if let Some(parent) = path.parent() {
|
|
if !parent.exists() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
}
|
|
|
|
if !path.exists() {
|
|
// AsyncRead not implemented for entry..
|
|
if entry.is_file() {
|
|
let mut outfile = std::fs::File::create(&path)?;
|
|
std::io::copy(&mut entry, &mut outfile)?;
|
|
} else {
|
|
std::fs::create_dir(path)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
pb.finish();
|
|
|
|
let app_yaml_path = output_path.join(AppConfigV1::CANONICAL_FILE_NAME);
|
|
|
|
if app_yaml_path.exists() && app_yaml_path.is_file() {
|
|
let contents = tokio::fs::read_to_string(&app_yaml_path).await?;
|
|
let contents = format!("{contents}\nname: {app_name}");
|
|
let mut app_config = AppConfigV1::parse_yaml(&contents)?;
|
|
app_config.owner = Some(owner.to_string());
|
|
let raw_app = serde_yaml::to_string(&app_config)?;
|
|
tokio::fs::write(&app_yaml_path, raw_app).await?;
|
|
}
|
|
|
|
let build_md_path = output_path.join("BUILD.md");
|
|
if build_md_path.exists() {
|
|
let contents = tokio::fs::read_to_string(build_md_path).await?;
|
|
eprintln!(
|
|
"{}: {}
|
|
{}",
|
|
"NOTE".bold(),
|
|
"The selected template has a `BUILD.md` file.
|
|
This means there are likely additional build
|
|
steps that you need to perform before deploying
|
|
the app:\n"
|
|
.bold(),
|
|
contents
|
|
);
|
|
} else {
|
|
self.try_deploy(owner, app_name).await?;
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
async fn try_deploy(&self, owner: &str, app_name: &str) -> anyhow::Result<()> {
|
|
let interactive = !self.non_interactive;
|
|
let theme = dialoguer::theme::ColorfulTheme::default();
|
|
|
|
if self.deploy_app
|
|
|| (interactive
|
|
&& Confirm::with_theme(&theme)
|
|
.with_prompt("Do you want to deploy the app now?")
|
|
.interact()?)
|
|
{
|
|
let cmd_deploy = CmdAppDeploy {
|
|
quiet: false,
|
|
api: self.api.clone(),
|
|
env: self.env.clone(),
|
|
fmt: ItemFormatOpts {
|
|
format: self.fmt.format,
|
|
},
|
|
no_validate: false,
|
|
non_interactive: self.non_interactive,
|
|
publish_package: true,
|
|
path: self.app_dir_path.clone(),
|
|
no_wait: self.no_wait,
|
|
no_default: false,
|
|
no_persist_id: false,
|
|
owner: Some(String::from(owner)),
|
|
app_name: Some(app_name.into()),
|
|
bump: false,
|
|
template: self.template.clone(),
|
|
package: self.package.clone(),
|
|
use_local_manifest: self.use_local_manifest,
|
|
};
|
|
cmd_deploy.run_async().await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl AsyncCliCommand for CmdAppCreate {
|
|
type Output = ();
|
|
|
|
async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
|
|
let client = login_user(
|
|
&self.api,
|
|
&self.env,
|
|
!self.non_interactive,
|
|
"retrieve informations about the owner of the app",
|
|
)
|
|
.await?;
|
|
|
|
// Get the future owner of the app.
|
|
let owner = self.get_owner(&client).await?;
|
|
|
|
// Get the name of the app.
|
|
let app_name = self.get_app_name().await?;
|
|
|
|
if !self.create_from_local_manifest(&owner, &app_name).await? {
|
|
if self.template.is_some() {
|
|
self.create_from_template(&client, &owner, &app_name)
|
|
.await?;
|
|
} else if self.package.is_some() {
|
|
self.create_from_package(&owner, &app_name).await?;
|
|
} else if !self.non_interactive {
|
|
let theme = ColorfulTheme::default();
|
|
let choice = Select::with_theme(&theme)
|
|
.with_prompt("What would you like to deploy?")
|
|
.items(&["Start with a template", "Choose an existing package"])
|
|
.default(0)
|
|
.interact()?;
|
|
match choice {
|
|
0 => {
|
|
self.create_from_template(&client, &owner, &app_name)
|
|
.await?
|
|
}
|
|
1 => self.create_from_package(&owner, &app_name).await?,
|
|
x => panic!("unhandled selection {x}"),
|
|
};
|
|
} else {
|
|
eprintln!("Warning: the creation process did not produce any result.");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_app_create_static_site_offline() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let cmd = CmdAppCreate {
|
|
quiet: true,
|
|
template: Some(AppType::StaticWebsite),
|
|
deploy_app: false,
|
|
no_validate: false,
|
|
non_interactive: true,
|
|
offline: true,
|
|
owner: Some("testuser".to_string()),
|
|
app_name: Some("static-site-1".to_string()),
|
|
app_dir_path: Some(dir.path().to_owned()),
|
|
no_wait: true,
|
|
api: ApiOpts::default(),
|
|
fmt: ItemFormatOpts::default(),
|
|
package: Some("testuser/static-site-1@0.1.0".to_string()),
|
|
use_local_manifest: false,
|
|
new_package_name: None,
|
|
env: WasmerEnv::default(),
|
|
};
|
|
cmd.run_async().await.unwrap();
|
|
|
|
let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap();
|
|
assert_eq!(
|
|
app,
|
|
r#"kind: wasmer.io/App.v0
|
|
name: static-site-1
|
|
owner: testuser
|
|
package: testuser/static-site-1@^0.1.0
|
|
debug: false
|
|
"#,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_app_create_offline_with_package() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let cmd = CmdAppCreate {
|
|
quiet: true,
|
|
template: Some(AppType::HttpServer),
|
|
deploy_app: false,
|
|
no_validate: false,
|
|
non_interactive: true,
|
|
offline: true,
|
|
owner: Some("wasmer".to_string()),
|
|
app_name: Some("testapp".to_string()),
|
|
app_dir_path: Some(dir.path().to_owned()),
|
|
no_wait: true,
|
|
api: ApiOpts::default(),
|
|
fmt: ItemFormatOpts::default(),
|
|
package: Some("wasmer/testpkg".to_string()),
|
|
use_local_manifest: false,
|
|
new_package_name: None,
|
|
env: WasmerEnv::default(),
|
|
};
|
|
cmd.run_async().await.unwrap();
|
|
|
|
let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap();
|
|
assert_eq!(
|
|
app,
|
|
r#"kind: wasmer.io/App.v0
|
|
name: testapp
|
|
owner: wasmer
|
|
package: wasmer/testpkg
|
|
debug: false
|
|
"#,
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn test_app_create_js_worker() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let cmd = CmdAppCreate {
|
|
quiet: true,
|
|
template: Some(AppType::JsWorker),
|
|
deploy_app: false,
|
|
no_validate: false,
|
|
non_interactive: true,
|
|
offline: true,
|
|
owner: Some("wasmer".to_string()),
|
|
app_name: Some("test-js-worker".to_string()),
|
|
app_dir_path: Some(dir.path().to_owned()),
|
|
no_wait: true,
|
|
api: ApiOpts::default(),
|
|
fmt: ItemFormatOpts::default(),
|
|
package: Some("wasmer/test-js-worker".to_string()),
|
|
use_local_manifest: false,
|
|
new_package_name: None,
|
|
env: WasmerEnv::default(),
|
|
};
|
|
cmd.run_async().await.unwrap();
|
|
|
|
let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap();
|
|
assert_eq!(
|
|
app,
|
|
r#"kind: wasmer.io/App.v0
|
|
name: test-js-worker
|
|
owner: wasmer
|
|
package: wasmer/test-js-worker
|
|
cli_args:
|
|
- /src/index.js
|
|
debug: false
|
|
"#,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_app_create_py_worker() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let cmd = CmdAppCreate {
|
|
quiet: true,
|
|
template: Some(AppType::PyApplication),
|
|
deploy_app: false,
|
|
no_validate: false,
|
|
non_interactive: true,
|
|
offline: true,
|
|
owner: Some("wasmer".to_string()),
|
|
app_name: Some("test-py-worker".to_string()),
|
|
app_dir_path: Some(dir.path().to_owned()),
|
|
no_wait: true,
|
|
api: ApiOpts::default(),
|
|
fmt: ItemFormatOpts::default(),
|
|
package: Some("wasmer/test-py-worker".to_string()),
|
|
use_local_manifest: false,
|
|
new_package_name: None,
|
|
env: WasmerEnv::default(),
|
|
};
|
|
cmd.run_async().await.unwrap();
|
|
|
|
let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap();
|
|
assert_eq!(
|
|
app,
|
|
r#"kind: wasmer.io/App.v0
|
|
name: test-py-worker
|
|
owner: wasmer
|
|
package: wasmer/test-py-worker
|
|
cli_args:
|
|
- /src/main.py
|
|
debug: false
|
|
"#,
|
|
);
|
|
}
|
|
}
|