use std::io::Write;
use anyhow::{Context, Result, bail};
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde_json::{Map, Value, json};
use termcolor::{ColorChoice, StandardStream, WriteColor};
use super::feature_settings;
use crate::{
CliCommand,
core::command::command,
managed::{
client::{Missing, patch_json, print_dryrun, require_managed_mode, resolve_managed_auth},
types::{AppTemplate, CLUSTER_TYPES, TEMPLATE_STATUSES},
},
};
#[derive(Debug, Default)]
pub(super) struct TemplateUpdate<'a> {
pub(super) name: Option<&'a String>,
pub(super) description: Option<&'a String>,
pub(super) status: Option<&'a str>,
pub(super) stripe_product: Option<&'a String>,
pub(super) cluster_type: Option<&'a str>,
pub(super) base_domain: Option<&'a String>,
pub(super) frontend_domain: Option<Option<&'a String>>,
pub(super) default_instance_size: Option<Option<&'a String>>,
pub(super) supports_key_rotation: Option<bool>,
pub(super) auto_approve_launches: Option<bool>,
pub(super) source_repo: Option<&'a String>,
pub(super) model_gateway: Option<Value>,
pub(super) feature_settings: Vec<(&'static str, Value)>,
pub(super) dryrun: bool,
pub(super) json: bool,
}
#[derive(Debug)]
pub(super) struct UpdateCommand;
impl UpdateCommand {
pub(super) fn new() -> Self {
Self
}
}
impl CliCommand for UpdateCommand {
fn command(&self) -> Command {
let base = command(
"update",
"Change a template's name, description, status, Stripe product, placement, or domains",
)
.long_about(
"Change a template's name, description, status, or Stripe product.\n\n\
Only the fields you pass are changed; everything else is left alone.\n\n\
--status is the important one. A template is created as `draft`, and\n\
`instance create` will only launch from a `published` template, so a template\n\
stays uninstantiable until its status is moved. `template publish-template` is\n\
the shorthand for exactly that, and is usually what you want.\n\n\
The three statuses:\n\
\x20 draft registered but not launchable — the state every template starts in\n\
\x20 published launchable; `instance create` accepts it\n\
\x20 retired no longer launchable for new instances\n\n\
--stripe-product records a Stripe product id against the template. Note that\n\
nothing in billing reads that id today, so setting it does not by itself cause\n\
anyone to be charged — it is stored for later use.\n\n\
Placement and addressing (the same fields the dashboard edits):\n\
\x20 --cluster-type where NEW instances run: org-shared (default),\n\
\x20 platform-shared, or dedicated. Existing instances\n\
\x20 do not move; a component's manifest hostingType wins.\n\
\x20 --base-domain the zone instances are hosted under (their API hosts).\n\
\x20 --frontend-domain the domain the product UI is served from; each\n\
\x20 instance's UI becomes <hostPrefix>.<frontend domain>\n\
\x20 and the claim page shows customers that link.\n\
\x20 --default-instance-size the compute tier new instances launch with\n\
\x20 (pico, nano, micro, small, ...).\n\
\x20 --supports-key-rotation declare that every service re-encrypts its data on\n\
\x20 boot from LEGACY_<KEY>S, so `instance rotate-keys`\n\
\x20 is allowed; --no-supports-key-rotation withdraws it.\n\
\x20 --auto-approve-launches true: launches of this template's instances no longer\n\
\x20 wait for an admin to approve their first deploy, so a\n\
\x20 signup backend can hand instances out unattended. It\n\
\x20 skips ONLY that managed-launch gate: an environment\n\
\x20 that requires deployment approval still parks the\n\
\x20 deploy. Admin only; who set it is recorded. false\n\
\x20 restores the gate.\n\
\x20 --source-repo the repository versions are built from. Re-point it\n\
\x20 when the code moves (e.g. to the customer's account);\n\
\x20 the org's GitHub App installation must read it.\n\n\
Platform-held features, for every instance of the product (read on each call,\n\
so a change applies at once). Each feature's setting is REPLACED AS A WHOLE:\n\
pass every field you want it to keep. One instance can differ (`managed\n\
instance model-gateway | sms-gateway | whatsapp | voice | email-gateway |\n\
payments-gateway`); `managed template gateway-settings` shows what is set.\n\
\x20 --gateway-* model gateway: models, monthly tokens, requests/minute\n\
\x20 --sms-* pool, monthly segments, texts/minute, promotional, off\n\
\x20 --whatsapp-* linked phone number id, sends/minute, off\n\
\x20 --voice-* flow catalog (name=contact-flow-id), concurrent calls, minutes\n\
\x20 --email-* recipients/day, messages/minute\n\
\x20 --payments-* application fee (percent and/or amount), Stripe calls/minute\n\
--clear-sms, --unlink-whatsapp, --disable-voice, --clear-email and\n\
--clear-payments return a feature to the platform defaults.",
)
.arg(
Arg::new("slug")
.long("slug")
.required(true)
.help("Slug of the template to update"),
)
.arg(
Arg::new("name")
.long("name")
.help("New human-readable template name"),
)
.arg(
Arg::new("description")
.long("description")
.help("New description shown alongside the template"),
)
.arg(
Arg::new("status")
.long("status")
.value_parser(TEMPLATE_STATUSES.to_vec())
.help("New status — `published` is what makes the template launchable"),
)
.arg(
Arg::new("stripe_product")
.long("stripe-product")
.help("Stripe product id to record against the template (not yet read by billing)"),
)
.arg(
Arg::new("cluster_type")
.long("cluster-type")
.value_parser(CLUSTER_TYPES.to_vec())
.help("Where NEW instances run: org-shared | platform-shared | dedicated"),
)
.arg(
Arg::new("base_domain")
.long("base-domain")
.help("The zone instances are hosted under, e.g. buildbespoke.app"),
)
.arg(
Arg::new("frontend_domain")
.long("frontend-domain")
.conflicts_with("clear_frontend_domain")
.help("Domain the product UI is served from; instance UIs become <hostPrefix>.<domain>"),
)
.arg(
Arg::new("clear_frontend_domain")
.long("clear-frontend-domain")
.help("Remove the frontend domain (instances stop advertising a UI link)")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("default_instance_size")
.long("default-instance-size")
.conflicts_with("clear_default_instance_size")
.help("Compute tier new instances launch with (pico, nano, micro, small, ...)"),
)
.arg(
Arg::new("clear_default_instance_size")
.long("clear-default-instance-size")
.help("Return new instances to the platform's default tier")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("supports_key_rotation")
.long("supports-key-rotation")
.conflicts_with("no_supports_key_rotation")
.help("Allow `instance rotate-keys`: every service re-encrypts on boot from LEGACY_<KEY>S")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no_supports_key_rotation")
.long("no-supports-key-rotation")
.help("Refuse `instance rotate-keys` for this product")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("auto_approve_launches")
.long("auto-approve-launches")
.value_name("true|false")
.value_parser(clap::value_parser!(bool))
.help("true: launches skip the managed first-deploy approval gate (admin only); false restores it"),
)
.arg(
Arg::new("source_repo")
.long("source-repo")
.help("https URL of the repository versions are built from, e.g. https://github.com/org/repo"),
)
.arg(
Arg::new("gateway_models")
.long("gateway-models")
.help("Model gateway: comma-separated catalog aliases instances may call (e.g. terra,luna)"),
)
.arg(
Arg::new("gateway_monthly_tokens")
.long("gateway-monthly-tokens")
.value_parser(clap::value_parser!(u64).range(1..))
.help("Model gateway: monthly token budget per instance"),
)
.arg(
Arg::new("gateway_rpm")
.long("gateway-rpm")
.value_parser(clap::value_parser!(u64).range(1..))
.help("Model gateway: requests per minute per instance"),
)
.arg(
Arg::new("disable_model_gateway")
.long("disable-model-gateway")
.help("Turn the model gateway off for this product")
.action(ArgAction::SetTrue)
.conflicts_with_all(["gateway_models", "gateway_monthly_tokens", "gateway_rpm"]),
);
feature_settings::add_args(base)
.arg(
Arg::new("dryrun")
.long("dryrun")
.help("Print the request that would be sent without sending it")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("json")
.long("json")
.help("Output raw JSON instead of formatted terminal output")
.action(ArgAction::SetTrue),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let slug = matches
.get_one::<String>("slug")
.context("--slug is required")?;
if let Some(repo) = matches.get_one::<String>("source_repo") {
if !repo.starts_with("https://") || repo.trim_end_matches('/').matches('/').count() != 4
{
bail!(
"--source-repo '{}' must be an https repository URL such as https://github.com/org/repo",
repo
);
}
}
let supports_key_rotation = if matches.get_flag("supports_key_rotation") {
Some(true)
} else if matches.get_flag("no_supports_key_rotation") {
Some(false)
} else {
None
};
let frontend_domain = if matches.get_flag("clear_frontend_domain") {
Some(None)
} else {
matches.get_one::<String>("frontend_domain").map(Some)
};
let default_instance_size = if matches.get_flag("clear_default_instance_size") {
Some(None)
} else {
matches.get_one::<String>("default_instance_size").map(Some)
};
let model_gateway = model_gateway_body(
matches.get_one::<String>("gateway_models"),
matches.get_one::<u64>("gateway_monthly_tokens").copied(),
matches.get_one::<u64>("gateway_rpm").copied(),
matches.get_flag("disable_model_gateway"),
)?;
let feature_settings = feature_settings::bodies(matches)?;
update_template(
slug,
TemplateUpdate {
name: matches.get_one::<String>("name"),
description: matches.get_one::<String>("description"),
status: matches.get_one::<String>("status").map(String::as_str),
stripe_product: matches.get_one::<String>("stripe_product"),
cluster_type: matches
.get_one::<String>("cluster_type")
.map(String::as_str),
base_domain: matches.get_one::<String>("base_domain"),
frontend_domain,
default_instance_size,
supports_key_rotation,
auto_approve_launches: matches.get_one::<bool>("auto_approve_launches").copied(),
source_repo: matches.get_one::<String>("source_repo"),
model_gateway,
feature_settings,
dryrun: matches.get_flag("dryrun"),
json: matches.get_flag("json"),
},
)
}
}
pub(super) fn model_gateway_body(
models: Option<&String>,
monthly_tokens: Option<u64>,
rpm: Option<u64>,
disable: bool,
) -> Result<Option<Value>> {
if disable {
return Ok(Some(json!({ "models": [] })));
}
let Some(models) = models else {
if monthly_tokens.is_some() || rpm.is_some() {
bail!(
"--gateway-monthly-tokens / --gateway-rpm replace the whole gateway setting, \
so they need --gateway-models too (the models the product may call)"
);
}
return Ok(None);
};
let aliases: Vec<&str> = models
.split(',')
.map(str::trim)
.filter(|alias| !alias.is_empty())
.collect();
if aliases.is_empty() {
bail!("--gateway-models is empty — to turn the gateway off, use --disable-model-gateway");
}
let mut setting = Map::new();
setting.insert("models".to_string(), json!(aliases));
if let Some(tokens) = monthly_tokens {
setting.insert("monthlyTokenBudget".to_string(), json!(tokens));
}
if let Some(rpm) = rpm {
setting.insert("requestsPerMinute".to_string(), json!(rpm));
}
Ok(Some(Value::Object(setting)))
}
pub(super) fn update_template(slug: &str, update: TemplateUpdate<'_>) -> Result<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let mut body = Map::new();
if let Some(name) = update.name {
body.insert("name".to_string(), json!(name));
}
if let Some(description) = update.description {
body.insert("description".to_string(), json!(description));
}
if let Some(status) = update.status {
body.insert("status".to_string(), json!(status));
}
if let Some(stripe_product) = update.stripe_product {
body.insert("stripeProductId".to_string(), json!(stripe_product));
}
if let Some(cluster_type) = update.cluster_type {
body.insert("clusterType".to_string(), json!(cluster_type));
}
if let Some(base_domain) = update.base_domain {
body.insert("baseDomain".to_string(), json!(base_domain));
}
if let Some(frontend_domain) = update.frontend_domain {
body.insert("frontendDomain".to_string(), json!(frontend_domain));
}
if let Some(default_instance_size) = update.default_instance_size {
body.insert(
"defaultInstanceSize".to_string(),
json!(default_instance_size),
);
}
if let Some(supports_key_rotation) = update.supports_key_rotation {
body.insert(
"supportsKeyRotation".to_string(),
json!(supports_key_rotation),
);
}
if let Some(auto_approve_launches) = update.auto_approve_launches {
body.insert(
"autoApproveLaunches".to_string(),
json!(auto_approve_launches),
);
}
if let Some(source_repo) = update.source_repo {
body.insert("sourceRepo".to_string(), json!(source_repo));
}
if let Some(model_gateway) = update.model_gateway.clone() {
body.insert("modelGateway".to_string(), model_gateway);
}
for (key, value) in &update.feature_settings {
body.insert((*key).to_string(), value.clone());
}
if body.is_empty() {
bail!(
"nothing to update — pass at least one of --name, --description, --status, \
--stripe-product, --cluster-type, --base-domain, --frontend-domain, \
--default-instance-size, --supports-key-rotation, --auto-approve-launches, \
--source-repo, or the \
--gateway-* / --sms-* / --whatsapp-* / --voice-* / --email-* / --payments-* \
flags (to publish a template, `forklaunch managed template \
publish-template --slug {}` is the shorthand)",
slug
);
}
let body = Value::Object(body);
let path = format!("/templates/{}", urlencoding::encode(slug));
if update.dryrun {
return print_dryrun("PATCH", &path, Some(&body));
}
let auth_mode = resolve_managed_auth()?;
require_managed_mode(&auth_mode)?;
let template: AppTemplate = patch_json(
&path,
body,
Missing::Resource(format!("template '{}'", slug)),
)?;
if update.json {
println!("{}", serde_json::to_string_pretty(&template)?);
return Ok(());
}
let new_status = template
.status
.as_deref()
.or(update.status)
.unwrap_or("unchanged");
log_ok!(
stdout,
"Updated template '{}' — status: {}",
slug,
new_status
);
if let Some(Some(domain)) = update.frontend_domain {
log_info!(
stdout,
"Each instance's UI is now https://<hostPrefix>.{} — point that wildcard at your frontend deployment (see the vercel-frontend skill).",
domain
);
}
match update.auto_approve_launches {
Some(true) => log_warn!(
stdout,
"Launches of '{}' no longer wait for approval. An environment that requires deployment approval still parks them.",
slug
),
Some(false) => log_info!(
stdout,
"Every launch of '{}' waits for an admin to approve its first deploy again.",
slug
),
None => {}
}
if new_status == "published" {
log_info!(
stdout,
"Instances can now be launched from it: forklaunch managed instance create --template {} --region <region>",
slug
);
} else if new_status == "draft" {
log_info!(
stdout,
"This template is still a draft, so `instance create` will refuse it. Publish it with: forklaunch managed template publish-template --slug {}",
slug
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_update_with_no_fields_is_refused_rather_than_reported_as_success() {
let error = update_template("clinic", TemplateUpdate::default()).unwrap_err();
let message = error.to_string();
assert!(message.contains("nothing to update"), "{}", message);
assert!(message.contains("publish-template"), "{}", message);
}
#[test]
fn publish_template_sends_exactly_a_published_status() {
let update = TemplateUpdate {
status: Some("published"),
dryrun: true,
..Default::default()
};
assert_eq!(update.status, Some("published"));
assert!(update.name.is_none());
assert!(update.description.is_none());
assert!(update.stripe_product.is_none());
assert!(update.frontend_domain.is_none());
}
#[test]
fn clearing_the_frontend_domain_sends_an_explicit_null() {
let update = TemplateUpdate {
frontend_domain: Some(None),
..Default::default()
};
assert_eq!(json!(update.frontend_domain.unwrap()), Value::Null);
}
#[test]
fn gateway_flags_build_the_whole_setting() {
let models = "terra, luna,".to_string();
assert_eq!(
model_gateway_body(Some(&models), Some(500_000), Some(30), false).unwrap(),
Some(json!({
"models": ["terra", "luna"],
"monthlyTokenBudget": 500_000,
"requestsPerMinute": 30
}))
);
assert_eq!(
model_gateway_body(None, None, None, true).unwrap(),
Some(json!({ "models": [] }))
);
assert_eq!(model_gateway_body(None, None, None, false).unwrap(), None);
}
#[test]
fn a_gateway_budget_without_models_is_refused_rather_than_turning_it_off() {
let error = model_gateway_body(None, Some(10), None, false).unwrap_err();
assert!(error.to_string().contains("--gateway-models"), "{}", error);
let empty = " , ".to_string();
let error = model_gateway_body(Some(&empty), None, None, false).unwrap_err();
assert!(
error.to_string().contains("--disable-model-gateway"),
"{}",
error
);
}
#[test]
fn auto_approve_launches_is_sent_as_a_boolean_and_counts_as_a_change() {
for value in [true, false] {
let update = TemplateUpdate {
auto_approve_launches: Some(value),
dryrun: true,
..Default::default()
};
update_template("clinic", update).unwrap();
}
let command = UpdateCommand::new().command().version("0.0.0-test");
let matches = command
.try_get_matches_from([
"update",
"--slug",
"clinic",
"--auto-approve-launches",
"true",
])
.unwrap();
assert_eq!(
matches.get_one::<bool>("auto_approve_launches").copied(),
Some(true)
);
let refused = UpdateCommand::new()
.command()
.version("0.0.0-test")
.try_get_matches_from([
"update",
"--slug",
"clinic",
"--auto-approve-launches",
"maybe",
]);
assert!(refused.is_err());
}
#[test]
fn the_cli_cluster_type_list_matches_what_the_control_plane_validates() {
assert_eq!(
CLUSTER_TYPES,
&["org-shared", "platform-shared", "dedicated"]
);
}
#[test]
fn the_cli_status_list_matches_what_the_control_plane_validates() {
assert_eq!(TEMPLATE_STATUSES, &["draft", "published", "retired"]);
}
}