From 2e9caf29ffa8add9a09535ce1f2f09d601222de1 Mon Sep 17 00:00:00 2001 From: James Henderson Date: Fri, 4 Sep 2026 15:19:20 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20HPX-2484=20=E2=80=93=20Add=20CLI=20?= =?UTF-8?q?command=20groups=20for=20Hosting=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/src/hosting/app/create.rs | 65 +++ rust/src/hosting/app/delete.rs | 30 ++ rust/src/hosting/app/get.rs | 32 ++ rust/src/hosting/app/list.rs | 81 ++++ rust/src/hosting/app/mod.rs | 29 ++ rust/src/hosting/app/restart.rs | 47 ++ rust/src/hosting/app/status.rs | 36 ++ rust/src/hosting/app/update.rs | 102 +++++ rust/src/hosting/client.rs | 488 +++++++++++++++++++++ rust/src/hosting/client_tests.rs | 559 ++++++++++++++++++++++++ rust/src/hosting/common.rs | 141 ++++++ rust/src/hosting/deployment/get.rs | 42 ++ rust/src/hosting/deployment/list.rs | 75 ++++ rust/src/hosting/deployment/mod.rs | 17 + rust/src/hosting/deployment/publish.rs | 41 ++ rust/src/hosting/domain/attach.rs | 45 ++ rust/src/hosting/domain/detach.rs | 47 ++ rust/src/hosting/domain/get.rs | 43 ++ rust/src/hosting/domain/list.rs | 74 ++++ rust/src/hosting/domain/mod.rs | 19 + rust/src/hosting/github/branches.rs | 83 ++++ rust/src/hosting/github/mod.rs | 17 + rust/src/hosting/github/repos.rs | 69 +++ rust/src/hosting/github/status.rs | 26 ++ rust/src/hosting/log/list.rs | 89 ++++ rust/src/hosting/log/mod.rs | 12 + rust/src/hosting/mod.rs | 41 +- rust/src/hosting/operation/get.rs | 32 ++ rust/src/hosting/operation/mod.rs | 13 + rust/src/hosting/runtime/get.rs | 30 ++ rust/src/hosting/runtime/mod.rs | 13 + rust/src/hosting/secrets/create.rs | 57 +++ rust/src/hosting/secrets/delete.rs | 59 +++ rust/src/hosting/secrets/list.rs | 46 ++ rust/src/hosting/secrets/mod.rs | 22 + rust/src/hosting/secrets/sync.rs | 100 +++++ rust/src/hosting/secrets/update.rs | 57 +++ rust/src/hosting/source/import.rs | 56 +++ rust/src/hosting/source/mod.rs | 15 + rust/src/hosting/source/status.rs | 43 ++ rust/src/hosting/subscription/attach.rs | 48 ++ rust/src/hosting/subscription/get.rs | 36 ++ rust/src/hosting/subscription/list.rs | 75 ++++ rust/src/hosting/subscription/mod.rs | 18 + rust/src/scopes.rs | 106 +++++ 45 files changed, 3174 insertions(+), 2 deletions(-) create mode 100644 rust/src/hosting/app/create.rs create mode 100644 rust/src/hosting/app/delete.rs create mode 100644 rust/src/hosting/app/get.rs create mode 100644 rust/src/hosting/app/list.rs create mode 100644 rust/src/hosting/app/mod.rs create mode 100644 rust/src/hosting/app/restart.rs create mode 100644 rust/src/hosting/app/status.rs create mode 100644 rust/src/hosting/app/update.rs create mode 100644 rust/src/hosting/client.rs create mode 100644 rust/src/hosting/client_tests.rs create mode 100644 rust/src/hosting/common.rs create mode 100644 rust/src/hosting/deployment/get.rs create mode 100644 rust/src/hosting/deployment/list.rs create mode 100644 rust/src/hosting/deployment/mod.rs create mode 100644 rust/src/hosting/deployment/publish.rs create mode 100644 rust/src/hosting/domain/attach.rs create mode 100644 rust/src/hosting/domain/detach.rs create mode 100644 rust/src/hosting/domain/get.rs create mode 100644 rust/src/hosting/domain/list.rs create mode 100644 rust/src/hosting/domain/mod.rs create mode 100644 rust/src/hosting/github/branches.rs create mode 100644 rust/src/hosting/github/mod.rs create mode 100644 rust/src/hosting/github/repos.rs create mode 100644 rust/src/hosting/github/status.rs create mode 100644 rust/src/hosting/log/list.rs create mode 100644 rust/src/hosting/log/mod.rs create mode 100644 rust/src/hosting/operation/get.rs create mode 100644 rust/src/hosting/operation/mod.rs create mode 100644 rust/src/hosting/runtime/get.rs create mode 100644 rust/src/hosting/runtime/mod.rs create mode 100644 rust/src/hosting/secrets/create.rs create mode 100644 rust/src/hosting/secrets/delete.rs create mode 100644 rust/src/hosting/secrets/list.rs create mode 100644 rust/src/hosting/secrets/mod.rs create mode 100644 rust/src/hosting/secrets/sync.rs create mode 100644 rust/src/hosting/secrets/update.rs create mode 100644 rust/src/hosting/source/import.rs create mode 100644 rust/src/hosting/source/mod.rs create mode 100644 rust/src/hosting/source/status.rs create mode 100644 rust/src/hosting/subscription/attach.rs create mode 100644 rust/src/hosting/subscription/get.rs create mode 100644 rust/src/hosting/subscription/list.rs create mode 100644 rust/src/hosting/subscription/mod.rs diff --git a/rust/src/hosting/app/create.rs b/rust/src/hosting/app/create.rs new file mode 100644 index 00000000..ad53d24d --- /dev/null +++ b/rust/src/hosting/app/create.rs @@ -0,0 +1,65 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::common::{client_err, make_client, parse_app_type}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_CREATE as APP_CREATE; + +#[derive(Debug, Clone, clap::Args)] +struct AppCreateArgs { + /// Application type (NODEJS). + #[arg(long = "app-type", value_name = "TYPE", value_parser = parse_app_type)] + app_type: String, + + /// Human-readable display name (1–200 characters). + #[arg(long, value_name = "NAME")] + name: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("create", "Create a hosting application") + .with_long( + "Provision a new hosting application slot. Because no app ID exists \ + until provisioning completes, this returns an operation ID. \ + Poll `hosting operation get --operation-id ` until \ + status is COMPLETED or FAILED. The completed operation result \ + includes the created application with its ID.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[APP_CREATE]), + |ctx, args: AppCreateArgs| async move { + let app_type = args.app_type; + let name = args.name; + let client = make_client(&ctx, &[APP_CREATE]).await?; + let data = client + .create_app(&app_type, json!({ "name": name })) + .await + .map_err(client_err)?; + + let operation_id = data + .get("operationId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + + let mut poll_action = next_action( + "hosting operation get --operation-id ", + "Poll app provisioning status", + ) + .with_param("operation-id", NextActionParam::required()); + + if !operation_id.is_empty() { + poll_action = next_action( + "hosting operation get --operation-id ", + "Poll app provisioning status", + ) + .with_param("operation-id", NextActionParam::value(operation_id)); + } + + Ok(CommandResult::new(data).with_next_actions(vec![poll_action])) + }, + ) +} diff --git a/rust/src/hosting/app/delete.rs b/rust/src/hosting/app/delete.rs new file mode 100644 index 00000000..24ea3d4b --- /dev/null +++ b/rust/src/hosting/app/delete.rs @@ -0,0 +1,30 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_DELETE as APP_DELETE; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("delete", "Delete a hosting application") + .with_long("Permanently delete a hosting application and all its associated data.") + .with_system("hosting") + .with_tier(Tier::Destructive) + .mutates(true) + .with_scopes(&[APP_DELETE]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[APP_DELETE]).await?; + client.delete_app(&app_id).await.map_err(client_err)?; + Ok( + CommandResult::new(json!({ "deleted": true, "appId": app_id })).with_next_actions( + vec![next_action( + "hosting app list --app-type ", + "List remaining applications", + )], + ), + ) + }, + ) +} diff --git a/rust/src/hosting/app/get.rs b/rust/src/hosting/app/get.rs new file mode 100644 index 00000000..db5ece48 --- /dev/null +++ b/rust/src/hosting/app/get.rs @@ -0,0 +1,32 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get a hosting application") + .with_long("Get details for a single hosting application by ID.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APP_READ]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[APP_READ]).await?; + let data = client.get_app(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting app status --app-id ", + "Get runtime status for this application", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())), + next_action( + "hosting deployment list --app-id ", + "List deployments for this application", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/app/list.rs b/rust/src/hosting/app/list.rs new file mode 100644 index 00000000..03e6ad28 --- /dev/null +++ b/rust/src/hosting/app/list.rs @@ -0,0 +1,81 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{ + HostingAppSummary, client_err, make_client, next_page_token, parse_app_type, +}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; + +#[derive(Debug, Clone, clap::Args)] +struct AppListArgs { + /// Application type (NODEJS). + #[arg(long = "app-type", value_name = "TYPE", value_parser = parse_app_type)] + app_type: String, + + /// Maximum number of applications to return. Omit to return all. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + limit: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List hosting applications") + .with_long( + "List all hosting applications of a given type. Results are autopaginated — \ + all pages are fetched and combined. Use --limit to cap the total returned.\n\ + \n\ + --app-type is required. Currently supported: NODEJS.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APP_READ]) + .with_default_fields("id,name,status") + .with_output_schema::(), + |ctx, args: AppListArgs| async move { + let app_type = args.app_type; + let limit = args.limit; + let client = make_client(&ctx, &[APP_READ]).await?; + + let mut all_items: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let page_limit = limit.map(|cap| { + let remaining = cap.saturating_sub(all_items.len() as u32); + remaining.min(100) + }); + + let response = client + .list_apps(&app_type, page_token.as_deref(), page_limit) + .await + .map_err(client_err)?; + + if let Some(items) = response.get("items").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + if limit.is_some_and(|cap| all_items.len() >= cap as usize) { + if let Some(cap) = limit { + all_items.truncate(cap as usize); + } + break; + } + + match next_page_token(&response) { + Some(token) => page_token = Some(token), + None => break, + } + } + + let data = json!({ "items": all_items }); + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting app get --app-id ", + "Get details for an application", + ) + .with_param("app-id", NextActionParam::required()), + ])) + }, + ) +} diff --git a/rust/src/hosting/app/mod.rs b/rust/src/hosting/app/mod.rs new file mode 100644 index 00000000..4b0a9e23 --- /dev/null +++ b/rust/src/hosting/app/mod.rs @@ -0,0 +1,29 @@ +mod create; +mod delete; +mod get; +mod list; +mod restart; +mod status; +mod update; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new( + "app", + "Create, inspect, update, and delete hosting applications", + ) + .with_long( + "Work with hosting applications. Use --app-type on list and create to \ + specify the product type (currently NODEJS).", + ), + ) + .with_command(list::command()) + .with_command(get::command()) + .with_command(create::command()) + .with_command(update::command()) + .with_command(delete::command()) + .with_command(status::command()) + .with_command(restart::command()) +} diff --git a/rust/src/hosting/app/restart.rs b/rust/src/hosting/app/restart.rs new file mode 100644 index 00000000..da8908fa --- /dev/null +++ b/rust/src/hosting/app/restart.rs @@ -0,0 +1,47 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_DEPLOYMENT_EXECUTE as DEPLOY_EXECUTE; + +#[derive(Debug, Clone, clap::Args)] +struct AppRestartArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Environment to restart (PREVIEW or PUBLISH). + #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"])] + variant: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("restart", "Restart an application environment") + .with_long( + "Restart the PREVIEW or PUBLISH environment of a hosting application. \ + Check `hosting app status` after restarting to confirm the environment \ + returns to ACTIVE.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[DEPLOY_EXECUTE]), + |ctx, args: AppRestartArgs| async move { + let app_id = args.app_id; + let variant = args.variant; + let client = make_client(&ctx, &[DEPLOY_EXECUTE]).await?; + let data = client + .restart_app(&app_id, &variant) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting app status --app-id ", + "Check environment status after restart", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/app/status.rs b/rust/src/hosting/app/status.rs new file mode 100644 index 00000000..e98122fa --- /dev/null +++ b/rust/src/hosting/app/status.rs @@ -0,0 +1,36 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("status", "Get application runtime status") + .with_long( + "Get the runtime status of a hosting application's environments \ + (preview and publish). Use this to check whether an environment \ + is ACTIVE, IDLE, or in a transitional state after a restart or deployment.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APP_READ]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[APP_READ]).await?; + let data = client.get_app_status(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting deployment list --app-id ", + "List deployments for this application", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())), + next_action( + "hosting app restart --app-id --variant ", + "Restart an environment", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/app/update.rs b/rust/src/hosting/app/update.rs new file mode 100644 index 00000000..913f26ef --- /dev/null +++ b/rust/src/hosting/app/update.rs @@ -0,0 +1,102 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{client_err, make_client}; +use crate::scopes::HOSTING_APPLICATION_UPDATE as APP_UPDATE; + +#[derive(Debug, Clone, clap::Args)] +struct AppUpdateArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// New display name (1–200 characters). + #[arg(long, value_name = "NAME")] + name: Option, + + /// New root path. + #[arg(long = "root-path", value_name = "PATH")] + root_path: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("update", "Update a hosting application") + .with_long( + "Update application metadata. At least one of --name or --root-path \ + is required. Changes take effect immediately and do not trigger a \ + new deployment.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[APP_UPDATE]), + |ctx, args: AppUpdateArgs| async move { + let app_id = args.app_id; + let name = args.name.filter(|s| !s.trim().is_empty()); + let root_path = args.root_path.filter(|s| !s.trim().is_empty()); + + if name.is_none() && root_path.is_none() { + return Err(crate::error::GddyError::validation( + "at least one of --name or --root-path is required", + ) + .into_cli_error()); + } + + let mut ops: Vec = Vec::new(); + if let Some(name) = name { + ops.push(json!({ "op": "replace", "path": "/name", "value": name })); + } + if let Some(root_path) = root_path { + ops.push(json!({ "op": "replace", "path": "/rootPath", "value": root_path })); + } + + let client = make_client(&ctx, &[APP_UPDATE]).await?; + let data = client + .update_app(&app_id, Value::Array(ops)) + .await + .map_err(client_err)?; + + Ok(CommandResult::new(data)) + }, + ) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + fn make_patch(name: Option<&str>, root_path: Option<&str>) -> Vec { + let mut ops = Vec::new(); + if let Some(n) = name { + ops.push(json!({ "op": "replace", "path": "/name", "value": n })); + } + if let Some(p) = root_path { + ops.push(json!({ "op": "replace", "path": "/rootPath", "value": p })); + } + ops + } + + #[test] + fn patch_with_name_only() { + let ops = make_patch(Some("new-name"), None); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0]["path"], "/name"); + assert_eq!(ops[0]["value"], "new-name"); + assert_eq!(ops[0]["op"], "replace"); + } + + #[test] + fn patch_with_root_path_only() { + let ops = make_patch(None, Some("/src")); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0]["path"], "/rootPath"); + assert_eq!(ops[0]["value"], "/src"); + } + + #[test] + fn patch_with_both_fields() { + let ops = make_patch(Some("n"), Some("/p")); + assert_eq!(ops.len(), 2); + } +} diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs new file mode 100644 index 00000000..82c3eafa --- /dev/null +++ b/rust/src/hosting/client.rs @@ -0,0 +1,488 @@ +use reqwest::{Client, Method}; +use serde_json::{Value, json}; + +use crate::application::client::make_http_client; + +const BASE_PATH: &str = "/v1/hosting"; + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("HTTP error {status}: {body}")] + Http { status: u16, body: String }, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), +} + +impl From for crate::error::GddyError { + fn from(value: ClientError) -> Self { + match value { + ClientError::Http { status, body } => Self::from_http(status, body, "hosting"), + ClientError::Network(e) => { + Self::network(format!("network error: {e}")).with_system("hosting") + } + } + } +} + +pub struct HostingClient { + client: Client, + base_url: String, + token: String, +} + +impl HostingClient { + pub fn new(base_url: impl Into, token: impl Into) -> Self { + Self { + client: make_http_client(), + base_url: base_url.into(), + token: token.into(), + } + } + + fn url(&self, path: &str) -> String { + format!("{}{BASE_PATH}{path}", self.base_url) + } + + fn new_request_id() -> String { + uuid::Uuid::new_v4().to_string() + } + + async fn send_json( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + body: Option, + ) -> Result { + let mut req = self + .client + .request(method, self.url(path)) + .bearer_auth(&self.token) + .header("x-request-id", Self::new_request_id()); + + for (key, value) in query { + req = req.query(&[(key, value)]); + } + + if let Some(body) = body { + req = req.json(&body); + } + + let request = req.build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let resp = self.client.execute(request).await?; + + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + let status = status.as_u16(); + if status == 204 { + return Ok(json!(null)); + } + + if !(200..300).contains(&status) { + return Err(ClientError::Http { + status, + body: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + + if bytes.is_empty() { + return Ok(json!(null)); + } + + serde_json::from_slice(&bytes).map_err(|e| ClientError::Http { + status, + body: format!( + "invalid JSON response: {e} (body: {})", + String::from_utf8_lossy(&bytes) + ), + }) + } + + // JSON Patch (RFC 6902) requires application/json-patch+json, which reqwest's + // .json() won't set. Serialize manually and force the content-type header. + async fn send_patch(&self, path: &str, body: Value) -> Result { + let body_str = serde_json::to_string(&body).map_err(|e| ClientError::Http { + status: 0, + body: format!("failed to serialize patch: {e}"), + })?; + + let request = self + .client + .request(Method::PATCH, self.url(path)) + .bearer_auth(&self.token) + .header("x-request-id", Self::new_request_id()) + .header("content-type", "application/json-patch+json") + .body(body_str) + .build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let resp = self.client.execute(request).await?; + + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + let status = status.as_u16(); + if !(200..300).contains(&status) { + return Err(ClientError::Http { + status, + body: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + + if bytes.is_empty() { + return Ok(json!(null)); + } + + serde_json::from_slice(&bytes).map_err(|e| ClientError::Http { + status, + body: format!( + "invalid JSON response: {e} (body: {})", + String::from_utf8_lossy(&bytes) + ), + }) + } + + pub async fn list_apps( + &self, + app_type: &str, + page_token: Option<&str>, + limit: Option, + ) -> Result { + let mut query = vec![("appType", app_type.to_owned())]; + if let Some(token) = page_token { + query.push(("pageToken", token.to_owned())); + } + if let Some(limit) = limit { + query.push(("limit", limit.to_string())); + } + self.send_json(Method::GET, "/apps", &query, None).await + } + + pub async fn get_app(&self, app_id: &str) -> Result { + self.send_json(Method::GET, &format!("/apps/{app_id}"), &[], None) + .await + } + + pub async fn create_app(&self, app_type: &str, body: Value) -> Result { + let query = [("appType", app_type.to_owned())]; + self.send_json(Method::POST, "/apps", &query, Some(body)) + .await + } + + pub async fn update_app(&self, app_id: &str, patch: Value) -> Result { + self.send_patch(&format!("/apps/{app_id}"), patch).await + } + + pub async fn delete_app(&self, app_id: &str) -> Result { + self.send_json(Method::DELETE, &format!("/apps/{app_id}"), &[], None) + .await + } + + pub async fn get_app_status(&self, app_id: &str) -> Result { + self.send_json(Method::GET, &format!("/apps/{app_id}/status"), &[], None) + .await + } + + pub async fn restart_app(&self, app_id: &str, variant: &str) -> Result { + self.send_json( + Method::POST, + &format!("/apps/{app_id}/restarts"), + &[], + Some(json!({ "variant": variant })), + ) + .await + } + + pub async fn list_deployments( + &self, + app_id: &str, + page_token: Option<&str>, + limit: Option, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(t) = page_token { + query.push(("pageToken", t.to_owned())); + } + if let Some(l) = limit { + query.push(("limit", l.to_string())); + } + self.send_json( + Method::GET, + &format!("/apps/{app_id}/deployments"), + &query, + None, + ) + .await + } + + pub async fn get_deployment( + &self, + app_id: &str, + deployment_id: &str, + ) -> Result { + self.send_json( + Method::GET, + &format!("/apps/{app_id}/deployments/{deployment_id}"), + &[], + None, + ) + .await + } + + pub async fn create_deployment(&self, app_id: &str) -> Result { + self.send_json( + Method::POST, + &format!("/apps/{app_id}/deployments"), + &[], + None, + ) + .await + } + + pub async fn get_operation(&self, operation_id: &str) -> Result { + self.send_json( + Method::GET, + &format!("/app-operations/{operation_id}"), + &[], + None, + ) + .await + } + + pub async fn create_import( + &self, + app_id: &str, + repo: &str, + branch: &str, + ) -> Result { + self.send_json( + Method::POST, + &format!("/apps/{app_id}/imports"), + &[], + Some(json!({ "repositoryFullName": repo, "branch": branch })), + ) + .await + } + + pub async fn get_import(&self, app_id: &str, import_id: &str) -> Result { + self.send_json( + Method::GET, + &format!("/apps/{app_id}/imports/{import_id}"), + &[], + None, + ) + .await + } + + pub async fn get_github_connection(&self) -> Result { + self.send_json(Method::GET, "/settings/github/connection", &[], None) + .await + } + + pub async fn list_github_repos( + &self, + page_token: Option<&str>, + limit: Option, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(t) = page_token { + query.push(("pageToken", t.to_owned())); + } + if let Some(l) = limit { + query.push(("limit", l.to_string())); + } + self.send_json(Method::GET, "/settings/github/repositories", &query, None) + .await + } + + pub async fn list_github_branches( + &self, + owner: &str, + repo: &str, + page_token: Option<&str>, + limit: Option, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(t) = page_token { + query.push(("pageToken", t.to_owned())); + } + if let Some(l) = limit { + query.push(("limit", l.to_string())); + } + self.send_json( + Method::GET, + &format!("/settings/github/repositories/{owner}/{repo}/branches"), + &query, + None, + ) + .await + } + + pub async fn list_secrets( + &self, + app_id: &str, + variant: Option<&str>, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(v) = variant { + query.push(("variant", v.to_owned())); + } + self.send_json( + Method::GET, + &format!("/apps/{app_id}/secrets"), + &query, + None, + ) + .await + } + + pub async fn sync_secrets(&self, app_id: &str, body: Value) -> Result { + self.send_json( + Method::POST, + &format!("/apps/{app_id}/secrets/sync"), + &[], + Some(body), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn list_logs( + &self, + app_id: &str, + target: Option<&str>, + since: Option<&str>, + source: Option<&str>, + level: Option<&str>, + page_token: Option<&str>, + limit: Option, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(v) = target { + query.push(("target", v.to_owned())); + } + if let Some(v) = since { + query.push(("since", v.to_owned())); + } + if let Some(v) = source { + query.push(("source", v.to_owned())); + } + if let Some(v) = level { + query.push(("level", v.to_owned())); + } + if let Some(t) = page_token { + query.push(("pageToken", t.to_owned())); + } + if let Some(l) = limit { + query.push(("limit", l.to_string())); + } + self.send_json(Method::GET, &format!("/apps/{app_id}/logs"), &query, None) + .await + } + + pub async fn get_runtime(&self, app_id: &str) -> Result { + self.send_json(Method::GET, &format!("/apps/{app_id}/runtime"), &[], None) + .await + } + + pub async fn list_domains( + &self, + app_id: &str, + page_token: Option<&str>, + limit: Option, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(t) = page_token { + query.push(("pageToken", t.to_owned())); + } + if let Some(l) = limit { + query.push(("limit", l.to_string())); + } + self.send_json( + Method::GET, + &format!("/apps/{app_id}/domains"), + &query, + None, + ) + .await + } + + pub async fn get_domain(&self, app_id: &str, domain_id: &str) -> Result { + self.send_json( + Method::GET, + &format!("/apps/{app_id}/domains/{domain_id}"), + &[], + None, + ) + .await + } + + pub async fn attach_domain(&self, app_id: &str, hostname: &str) -> Result { + self.send_json( + Method::POST, + &format!("/apps/{app_id}/domains"), + &[], + Some(json!({ "hostname": hostname })), + ) + .await + } + + pub async fn detach_domain(&self, app_id: &str, domain_id: &str) -> Result { + self.send_json( + Method::DELETE, + &format!("/apps/{app_id}/domains/{domain_id}"), + &[], + None, + ) + .await + } + + pub async fn list_subscriptions( + &self, + page_token: Option<&str>, + limit: Option, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(t) = page_token { + query.push(("pageToken", t.to_owned())); + } + if let Some(l) = limit { + query.push(("limit", l.to_string())); + } + self.send_json(Method::GET, "/subscriptions", &query, None) + .await + } + + pub async fn get_app_subscription(&self, app_id: &str) -> Result { + self.send_json( + Method::GET, + &format!("/apps/{app_id}/subscription"), + &[], + None, + ) + .await + } + + pub async fn attach_subscription( + &self, + app_id: &str, + subscription_id: &str, + ) -> Result { + self.send_json( + Method::PUT, + &format!("/apps/{app_id}/subscription"), + &[], + Some(json!({ "subscriptionId": subscription_id })), + ) + .await + } +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/rust/src/hosting/client_tests.rs b/rust/src/hosting/client_tests.rs new file mode 100644 index 00000000..a91316d8 --- /dev/null +++ b/rust/src/hosting/client_tests.rs @@ -0,0 +1,559 @@ +use httpmock::prelude::*; +use serde_json::json; + +use super::*; + +fn client(base_url: &str) -> HostingClient { + HostingClient::new(base_url, "test-token") +} + +#[tokio::test] +async fn list_apps_sends_app_type_query_param() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps") + .query_param("appType", "NODEJS") + .header("authorization", "Bearer test-token"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + let body = client(&server.base_url()) + .list_apps("NODEJS", None, None) + .await + .expect("list apps"); + + mock.assert_async().await; + assert_eq!(body["items"], json!([])); +} + +#[tokio::test] +async fn list_apps_sends_page_token_and_limit() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps") + .query_param("appType", "NODEJS") + .query_param("pageToken", "tok-1") + .query_param("limit", "5"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + client(&server.base_url()) + .list_apps("NODEJS", Some("tok-1"), Some(5)) + .await + .expect("list apps with pagination"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn get_app_sends_bearer_auth() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps/app-1") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(json!({ "id": "app-1" })); + }) + .await; + + let body = client(&server.base_url()) + .get_app("app-1") + .await + .expect("get app"); + + mock.assert_async().await; + assert_eq!(body["id"], "app-1"); +} + +#[tokio::test] +async fn create_app_sends_app_type_query_and_json_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/apps") + .query_param("appType", "NODEJS") + .json_body(json!({ "name": "my-app" })); + then.status(202) + .json_body(json!({ "operationId": "op-1", "status": "PENDING" })); + }) + .await; + + let body = client(&server.base_url()) + .create_app("NODEJS", json!({ "name": "my-app" })) + .await + .expect("create app"); + + mock.assert_async().await; + assert_eq!(body["operationId"], "op-1"); +} + +#[tokio::test] +async fn update_app_sends_json_patch_content_type() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PATCH) + .path("/v1/hosting/apps/app-1") + .header("content-type", "application/json-patch+json"); + then.status(200).json_body(json!({ "id": "app-1" })); + }) + .await; + + let patch = json!([{ "op": "replace", "path": "/name", "value": "new-name" }]); + let body = client(&server.base_url()) + .update_app("app-1", patch) + .await + .expect("update app"); + + mock.assert_async().await; + assert_eq!(body["id"], "app-1"); +} + +#[tokio::test] +async fn delete_app_returns_null_on_204() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(DELETE).path("/v1/hosting/apps/app-1"); + then.status(204); + }) + .await; + + let body = client(&server.base_url()) + .delete_app("app-1") + .await + .expect("delete app"); + + mock.assert_async().await; + assert_eq!(body, json!(null)); +} + +#[tokio::test] +async fn get_app_status_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/hosting/apps/app-1/status"); + then.status(200) + .json_body(json!({ "preview": "ACTIVE", "publish": "IDLE" })); + }) + .await; + + let body = client(&server.base_url()) + .get_app_status("app-1") + .await + .expect("get app status"); + + mock.assert_async().await; + assert!(body.get("preview").is_some()); +} + +#[tokio::test] +async fn restart_app_sends_variant_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/apps/app-1/restarts") + .json_body(json!({ "variant": "PREVIEW" })); + then.status(200).json_body(json!({})); + }) + .await; + + client(&server.base_url()) + .restart_app("app-1", "PREVIEW") + .await + .expect("restart app"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn list_deployments_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps/app-1/deployments") + .header("authorization", "Bearer test-token"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + client(&server.base_url()) + .list_deployments("app-1", None, None) + .await + .expect("list deployments"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn get_deployment_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps/app-1/deployments/dep-1"); + then.status(200).json_body(json!({ "id": "dep-1" })); + }) + .await; + + let body = client(&server.base_url()) + .get_deployment("app-1", "dep-1") + .await + .expect("get deployment"); + + mock.assert_async().await; + assert_eq!(body["id"], "dep-1"); +} + +#[tokio::test] +async fn create_deployment_posts_with_no_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v1/hosting/apps/app-1/deployments"); + then.status(202) + .json_body(json!({ "id": "dep-1", "status": "PENDING" })); + }) + .await; + + client(&server.base_url()) + .create_deployment("app-1") + .await + .expect("create deployment"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn get_operation_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/hosting/app-operations/op-1"); + then.status(200) + .json_body(json!({ "id": "op-1", "status": "COMPLETED" })); + }) + .await; + + let body = client(&server.base_url()) + .get_operation("op-1") + .await + .expect("get operation"); + + mock.assert_async().await; + assert_eq!(body["id"], "op-1"); +} + +#[tokio::test] +async fn create_import_sends_repo_and_branch() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/apps/app-1/imports") + .json_body(json!({ "repositoryFullName": "acme/my-app", "branch": "main" })); + then.status(202).json_body(json!({ "id": "imp-1" })); + }) + .await; + + client(&server.base_url()) + .create_import("app-1", "acme/my-app", "main") + .await + .expect("create import"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn get_import_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps/app-1/imports/imp-1"); + then.status(200).json_body(json!({ "id": "imp-1" })); + }) + .await; + + client(&server.base_url()) + .get_import("app-1", "imp-1") + .await + .expect("get import"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn get_github_connection_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/settings/github/connection"); + then.status(200).json_body(json!({ "connected": true })); + }) + .await; + + let body = client(&server.base_url()) + .get_github_connection() + .await + .expect("get github connection"); + + mock.assert_async().await; + assert_eq!(body["connected"], true); +} + +#[tokio::test] +async fn list_github_repos_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/settings/github/repositories"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + client(&server.base_url()) + .list_github_repos(None, None) + .await + .expect("list github repos"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn list_github_branches_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/settings/github/repositories/acme/my-app/branches"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + client(&server.base_url()) + .list_github_branches("acme", "my-app", None, None) + .await + .expect("list github branches"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn list_secrets_sends_variant_query_param() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps/app-1/secrets") + .query_param("variant", "PUBLISH"); + then.status(200).json_body(json!({ "items": [] })); + }) + .await; + + client(&server.base_url()) + .list_secrets("app-1", Some("PUBLISH")) + .await + .expect("list secrets"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn sync_secrets_sends_body() { + let server = MockServer::start_async().await; + let body = json!({ + "variant": "PREVIEW", + "operations": { "additions": [{ "name": "MY_SECRET", "value": "val" }] } + }); + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/apps/app-1/secrets/sync") + .json_body(body.clone()); + then.status(200).json_body(json!({ "items": [] })); + }) + .await; + + client(&server.base_url()) + .sync_secrets("app-1", body) + .await + .expect("sync secrets"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn list_logs_sends_target_query_param() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/apps/app-1/logs") + .query_param("target", "PREVIEW"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + client(&server.base_url()) + .list_logs("app-1", Some("PREVIEW"), None, None, None, None, None) + .await + .expect("list logs"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn get_runtime_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/hosting/apps/app-1/runtime"); + then.status(200).json_body(json!({ "status": "RUNNING" })); + }) + .await; + + client(&server.base_url()) + .get_runtime("app-1") + .await + .expect("get runtime"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn list_domains_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/hosting/apps/app-1/domains"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + client(&server.base_url()) + .list_domains("app-1", None, None) + .await + .expect("list domains"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn attach_domain_sends_hostname() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/apps/app-1/domains") + .json_body(json!({ "hostname": "www.example.com" })); + then.status(200).json_body(json!({ "id": "dom-1" })); + }) + .await; + + client(&server.base_url()) + .attach_domain("app-1", "www.example.com") + .await + .expect("attach domain"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn detach_domain_returns_null_on_204() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(DELETE) + .path("/v1/hosting/apps/app-1/domains/dom-1"); + then.status(204); + }) + .await; + + let body = client(&server.base_url()) + .detach_domain("app-1", "dom-1") + .await + .expect("detach domain"); + + mock.assert_async().await; + assert_eq!(body, json!(null)); +} + +#[tokio::test] +async fn list_subscriptions_hits_correct_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/hosting/subscriptions"); + then.status(200) + .json_body(json!({ "items": [], "links": [] })); + }) + .await; + + client(&server.base_url()) + .list_subscriptions(None, None) + .await + .expect("list subscriptions"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn attach_subscription_sends_subscription_id() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT) + .path("/v1/hosting/apps/app-1/subscription") + .json_body(json!({ "subscriptionId": "sub-1" })); + then.status(200) + .json_body(json!({ "subscriptionId": "sub-1" })); + }) + .await; + + client(&server.base_url()) + .attach_subscription("app-1", "sub-1") + .await + .expect("attach subscription"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn http_error_is_returned_as_client_error() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path("/v1/hosting/apps/not-found"); + then.status(404) + .json_body(json!({ "code": "NOT_FOUND", "message": "app not found" })); + }) + .await; + + let err = client(&server.base_url()) + .get_app("not-found") + .await + .expect_err("expected 404 error"); + + assert!(matches!(err, ClientError::Http { status: 404, .. })); +} diff --git a/rust/src/hosting/common.rs b/rust/src/hosting/common.rs new file mode 100644 index 00000000..d0966533 --- /dev/null +++ b/rust/src/hosting/common.rs @@ -0,0 +1,141 @@ +use cli_engine::{CliCoreError, CommandContext}; +use serde_json::Value; + +use crate::application::client::api_url_for_env; +use crate::hosting::client::{ClientError, HostingClient}; +use crate::output_schema::output_schema; + +output_schema!(HostingAppSummary { + "id": "string"; + "name": "string"; + "status": "string"; +}); + +output_schema!(HostingDeploymentSummary { + "id": "string"; + "status": "string"; + "createdAt": "string"; +}); + +output_schema!(HostingSecretSummary { + "name": "string"; + "scope": "string"; +}); + +output_schema!(HostingDomainSummary { + "id": "string"; + "hostname": "string"; + "status": "string"; +}); + +output_schema!(HostingSubscriptionSummary { + "id": "string"; + "planName": "string"; + "status": "string"; +}); + +output_schema!(HostingLogEntry { + "timestamp": "string"; + "level": "string"; + "message": "string"; +}); + +pub fn client_err(e: ClientError) -> CliCoreError { + crate::error::GddyError::from(e).into_cli_error() +} + +pub async fn make_client( + ctx: &CommandContext, + scopes: &[&str], +) -> cli_engine::Result { + let required: Vec = scopes.iter().map(|s| (*s).to_owned()).collect(); + let token = ctx.credential_with_scopes(&required).await?.token; + let base_url = api_url_for_env(&ctx.middleware.env)?; + Ok(HostingClient::new(base_url, token)) +} + +#[derive(Debug, Clone, clap::Args)] +pub struct AppIdArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + pub app_id: String, +} + +pub fn parse_app_type(s: &str) -> Result { + match s.to_uppercase().as_str() { + "NODEJS" | "MHWP" => Ok(s.to_uppercase()), + _ => Err(format!("unknown app type {s:?} — expected NODEJS")), + } +} + +/// Extracts the `pageToken` value from `links[rel=next].href` in a paged response. +pub fn next_page_token(response: &Value) -> Option { + let links = response.get("links")?.as_array()?; + for link in links { + if link.get("rel").and_then(|v| v.as_str()) == Some("next") { + let href = link.get("href").and_then(|v| v.as_str())?; + return extract_query_param(href, "pageToken"); + } + } + None +} + +fn extract_query_param(url: &str, param: &str) -> Option { + let query = url.split_once('?')?.1; + for pair in query.split('&') { + if let Some((k, v)) = pair.split_once('=') + && k == param + { + return Some(v.to_owned()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_app_type_accepts_known_types_case_insensitive() { + assert_eq!(parse_app_type("NODEJS").unwrap(), "NODEJS"); + assert_eq!(parse_app_type("nodejs").unwrap(), "NODEJS"); + assert_eq!(parse_app_type("MHWP").unwrap(), "MHWP"); + } + + #[test] + fn parse_app_type_rejects_unknown() { + assert!(parse_app_type("UNKNOWN").is_err()); + assert!(parse_app_type("").is_err()); + } + + #[test] + fn next_page_token_extracts_from_links() { + let response = json!({ + "items": [], + "links": [ + { "rel": "self", "href": "https://api.godaddy.com/v1/hosting/apps?appType=NODEJS" }, + { "rel": "next", "href": "https://api.godaddy.com/v1/hosting/apps?appType=NODEJS&pageToken=tok-2&limit=10" } + ] + }); + assert_eq!(next_page_token(&response).as_deref(), Some("tok-2")); + } + + #[test] + fn next_page_token_returns_none_when_no_next_link() { + let response = json!({ + "items": [], + "links": [ + { "rel": "self", "href": "https://api.godaddy.com/v1/hosting/apps?appType=NODEJS" } + ] + }); + assert!(next_page_token(&response).is_none()); + } + + #[test] + fn next_page_token_returns_none_when_no_links() { + let response = json!({ "items": [] }); + assert!(next_page_token(&response).is_none()); + } +} diff --git a/rust/src/hosting/deployment/get.rs b/rust/src/hosting/deployment/get.rs new file mode 100644 index 00000000..ae49f91f --- /dev/null +++ b/rust/src/hosting/deployment/get.rs @@ -0,0 +1,42 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; + +#[derive(Debug, Clone, clap::Args)] +struct DeploymentGetArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Deployment ID. + #[arg(long = "deployment-id", value_name = "DEPLOYMENT_ID")] + deployment_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get a deployment") + .with_long("Get the current status and details of a specific deployment.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APP_READ]), + |ctx, args: DeploymentGetArgs| async move { + let app_id = args.app_id.clone(); + let deployment_id = args.deployment_id; + let client = make_client(&ctx, &[APP_READ]).await?; + let data = client + .get_deployment(&app_id, &deployment_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting operation get --operation-id ", + "Poll the operation for this deployment", + ) + .with_param("operation-id", NextActionParam::required()), + ])) + }, + ) +} diff --git a/rust/src/hosting/deployment/list.rs b/rust/src/hosting/deployment/list.rs new file mode 100644 index 00000000..0a3878a8 --- /dev/null +++ b/rust/src/hosting/deployment/list.rs @@ -0,0 +1,75 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{HostingDeploymentSummary, client_err, make_client, next_page_token}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; + +#[derive(Debug, Clone, clap::Args)] +struct DeploymentListArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Maximum number of deployments to return. Omit to return all. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + limit: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List deployments for an application") + .with_long( + "List all deployments for a hosting application, newest first. \ + Results are autopaginated. Use --limit to cap the total returned.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APP_READ]) + .with_default_fields("id,status,createdAt") + .with_output_schema::(), + |ctx, args: DeploymentListArgs| async move { + let app_id = args.app_id; + let limit = args.limit; + let client = make_client(&ctx, &[APP_READ]).await?; + + let mut all_items: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let page_limit = + limit.map(|cap| cap.saturating_sub(all_items.len() as u32).min(100)); + + let response = client + .list_deployments(&app_id, page_token.as_deref(), page_limit) + .await + .map_err(client_err)?; + + if let Some(items) = response.get("items").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + if limit.is_some_and(|cap| all_items.len() >= cap as usize) { + all_items.truncate(limit.expect("checked") as usize); + break; + } + + match next_page_token(&response) { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok( + CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ + next_action( + "hosting deployment get --app-id --deployment-id ", + "Get full details of a deployment", + ) + .with_param("app-id", NextActionParam::required()) + .with_param("deployment-id", NextActionParam::required()), + ]), + ) + }, + ) +} diff --git a/rust/src/hosting/deployment/mod.rs b/rust/src/hosting/deployment/mod.rs new file mode 100644 index 00000000..ba6b20d9 --- /dev/null +++ b/rust/src/hosting/deployment/mod.rs @@ -0,0 +1,17 @@ +mod get; +mod list; +mod publish; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("deployment", "Manage application deployments").with_long( + "List, inspect, and trigger deployments for a hosting application. \ + Deployments are triggered by `publish` and poll-able via `get`.", + ), + ) + .with_command(list::command()) + .with_command(get::command()) + .with_command(publish::command()) +} diff --git a/rust/src/hosting/deployment/publish.rs b/rust/src/hosting/deployment/publish.rs new file mode 100644 index 00000000..2bc72d67 --- /dev/null +++ b/rust/src/hosting/deployment/publish.rs @@ -0,0 +1,41 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_DEPLOYMENT_EXECUTE as DEPLOY_EXECUTE; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("publish", "Trigger a deployment") + .with_long( + "Build the latest imported source and deploy it to the publish environment. \ + Returns immediately with a deployment ID. Poll `hosting deployment get` \ + until status is COMPLETED or FAILED.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[DEPLOY_EXECUTE]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[DEPLOY_EXECUTE]).await?; + let data = client + .create_deployment(&app_id) + .await + .map_err(client_err)?; + let deployment_id = data + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting deployment get --app-id --deployment-id ", + "Poll until deployment completes", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("deployment-id", NextActionParam::value(deployment_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/domain/attach.rs b/rust/src/hosting/domain/attach.rs new file mode 100644 index 00000000..8e5d647d --- /dev/null +++ b/rust/src/hosting/domain/attach.rs @@ -0,0 +1,45 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_DOMAIN_WRITE as DOMAIN_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct DomainAttachArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Fully-qualified domain name to attach (e.g. www.example.com). + #[arg(long, value_name = "HOSTNAME")] + hostname: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("attach", "Attach a domain to an application") + .with_long( + "Attach a fully-qualified domain name to a hosting application. \ + The domain must be registered and have DNS pointing to GoDaddy hosting.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[DOMAIN_WRITE]), + |ctx, args: DomainAttachArgs| async move { + let app_id = args.app_id.clone(); + let client = make_client(&ctx, &[DOMAIN_WRITE]).await?; + let data = client + .attach_domain(&app_id, &args.hostname) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting domain list --app-id ", + "View all attached domains", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/domain/detach.rs b/rust/src/hosting/domain/detach.rs new file mode 100644 index 00000000..4d16d599 --- /dev/null +++ b/rust/src/hosting/domain/detach.rs @@ -0,0 +1,47 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_DOMAIN_WRITE as DOMAIN_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct DomainDetachArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Domain ID. + #[arg(long = "domain-id", value_name = "DOMAIN_ID")] + domain_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("detach", "Detach a domain from an application") + .with_long("Remove a domain from a hosting application. DNS records are not modified.") + .with_system("hosting") + .with_tier(Tier::Destructive) + .mutates(true) + .with_scopes(&[DOMAIN_WRITE]), + |ctx, args: DomainDetachArgs| async move { + let app_id = args.app_id.clone(); + let domain_id = args.domain_id.clone(); + let client = make_client(&ctx, &[DOMAIN_WRITE]).await?; + client + .detach_domain(&app_id, &domain_id) + .await + .map_err(client_err)?; + Ok( + CommandResult::new(json!({ "detached": true, "domainId": domain_id })) + .with_next_actions(vec![ + next_action( + "hosting domain list --app-id ", + "View remaining domains", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ]), + ) + }, + ) +} diff --git a/rust/src/hosting/domain/get.rs b/rust/src/hosting/domain/get.rs new file mode 100644 index 00000000..cc82f9bd --- /dev/null +++ b/rust/src/hosting/domain/get.rs @@ -0,0 +1,43 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_DOMAIN_READ as DOMAIN_READ; + +#[derive(Debug, Clone, clap::Args)] +struct DomainGetArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Domain ID. + #[arg(long = "domain-id", value_name = "DOMAIN_ID")] + domain_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get an attached domain") + .with_long("Get the details and status of a domain attached to a hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[DOMAIN_READ]), + |ctx, args: DomainGetArgs| async move { + let app_id = args.app_id.clone(); + let domain_id = args.domain_id.clone(); + let client = make_client(&ctx, &[DOMAIN_READ]).await?; + let data = client + .get_domain(&app_id, &domain_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting domain detach --app-id --domain-id ", + "Detach this domain", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("domain-id", NextActionParam::value(domain_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/domain/list.rs b/rust/src/hosting/domain/list.rs new file mode 100644 index 00000000..10174aa4 --- /dev/null +++ b/rust/src/hosting/domain/list.rs @@ -0,0 +1,74 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{HostingDomainSummary, client_err, make_client, next_page_token}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_DOMAIN_READ as DOMAIN_READ; + +#[derive(Debug, Clone, clap::Args)] +struct DomainListArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Maximum number of domains to return. Omit to return all. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + limit: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List domains attached to an application") + .with_long( + "List all domains attached to a hosting application. \ + Results are autopaginated.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[DOMAIN_READ]) + .with_default_fields("id,hostname,status") + .with_output_schema::(), + |ctx, args: DomainListArgs| async move { + let app_id = args.app_id.clone(); + let limit = args.limit; + let client = make_client(&ctx, &[DOMAIN_READ]).await?; + + let mut all_items: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let page_limit = + limit.map(|cap| cap.saturating_sub(all_items.len() as u32).min(100)); + + let response = client + .list_domains(&app_id, page_token.as_deref(), page_limit) + .await + .map_err(client_err)?; + + if let Some(items) = response.get("items").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + if limit.is_some_and(|cap| all_items.len() >= cap as usize) { + all_items.truncate(limit.expect("checked") as usize); + break; + } + + match next_page_token(&response) { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok( + CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ + next_action( + "hosting domain attach --app-id --hostname ", + "Attach a new domain", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ]), + ) + }, + ) +} diff --git a/rust/src/hosting/domain/mod.rs b/rust/src/hosting/domain/mod.rs new file mode 100644 index 00000000..a6f4c42d --- /dev/null +++ b/rust/src/hosting/domain/mod.rs @@ -0,0 +1,19 @@ +mod attach; +mod detach; +mod get; +mod list; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("domain", "Manage domains attached to an application").with_long( + "List, inspect, attach, and detach domains for a hosting application. \ + Domains must be registered and have DNS pointing to GoDaddy hosting.", + ), + ) + .with_command(list::command()) + .with_command(get::command()) + .with_command(attach::command()) + .with_command(detach::command()) +} diff --git a/rust/src/hosting/github/branches.rs b/rust/src/hosting/github/branches.rs new file mode 100644 index 00000000..969a6265 --- /dev/null +++ b/rust/src/hosting/github/branches.rs @@ -0,0 +1,83 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{client_err, make_client, next_page_token}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_GITHUB_READ as GH_READ; + +#[derive(Debug, Clone, clap::Args)] +struct BranchesArgs { + /// Repository owner (GitHub username or organization). + #[arg(long, value_name = "OWNER")] + owner: String, + + /// Repository name. + #[arg(long, value_name = "REPO")] + repo: String, + + /// Maximum number of branches to return. Omit to return all. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + limit: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("branches", "List branches for a GitHub repository") + .with_long( + "List branches for a repository accessible via the connected GitHub account. \ + Results are autopaginated.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[GH_READ]) + .with_default_fields("name"), + |ctx, args: BranchesArgs| async move { + let limit = args.limit; + let client = make_client(&ctx, &[GH_READ]).await?; + + let mut all_items: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let page_limit = + limit.map(|cap| cap.saturating_sub(all_items.len() as u32).min(100)); + + let response = client + .list_github_branches( + &args.owner, + &args.repo, + page_token.as_deref(), + page_limit, + ) + .await + .map_err(client_err)?; + + if let Some(items) = response.get("items").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + if limit.is_some_and(|cap| all_items.len() >= cap as usize) { + all_items.truncate(limit.expect("checked") as usize); + break; + } + + match next_page_token(&response) { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok( + CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ + next_action( + "hosting import create --app-id --repo --branch ", + "Import from this branch", + ) + .with_param("app-id", NextActionParam::required()) + .with_param("repo", NextActionParam::value(format!("{}/{}", args.owner, args.repo))) + .with_param("branch", NextActionParam::required()), + ]), + ) + }, + ) +} diff --git a/rust/src/hosting/github/mod.rs b/rust/src/hosting/github/mod.rs new file mode 100644 index 00000000..94fbf28b --- /dev/null +++ b/rust/src/hosting/github/mod.rs @@ -0,0 +1,17 @@ +mod branches; +mod repos; +mod status; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("github", "Manage GitHub integration").with_long( + "Inspect your GitHub connection, browse accessible repositories, \ + and list branches. Connect GitHub at godaddy.com before using these commands.", + ), + ) + .with_command(status::command()) + .with_command(repos::command()) + .with_command(branches::command()) +} diff --git a/rust/src/hosting/github/repos.rs b/rust/src/hosting/github/repos.rs new file mode 100644 index 00000000..49dfe048 --- /dev/null +++ b/rust/src/hosting/github/repos.rs @@ -0,0 +1,69 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{client_err, make_client, next_page_token}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_GITHUB_READ as GH_READ; + +#[derive(Debug, Clone, clap::Args)] +struct ReposArgs { + /// Maximum number of repositories to return. Omit to return all. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + limit: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("repos", "List connected GitHub repositories") + .with_long( + "List repositories accessible via the connected GitHub account. \ + Results are autopaginated.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[GH_READ]) + .with_default_fields("fullName"), + |ctx, args: ReposArgs| async move { + let limit = args.limit; + let client = make_client(&ctx, &[GH_READ]).await?; + + let mut all_items: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let page_limit = + limit.map(|cap| cap.saturating_sub(all_items.len() as u32).min(100)); + + let response = client + .list_github_repos(page_token.as_deref(), page_limit) + .await + .map_err(client_err)?; + + if let Some(items) = response.get("items").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + if limit.is_some_and(|cap| all_items.len() >= cap as usize) { + all_items.truncate(limit.expect("checked") as usize); + break; + } + + match next_page_token(&response) { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok( + CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ + next_action( + "hosting github branches --owner --repo ", + "List branches for a repository", + ) + .with_param("owner", NextActionParam::required()) + .with_param("repo", NextActionParam::required()), + ]), + ) + }, + ) +} diff --git a/rust/src/hosting/github/status.rs b/rust/src/hosting/github/status.rs new file mode 100644 index 00000000..f57d6fa9 --- /dev/null +++ b/rust/src/hosting/github/status.rs @@ -0,0 +1,26 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_GITHUB_READ as GH_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::<(), _, _, _>( + CommandSpec::new("status", "Show the GitHub connection status") + .with_long( + "Show whether a GitHub account is connected to your GoDaddy hosting profile. \ + Connection is account-level, not per-application.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[GH_READ]), + |ctx, _args: ()| async move { + let client = make_client(&ctx, &[GH_READ]).await?; + let data = client.get_github_connection().await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![next_action( + "hosting github repos", + "Browse connected repositories", + )])) + }, + ) +} diff --git a/rust/src/hosting/log/list.rs b/rust/src/hosting/log/list.rs new file mode 100644 index 00000000..c3c9e78d --- /dev/null +++ b/rust/src/hosting/log/list.rs @@ -0,0 +1,89 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{HostingLogEntry, client_err, make_client, next_page_token}; +use crate::scopes::HOSTING_LOG_READ as LOG_READ; + +#[derive(Debug, Clone, clap::Args)] +struct LogListArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Environment to retrieve logs from (PREVIEW or PUBLISH). Defaults to PREVIEW. + #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"])] + target: Option, + + /// Return only entries at or after this ISO 8601 timestamp (e.g. 2024-01-01T00:00:00Z). + #[arg(long, value_name = "DATETIME")] + since: Option, + + /// Filter by log source stream. + #[arg(long, value_name = "SOURCE")] + source: Option, + + /// Filter by severity level. + #[arg(long, value_name = "LEVEL")] + level: Option, + + /// Maximum number of log entries to return (max 500). Omit to return all. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=500))] + limit: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List application logs") + .with_long( + "Retrieve log entries for a hosting application. Results are autopaginated. \ + Use --target to select the environment, --since for a time window, \ + --source and --level to filter by stream and severity.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[LOG_READ]) + .with_default_fields("timestamp,level,message") + .with_output_schema::(), + |ctx, args: LogListArgs| async move { + let limit = args.limit; + let client = make_client(&ctx, &[LOG_READ]).await?; + + let mut all_items: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let page_limit = + limit.map(|cap| cap.saturating_sub(all_items.len() as u32).min(500)); + + let response = client + .list_logs( + &args.app_id, + args.target.as_deref(), + args.since.as_deref(), + args.source.as_deref(), + args.level.as_deref(), + page_token.as_deref(), + page_limit, + ) + .await + .map_err(client_err)?; + + if let Some(items) = response.get("items").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + if limit.is_some_and(|cap| all_items.len() >= cap as usize) { + all_items.truncate(limit.expect("checked") as usize); + break; + } + + match next_page_token(&response) { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok(CommandResult::new(json!({ "items": all_items }))) + }, + ) +} diff --git a/rust/src/hosting/log/mod.rs b/rust/src/hosting/log/mod.rs new file mode 100644 index 00000000..f9b4fb8b --- /dev/null +++ b/rust/src/hosting/log/mod.rs @@ -0,0 +1,12 @@ +mod list; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("log", "View application logs").with_long( + "Retrieve log entries for a hosting application. \ + Use --target to select the environment, --since to set a time window, \ + and --level/--source to filter.", + )) + .with_command(list::command()) +} diff --git a/rust/src/hosting/mod.rs b/rust/src/hosting/mod.rs index 9b5370b0..eccada23 100644 --- a/rust/src/hosting/mod.rs +++ b/rust/src/hosting/mod.rs @@ -1,5 +1,18 @@ pub mod nodejs; +mod app; +pub mod client; +pub mod common; +mod deployment; +mod domain; +mod github; +mod source; +mod log; +mod operation; +mod runtime; +mod secrets; +mod subscription; + use cli_engine::{GroupSpec, Module, RuntimeGroupSpec, Stage}; pub fn module() -> Module { @@ -8,10 +21,34 @@ pub fn module() -> Module { GroupSpec::new("hosting", "Manage GoDaddy hosting products").with_long( "Work with GoDaddy hosting APIs.\n\ \n\ - • nodejs — Node.js PaaS applications (create, upload, deploy, logs)", + • app — Hosting applications (create, inspect, update, delete, restart)\n\ + • deployment — Build and deploy application source\n\ + • source — Import source code from GitHub\n\ + • github — GitHub connection, repositories, branches\n\ + • secrets — Application secrets (create, update, delete, list)\n\ + • log — Application log entries\n\ + • runtime — Application runtime configuration\n\ + • domain — Domains attached to an application\n\ + • subscription — Hosting plan subscriptions\n\ + • operation — Poll async operations\n\ + • nodejs — Node.js PaaS applications (legacy; use `app` for new work)", ), ) - .with_group(nodejs::nodejs_group()) + .with_group(app::group()) + .with_group(deployment::group()) + .with_group(source::group()) + .with_group(github::group()) + .with_group(secrets::group()) + .with_group(log::group()) + .with_group(runtime::group()) + .with_group(domain::group()) + .with_group(subscription::group()) + .with_group(operation::group()) + .with_group({ + let mut g = nodejs::nodejs_group(); + g.group = g.group.hidden(true); + g + }) }) .with_feature_flag("hosting", Stage::Beta) } diff --git a/rust/src/hosting/operation/get.rs b/rust/src/hosting/operation/get.rs new file mode 100644 index 00000000..ceb92b5d --- /dev/null +++ b/rust/src/hosting/operation/get.rs @@ -0,0 +1,32 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; + +#[derive(Debug, Clone, clap::Args)] +struct OperationGetArgs { + /// Operation ID. + #[arg(long = "operation-id", value_name = "OPERATION_ID")] + operation_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get an operation") + .with_long( + "Poll an async operation by ID. Operations are returned by `hosting app create` \ + and `hosting deployment publish`. Keep polling until status is COMPLETED or FAILED.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APP_READ]), + |ctx, args: OperationGetArgs| async move { + let client = make_client(&ctx, &[APP_READ]).await?; + let data = client + .get_operation(&args.operation_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} diff --git a/rust/src/hosting/operation/mod.rs b/rust/src/hosting/operation/mod.rs new file mode 100644 index 00000000..2a81b919 --- /dev/null +++ b/rust/src/hosting/operation/mod.rs @@ -0,0 +1,13 @@ +mod get; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("operation", "Poll async operations").with_long( + "Poll the status of async operations returned by hosting commands. \ + Operations are created by `hosting app create` and `hosting deployment publish`.", + ), + ) + .with_command(get::command()) +} diff --git a/rust/src/hosting/runtime/get.rs b/rust/src/hosting/runtime/get.rs new file mode 100644 index 00000000..ce0def68 --- /dev/null +++ b/rust/src/hosting/runtime/get.rs @@ -0,0 +1,30 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get application runtime configuration") + .with_long( + "Get the runtime configuration for a hosting application, \ + including environment variables, entry point, and resource limits.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APP_READ]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[APP_READ]).await?; + let data = client.get_runtime(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting app status --app-id ", + "Check application status", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/runtime/mod.rs b/rust/src/hosting/runtime/mod.rs new file mode 100644 index 00000000..7753af0d --- /dev/null +++ b/rust/src/hosting/runtime/mod.rs @@ -0,0 +1,13 @@ +mod get; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("runtime", "View application runtime configuration").with_long( + "Get the runtime configuration for a hosting application, \ + including environment variables, entry point, and resource limits.", + ), + ) + .with_command(get::command()) +} diff --git a/rust/src/hosting/secrets/create.rs b/rust/src/hosting/secrets/create.rs new file mode 100644 index 00000000..9ed2cfe9 --- /dev/null +++ b/rust/src/hosting/secrets/create.rs @@ -0,0 +1,57 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SECRET_WRITE as SECRET_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SecretCreateArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Secret name. + #[arg(long, value_name = "NAME")] + name: String, + + /// Secret value. + #[arg(long, value_name = "VALUE")] + value: String, + + /// Target environment (PREVIEW or PUBLISH). Defaults to PREVIEW. + #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"], default_value = "PREVIEW")] + variant: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("create", "Create an application secret") + .with_long( + "Create a new secret for a hosting application. \ + Use --variant to target PREVIEW or PUBLISH (defaults to PREVIEW).", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SECRET_WRITE]), + |ctx, args: SecretCreateArgs| async move { + let app_id = args.app_id.clone(); + let body = json!({ + "variant": args.variant, + "operations": { + "additions": [{ "name": args.name, "value": args.value }] + } + }); + let client = make_client(&ctx, &[SECRET_WRITE]).await?; + let data = client + .sync_secrets(&app_id, body) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action("hosting secrets list --app-id ", "View all secrets") + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/secrets/delete.rs b/rust/src/hosting/secrets/delete.rs new file mode 100644 index 00000000..ab9fc4eb --- /dev/null +++ b/rust/src/hosting/secrets/delete.rs @@ -0,0 +1,59 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SECRET_WRITE as SECRET_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SecretDeleteArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Secret name. + #[arg(long, value_name = "NAME")] + name: String, + + /// Target environment (PREVIEW or PUBLISH). Defaults to PREVIEW. + #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"], default_value = "PREVIEW")] + variant: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("delete", "Delete an application secret") + .with_long( + "Delete a secret from a hosting application. \ + Use --variant to target PREVIEW or PUBLISH (defaults to PREVIEW). \ + System-managed secrets cannot be deleted.", + ) + .with_system("hosting") + .with_tier(Tier::Destructive) + .mutates(true) + .with_scopes(&[SECRET_WRITE]), + |ctx, args: SecretDeleteArgs| async move { + let app_id = args.app_id.clone(); + let name = args.name.clone(); + let body = json!({ + "variant": args.variant, + "operations": { + "deletions": [{ "name": args.name }] + } + }); + let client = make_client(&ctx, &[SECRET_WRITE]).await?; + let data = client + .sync_secrets(&app_id, body) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting secrets list --app-id ", + "Verify the secret was removed", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("name", NextActionParam::value(name)), + ])) + }, + ) +} diff --git a/rust/src/hosting/secrets/list.rs b/rust/src/hosting/secrets/list.rs new file mode 100644 index 00000000..be9dc3a2 --- /dev/null +++ b/rust/src/hosting/secrets/list.rs @@ -0,0 +1,46 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{HostingSecretSummary, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SECRET_READ as SECRET_READ; + +#[derive(Debug, Clone, clap::Args)] +struct SecretsListArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Filter by environment variant (PREVIEW or PUBLISH). + #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"])] + variant: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List application secrets") + .with_long( + "List secret names for a hosting application. Values are never returned. \ + Use --variant to filter by environment; omit to see all secrets.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[SECRET_READ]) + .with_default_fields("name,scope") + .with_output_schema::(), + |ctx, args: SecretsListArgs| async move { + let app_id = args.app_id.clone(); + let client = make_client(&ctx, &[SECRET_READ]).await?; + let data = client + .list_secrets(&app_id, args.variant.as_deref()) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting secrets create --app-id --name --value ", + "Add a new secret", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/secrets/mod.rs b/rust/src/hosting/secrets/mod.rs new file mode 100644 index 00000000..0a3344b5 --- /dev/null +++ b/rust/src/hosting/secrets/mod.rs @@ -0,0 +1,22 @@ +mod create; +mod delete; +mod list; +mod sync; +mod update; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("secrets", "Manage application secrets").with_long( + "Create, update, delete, and list secrets for a hosting application. \ + Secret values are write-only — list returns names only. \ + Use --variant to target PREVIEW or PUBLISH (defaults to PREVIEW for writes).", + ), + ) + .with_command(list::command()) + .with_command(create::command()) + .with_command(update::command()) + .with_command(delete::command()) + .with_command(sync::command()) +} diff --git a/rust/src/hosting/secrets/sync.rs b/rust/src/hosting/secrets/sync.rs new file mode 100644 index 00000000..c8b629f0 --- /dev/null +++ b/rust/src/hosting/secrets/sync.rs @@ -0,0 +1,100 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SECRET_WRITE as SECRET_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SecretSyncArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Target environment (PREVIEW or PUBLISH). Defaults to PREVIEW. + #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"], default_value = "PREVIEW")] + variant: String, + + /// Secrets to add, as a JSON array: '[{"name":"K","value":"V"}]'. + #[arg(long, value_name = "JSON")] + additions: Option, + + /// Secrets to update, as a JSON array: '[{"name":"K","value":"V"}]'. + #[arg(long, value_name = "JSON")] + updates: Option, + + /// Secrets to delete, as a JSON array: '[{"name":"K"}]'. + #[arg(long, value_name = "JSON")] + deletions: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("sync", "Sync secrets in bulk") + .with_long( + "Low-level access to the secrets sync endpoint. Accepts additions, updates, \ + and deletions in a single call. At least one of --additions, --updates, \ + or --deletions is required. Each value is a JSON array.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SECRET_WRITE]), + |ctx, args: SecretSyncArgs| async move { + let app_id = args.app_id.clone(); + + let parse_json_array = + |s: Option, flag: &str| -> cli_engine::Result> { + match s { + None => Ok(None), + Some(raw) => serde_json::from_str::(&raw).map(Some).map_err(|e| { + crate::error::GddyError::validation(format!( + "--{flag} is not valid JSON: {e}" + )) + .into_cli_error() + }), + } + }; + + let additions = parse_json_array(args.additions, "additions")?; + let updates = parse_json_array(args.updates, "updates")?; + let deletions = parse_json_array(args.deletions, "deletions")?; + + if additions.is_none() && updates.is_none() && deletions.is_none() { + return Err(crate::error::GddyError::validation( + "at least one of --additions, --updates, or --deletions is required", + ) + .into_cli_error()); + } + + let mut operations = serde_json::Map::new(); + if let Some(v) = additions { + operations.insert("additions".to_owned(), v); + } + if let Some(v) = updates { + operations.insert("updates".to_owned(), v); + } + if let Some(v) = deletions { + operations.insert("deletions".to_owned(), v); + } + + let body = json!({ + "variant": args.variant, + "operations": Value::Object(operations), + }); + + let client = make_client(&ctx, &[SECRET_WRITE]).await?; + let data = client + .sync_secrets(&app_id, body) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting secrets list --app-id ", + "View updated secrets", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/secrets/update.rs b/rust/src/hosting/secrets/update.rs new file mode 100644 index 00000000..582a9f3a --- /dev/null +++ b/rust/src/hosting/secrets/update.rs @@ -0,0 +1,57 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SECRET_WRITE as SECRET_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SecretUpdateArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Secret name. + #[arg(long, value_name = "NAME")] + name: String, + + /// New secret value. + #[arg(long, value_name = "VALUE")] + value: String, + + /// Target environment (PREVIEW or PUBLISH). Defaults to PREVIEW. + #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"], default_value = "PREVIEW")] + variant: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("update", "Update an application secret") + .with_long( + "Update the value of an existing secret for a hosting application. \ + Use --variant to target PREVIEW or PUBLISH (defaults to PREVIEW).", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SECRET_WRITE]), + |ctx, args: SecretUpdateArgs| async move { + let app_id = args.app_id.clone(); + let body = json!({ + "variant": args.variant, + "operations": { + "updates": [{ "name": args.name, "value": args.value }] + } + }); + let client = make_client(&ctx, &[SECRET_WRITE]).await?; + let data = client + .sync_secrets(&app_id, body) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action("hosting secrets list --app-id ", "View all secrets") + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/source/import.rs b/rust/src/hosting/source/import.rs new file mode 100644 index 00000000..436d5458 --- /dev/null +++ b/rust/src/hosting/source/import.rs @@ -0,0 +1,56 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SOURCE_WRITE as SOURCE_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SourceImportArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// GitHub repository in owner/repo format. + #[arg(long, value_name = "OWNER/REPO")] + repo: String, + + /// Branch to import from. + #[arg(long, value_name = "BRANCH")] + branch: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("import", "Import source from GitHub") + .with_long( + "Start a source import from a GitHub repository branch. \ + Returns immediately — poll `hosting source status` until status is \ + COMPLETED or FAILED. Then use `hosting deployment publish` to deploy.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SOURCE_WRITE]), + |ctx, args: SourceImportArgs| async move { + let app_id = args.app_id.clone(); + let client = make_client(&ctx, &[SOURCE_WRITE]).await?; + let data = client + .create_import(&app_id, &args.repo, &args.branch) + .await + .map_err(client_err)?; + let import_id = data + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting source status --app-id --import-id ", + "Poll until import completes", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("import-id", NextActionParam::value(import_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/source/mod.rs b/rust/src/hosting/source/mod.rs new file mode 100644 index 00000000..8a47fa08 --- /dev/null +++ b/rust/src/hosting/source/mod.rs @@ -0,0 +1,15 @@ +mod import; +mod status; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("source", "Manage application source code").with_long( + "Import source code from GitHub and check import status. \ + After a successful import, use `hosting deployment publish` to deploy.", + ), + ) + .with_command(import::command()) + .with_command(status::command()) +} diff --git a/rust/src/hosting/source/status.rs b/rust/src/hosting/source/status.rs new file mode 100644 index 00000000..aecb04a8 --- /dev/null +++ b/rust/src/hosting/source/status.rs @@ -0,0 +1,43 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SOURCE_READ as SOURCE_READ; + +#[derive(Debug, Clone, clap::Args)] +struct SourceStatusArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Import ID returned by `hosting source import`. + #[arg(long = "import-id", value_name = "IMPORT_ID")] + import_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("status", "Get the status of a source import") + .with_long( + "Poll the status of a source import started with `hosting source import`. \ + Keep polling until status is COMPLETED or FAILED, \ + then use `hosting deployment publish` to deploy.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[SOURCE_READ]), + |ctx, args: SourceStatusArgs| async move { + let app_id = args.app_id.clone(); + let client = make_client(&ctx, &[SOURCE_READ]).await?; + let data = client + .get_import(&app_id, &args.import_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![next_action( + "hosting deployment publish --app-id ", + "Deploy the imported source", + ) + .with_param("app-id", NextActionParam::value(app_id))])) + }, + ) +} diff --git a/rust/src/hosting/subscription/attach.rs b/rust/src/hosting/subscription/attach.rs new file mode 100644 index 00000000..ef812909 --- /dev/null +++ b/rust/src/hosting/subscription/attach.rs @@ -0,0 +1,48 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SUBSCRIPTION_WRITE as SUB_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SubscriptionAttachArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Subscription ID of the hosting plan to attach. + #[arg(long = "subscription-id", value_name = "SUBSCRIPTION_ID")] + subscription_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "attach", + "Attach a hosting plan to an application", + ) + .with_long( + "Attach a hosting plan subscription to an application. \ + Use `hosting subscription list` to find available subscription IDs.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SUB_WRITE]), + |ctx, args: SubscriptionAttachArgs| async move { + let app_id = args.app_id.clone(); + let client = make_client(&ctx, &[SUB_WRITE]).await?; + let data = client + .attach_subscription(&app_id, &args.subscription_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting subscription get --app-id ", + "Verify the attached subscription", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/subscription/get.rs b/rust/src/hosting/subscription/get.rs new file mode 100644 index 00000000..71f6776f --- /dev/null +++ b/rust/src/hosting/subscription/get.rs @@ -0,0 +1,36 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SUBSCRIPTION_READ as SUB_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "get", + "Get the subscription attached to an application", + ) + .with_long( + "Get the hosting plan subscription currently attached to a specific application.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[SUB_READ]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[SUB_READ]).await?; + let data = client + .get_app_subscription(&app_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting subscription attach --app-id --subscription-id ", + "Attach a different subscription", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("subscription-id", NextActionParam::required()), + ])) + }, + ) +} diff --git a/rust/src/hosting/subscription/list.rs b/rust/src/hosting/subscription/list.rs new file mode 100644 index 00000000..4921f252 --- /dev/null +++ b/rust/src/hosting/subscription/list.rs @@ -0,0 +1,75 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::common::{ + HostingSubscriptionSummary, client_err, make_client, next_page_token, +}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SUBSCRIPTION_READ as SUB_READ; + +#[derive(Debug, Clone, clap::Args)] +struct SubscriptionListArgs { + /// Maximum number of subscriptions to return. Omit to return all. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + limit: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "list", + "List available hosting plan subscriptions", + ) + .with_long( + "List hosting plan subscriptions available to attach to applications. \ + Results are autopaginated.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[SUB_READ]) + .with_default_fields("id,planName,status") + .with_output_schema::(), + |ctx, args: SubscriptionListArgs| async move { + let limit = args.limit; + let client = make_client(&ctx, &[SUB_READ]).await?; + + let mut all_items: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let page_limit = + limit.map(|cap| cap.saturating_sub(all_items.len() as u32).min(100)); + + let response = client + .list_subscriptions(page_token.as_deref(), page_limit) + .await + .map_err(client_err)?; + + if let Some(items) = response.get("items").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + if limit.is_some_and(|cap| all_items.len() >= cap as usize) { + all_items.truncate(limit.expect("checked") as usize); + break; + } + + match next_page_token(&response) { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok( + CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ + next_action( + "hosting subscription attach --app-id --subscription-id ", + "Attach a subscription to an application", + ) + .with_param("app-id", NextActionParam::required()) + .with_param("subscription-id", NextActionParam::required()), + ]), + ) + }, + ) +} diff --git a/rust/src/hosting/subscription/mod.rs b/rust/src/hosting/subscription/mod.rs new file mode 100644 index 00000000..7ca0780f --- /dev/null +++ b/rust/src/hosting/subscription/mod.rs @@ -0,0 +1,18 @@ +mod attach; +mod get; +mod list; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("subscription", "Manage hosting plan subscriptions").with_long( + "List available hosting plan subscriptions and attach one to an application. \ + A subscription represents a hosting plan (resources, billing) that backs \ + the application's runtime.", + ), + ) + .with_command(list::command()) + .with_command(get::command()) + .with_command(attach::command()) +} diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 3aca2895..4fb874e5 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -122,6 +122,37 @@ declare_scopes! { /// Read Node.js Hosting app logs (`hosting nodejs app logs`). HOSTING_LOGS_READ => "hosting.paas.logs:read", + /// Read hosting applications (`hosting app list/get/status`). + HOSTING_APPLICATION_READ => "hosting.application:read", + /// Create a hosting application (`hosting app create`). + HOSTING_APPLICATION_CREATE => "hosting.application:create", + /// Update a hosting application (`hosting app update`). + HOSTING_APPLICATION_UPDATE => "hosting.application:update", + /// Delete a hosting application (`hosting app delete`). + HOSTING_APPLICATION_DELETE => "hosting.application:delete", + /// Trigger deployments and restarts (`hosting app restart`, `hosting deployment publish`). + HOSTING_DEPLOYMENT_EXECUTE => "hosting.deployment:execute", + /// Read source imports (`hosting import get`). + HOSTING_SOURCE_READ => "hosting.source:read", + /// Create a source import (`hosting import create`). + HOSTING_SOURCE_WRITE => "hosting.source:write", + /// Read the GitHub connection and browse repositories (`hosting github status/repos/branches`). + HOSTING_GITHUB_READ => "hosting.github:read", + /// Read application secrets (`hosting secrets list`). + HOSTING_SECRET_READ => "hosting.secret:read", + /// Write application secrets (`hosting secrets create/update/delete/sync`). + HOSTING_SECRET_WRITE => "hosting.secret:write", + /// Read application logs (`hosting log list`). + HOSTING_LOG_READ => "hosting.log:read", + /// Read application domains (`hosting domain list/get`). + HOSTING_DOMAIN_READ => "hosting.domain:read", + /// Attach or detach application domains (`hosting domain attach/detach`). + HOSTING_DOMAIN_WRITE => "hosting.domain:write", + /// Read hosting subscriptions (`hosting subscription list/get`). + HOSTING_SUBSCRIPTION_READ => "hosting.subscription:read", + /// Attach a subscription to an application (`hosting subscription attach`). + HOSTING_SUBSCRIPTION_WRITE => "hosting.subscription:write", + /// Read mailboxes and check mailbox-creation eligibility (`email list`). EMAIL_READ => "email.mailbox:read", /// Create a mailbox (`email create`). @@ -220,6 +251,81 @@ pub const SCOPE_REGISTRY: &[ScopeInfo] = &[ description: "Read Node.js Hosting app logs", default: false, }, + ScopeInfo { + scope: HOSTING_APPLICATION_READ, + description: "Read hosting applications", + default: false, + }, + ScopeInfo { + scope: HOSTING_APPLICATION_CREATE, + description: "Create a hosting application", + default: false, + }, + ScopeInfo { + scope: HOSTING_APPLICATION_UPDATE, + description: "Update a hosting application", + default: false, + }, + ScopeInfo { + scope: HOSTING_APPLICATION_DELETE, + description: "Delete a hosting application", + default: false, + }, + ScopeInfo { + scope: HOSTING_DEPLOYMENT_EXECUTE, + description: "Trigger deployments and restarts", + default: false, + }, + ScopeInfo { + scope: HOSTING_SOURCE_READ, + description: "Read source imports for hosting applications", + default: false, + }, + ScopeInfo { + scope: HOSTING_SOURCE_WRITE, + description: "Create source imports for hosting applications", + default: false, + }, + ScopeInfo { + scope: HOSTING_GITHUB_READ, + description: "Read the GitHub connection and browse repositories and branches", + default: false, + }, + ScopeInfo { + scope: HOSTING_SECRET_READ, + description: "Read application secret names", + default: false, + }, + ScopeInfo { + scope: HOSTING_SECRET_WRITE, + description: "Create, update, or delete application secrets", + default: false, + }, + ScopeInfo { + scope: HOSTING_LOG_READ, + description: "Read application logs", + default: false, + }, + ScopeInfo { + scope: HOSTING_DOMAIN_READ, + description: "Read domains attached to hosting applications", + default: false, + }, + ScopeInfo { + scope: HOSTING_DOMAIN_WRITE, + description: "Attach or detach domains from hosting applications", + default: false, + }, + ScopeInfo { + scope: HOSTING_SUBSCRIPTION_READ, + description: "Read hosting subscriptions", + default: false, + }, + ScopeInfo { + scope: HOSTING_SUBSCRIPTION_WRITE, + description: "Attach a hosting subscription to an application", + default: false, + }, ScopeInfo { scope: HOSTING_GITHUB_EXECUTE, description: "Connect GitHub and import code for your Node.js Hosting apps", From 8f134e843602883d611d1e89626cdc7dd597aad7 Mon Sep 17 00:00:00 2001 From: James Henderson Date: Fri, 4 Sep 2026 15:56:23 +0100 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20HPX-2484=20=E2=80=93=20Modify=20so?= =?UTF-8?q?urce=20upload=20intents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/src/hosting/client.rs | 53 ++++++++++ rust/src/hosting/client_tests.rs | 22 ++++ rust/src/hosting/mod.rs | 4 +- .../hosting/source/{import.rs => github.rs} | 26 +++-- rust/src/hosting/source/mod.rs | 11 +- rust/src/hosting/source/status.rs | 12 ++- rust/src/hosting/source/upload.rs | 100 ++++++++++++++++++ 7 files changed, 206 insertions(+), 22 deletions(-) rename rust/src/hosting/source/{import.rs => github.rs} (73%) create mode 100644 rust/src/hosting/source/upload.rs diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index 82c3eafa..f24c0b8e 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -11,6 +11,11 @@ pub enum ClientError { Http { status: u16, body: String }, #[error("network error: {0}")] Network(#[from] reqwest::Error), + #[error("failed to read {path}: {source}")] + Io { + path: String, + source: std::io::Error, + }, } impl From for crate::error::GddyError { @@ -20,6 +25,9 @@ impl From for crate::error::GddyError { ClientError::Network(e) => { Self::network(format!("network error: {e}")).with_system("hosting") } + ClientError::Io { path, source } => { + Self::validation(format!("failed to read {path}: {source}")).with_system("hosting") + } } } } @@ -269,6 +277,51 @@ impl HostingClient { .await } + pub async fn create_import_zip( + &self, + app_id: &str, + zip_path: &std::path::Path, + ) -> Result { + let form = reqwest::multipart::Form::new() + .file("file", zip_path) + .await + .map_err(|e| ClientError::Io { + path: zip_path.display().to_string(), + source: e, + })?; + + let request = self + .client + .post(self.url(&format!("/apps/{app_id}/imports"))) + .bearer_auth(&self.token) + .header("x-request-id", Self::new_request_id()) + .multipart(form) + .build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let resp = self.client.execute(request).await?; + + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + let status = status.as_u16(); + if !(200..300).contains(&status) { + return Err(ClientError::Http { + status, + body: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + + serde_json::from_slice(&bytes).map_err(|e| ClientError::Http { + status, + body: format!( + "invalid JSON response: {e} (body: {})", + String::from_utf8_lossy(&bytes) + ), + }) + } + pub async fn get_import(&self, app_id: &str, import_id: &str) -> Result { self.send_json( Method::GET, diff --git a/rust/src/hosting/client_tests.rs b/rust/src/hosting/client_tests.rs index a91316d8..f4de753f 100644 --- a/rust/src/hosting/client_tests.rs +++ b/rust/src/hosting/client_tests.rs @@ -297,6 +297,28 @@ async fn get_import_hits_correct_path() { mock.assert_async().await; } +#[tokio::test] +async fn create_import_zip_sends_multipart_to_imports_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/apps/app-1/imports") + .header_exists("content-type"); + then.status(202).json_body(json!({ "id": "imp-zip-1" })); + }) + .await; + + let tmp = tempfile::NamedTempFile::new().expect("tmp file"); + let result = client(&server.base_url()) + .create_import_zip("app-1", tmp.path()) + .await + .expect("create import zip"); + + mock.assert_async().await; + assert_eq!(result["id"], "imp-zip-1"); +} + #[tokio::test] async fn get_github_connection_hits_correct_path() { let server = MockServer::start_async().await; diff --git a/rust/src/hosting/mod.rs b/rust/src/hosting/mod.rs index eccada23..51325167 100644 --- a/rust/src/hosting/mod.rs +++ b/rust/src/hosting/mod.rs @@ -6,11 +6,11 @@ pub mod common; mod deployment; mod domain; mod github; -mod source; mod log; mod operation; mod runtime; mod secrets; +mod source; mod subscription; use cli_engine::{GroupSpec, Module, RuntimeGroupSpec, Stage}; @@ -23,7 +23,7 @@ pub fn module() -> Module { \n\ • app — Hosting applications (create, inspect, update, delete, restart)\n\ • deployment — Build and deploy application source\n\ - • source — Import source code from GitHub\n\ + • source — Import source code\n\ • github — GitHub connection, repositories, branches\n\ • secrets — Application secrets (create, update, delete, list)\n\ • log — Application log entries\n\ diff --git a/rust/src/hosting/source/import.rs b/rust/src/hosting/source/github.rs similarity index 73% rename from rust/src/hosting/source/import.rs rename to rust/src/hosting/source/github.rs index 436d5458..312856ea 100644 --- a/rust/src/hosting/source/import.rs +++ b/rust/src/hosting/source/github.rs @@ -5,7 +5,7 @@ use crate::next_action::next_action; use crate::scopes::HOSTING_SOURCE_WRITE as SOURCE_WRITE; #[derive(Debug, Clone, clap::Args)] -struct SourceImportArgs { +struct SourceGithubArgs { /// Application ID. #[arg(long = "app-id", value_name = "APP_ID")] app_id: String, @@ -20,18 +20,22 @@ struct SourceImportArgs { } pub(super) fn command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("import", "Import source from GitHub") - .with_long( - "Start a source import from a GitHub repository branch. \ + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "github", + "Import source from a GitHub repository", + ) + .with_long( + "Start a source import from a GitHub repository branch. \ + Use `hosting source upload` instead when you have a local zip archive. \ Returns immediately — poll `hosting source status` until status is \ COMPLETED or FAILED. Then use `hosting deployment publish` to deploy.", - ) - .with_system("hosting") - .with_tier(Tier::Mutate) - .mutates(true) - .with_scopes(&[SOURCE_WRITE]), - |ctx, args: SourceImportArgs| async move { + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SOURCE_WRITE]), + |ctx, args: SourceGithubArgs| async move { let app_id = args.app_id.clone(); let client = make_client(&ctx, &[SOURCE_WRITE]).await?; let data = client diff --git a/rust/src/hosting/source/mod.rs b/rust/src/hosting/source/mod.rs index 8a47fa08..9b7bcad5 100644 --- a/rust/src/hosting/source/mod.rs +++ b/rust/src/hosting/source/mod.rs @@ -1,15 +1,18 @@ -mod import; +mod github; mod status; +mod upload; use cli_engine::{GroupSpec, RuntimeGroupSpec}; pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( GroupSpec::new("source", "Manage application source code").with_long( - "Import source code from GitHub and check import status. \ - After a successful import, use `hosting deployment publish` to deploy.", + "Upload source code from a local zip archive or import from GitHub, \ + then check import status. After a successful import, use \ + `hosting deployment publish` to deploy.", ), ) - .with_command(import::command()) + .with_command(upload::command()) .with_command(status::command()) + .with_command(github::command()) } diff --git a/rust/src/hosting/source/status.rs b/rust/src/hosting/source/status.rs index aecb04a8..0fd24afc 100644 --- a/rust/src/hosting/source/status.rs +++ b/rust/src/hosting/source/status.rs @@ -33,11 +33,13 @@ pub(super) fn command() -> RuntimeCommandSpec { .get_import(&app_id, &args.import_id) .await .map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![next_action( - "hosting deployment publish --app-id ", - "Deploy the imported source", - ) - .with_param("app-id", NextActionParam::value(app_id))])) + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting deployment publish --app-id ", + "Deploy the imported source", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) }, ) } diff --git a/rust/src/hosting/source/upload.rs b/rust/src/hosting/source/upload.rs new file mode 100644 index 00000000..bff6a775 --- /dev/null +++ b/rust/src/hosting/source/upload.rs @@ -0,0 +1,100 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::common::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_SOURCE_WRITE as SOURCE_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SourceUploadArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Path to the zip archive to upload. + #[arg(long, short = 'f', value_name = "PATH")] + file: String, +} + +fn require_zip_file(file: &str) -> Result<(), crate::error::GddyError> { + if std::path::Path::new(file).is_file() { + return Ok(()); + } + Err(crate::error::GddyError::validation(format!( + "zip file not found: {file}" + ))) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "upload", + "Upload a zip archive as the app's source code", + ) + .with_long( + "Upload a local zip archive as source code for an existing app. \ + Use `hosting source github` instead to import from a GitHub repository. \ + Returns immediately — poll `hosting source status` until status is \ + COMPLETED or FAILED. Then use `hosting deployment publish` to deploy.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SOURCE_WRITE]), + |ctx, args: SourceUploadArgs| async move { + let app_id = args.app_id; + let file = args.file; + require_zip_file(&file).map_err(crate::error::GddyError::into_cli_error)?; + let path = std::path::Path::new(&file); + let client = make_client(&ctx, &[SOURCE_WRITE]).await?; + let data = client + .create_import_zip(&app_id, path) + .await + .map_err(client_err)?; + let import_id = data + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let id_param = if import_id.is_empty() { + NextActionParam::required() + } else { + NextActionParam::value(&import_id) + }; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting source status --app-id --import-id ", + "Poll until upload import completes", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("import-id", id_param), + ])) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::require_zip_file; + + #[test] + fn require_zip_file_rejects_missing_path() { + let err = require_zip_file("/no/such/file.zip").expect_err("missing file should error"); + assert!(err.to_string().contains("zip file not found")); + assert!(err.to_string().contains("/no/such/file.zip")); + } + + #[test] + fn require_zip_file_accepts_an_existing_file() { + let tmp = tempfile::NamedTempFile::new().expect("failed to create temp file"); + let path = tmp.path().to_str().expect("temp path should be utf8"); + assert!(require_zip_file(path).is_ok()); + } + + #[test] + fn require_zip_file_rejects_a_directory() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let path = tmp.path().to_str().expect("temp path should be utf8"); + let err = require_zip_file(path).expect_err("directory should error"); + assert!(err.to_string().contains("zip file not found")); + } +} From 6219922f5150c4b00e01700371421522e44c4b76 Mon Sep 17 00:00:00 2001 From: James Henderson Date: Fri, 4 Sep 2026 17:09:45 +0100 Subject: [PATCH 3/5] chore: fix linting errors --- rust/src/hosting/common.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/src/hosting/common.rs b/rust/src/hosting/common.rs index d0966533..598f6029 100644 --- a/rust/src/hosting/common.rs +++ b/rust/src/hosting/common.rs @@ -99,9 +99,9 @@ mod tests { #[test] fn parse_app_type_accepts_known_types_case_insensitive() { - assert_eq!(parse_app_type("NODEJS").unwrap(), "NODEJS"); - assert_eq!(parse_app_type("nodejs").unwrap(), "NODEJS"); - assert_eq!(parse_app_type("MHWP").unwrap(), "MHWP"); + assert_eq!(parse_app_type("NODEJS").expect("NODEJS"), "NODEJS"); + assert_eq!(parse_app_type("nodejs").expect("nodejs"), "NODEJS"); + assert_eq!(parse_app_type("MHWP").expect("MHWP"), "MHWP"); } #[test] From 84cbc078ef230e5c88c154885c49050393cdeb98 Mon Sep 17 00:00:00 2001 From: James Henderson Date: Fri, 4 Sep 2026 22:42:41 +0100 Subject: [PATCH 4/5] refactor: add schemas based on API spec --- rust/src/hosting/app/create.rs | 5 +- rust/src/hosting/app/get.rs | 5 +- rust/src/hosting/app/list.rs | 3 +- rust/src/hosting/app/status.rs | 5 +- rust/src/hosting/app/update.rs | 5 +- rust/src/hosting/client.rs | 4 +- rust/src/hosting/common.rs | 79 +++++++++++++++++++++++-- rust/src/hosting/deployment/get.rs | 5 +- rust/src/hosting/deployment/list.rs | 20 +++---- rust/src/hosting/deployment/publish.rs | 7 ++- rust/src/hosting/domain/attach.rs | 5 +- rust/src/hosting/domain/get.rs | 5 +- rust/src/hosting/domain/list.rs | 18 +++--- rust/src/hosting/github/branches.rs | 16 ++--- rust/src/hosting/github/repos.rs | 23 ++++--- rust/src/hosting/github/status.rs | 5 +- rust/src/hosting/log/list.rs | 2 +- rust/src/hosting/nodejs/app/list.rs | 8 ++- rust/src/hosting/operation/get.rs | 5 +- rust/src/hosting/runtime/get.rs | 5 +- rust/src/hosting/secrets/create.rs | 5 +- rust/src/hosting/secrets/list.rs | 10 +++- rust/src/hosting/secrets/sync.rs | 5 +- rust/src/hosting/secrets/update.rs | 5 +- rust/src/hosting/source/github.rs | 5 +- rust/src/hosting/source/status.rs | 5 +- rust/src/hosting/source/upload.rs | 5 +- rust/src/hosting/subscription/attach.rs | 5 +- rust/src/hosting/subscription/get.rs | 5 +- rust/src/hosting/subscription/list.rs | 20 +++---- rust/src/scopes.rs | 2 +- 31 files changed, 199 insertions(+), 103 deletions(-) diff --git a/rust/src/hosting/app/create.rs b/rust/src/hosting/app/create.rs index ad53d24d..887ebb86 100644 --- a/rust/src/hosting/app/create.rs +++ b/rust/src/hosting/app/create.rs @@ -1,7 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::json; -use crate::hosting::common::{client_err, make_client, parse_app_type}; +use crate::hosting::common::{HostingAppOperation, client_err, make_client, parse_app_type}; use crate::next_action::next_action; use crate::scopes::HOSTING_APPLICATION_CREATE as APP_CREATE; @@ -29,7 +29,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[APP_CREATE]), + .with_scopes(&[APP_CREATE]) + .with_output_schema::(), |ctx, args: AppCreateArgs| async move { let app_type = args.app_type; let name = args.name; diff --git a/rust/src/hosting/app/get.rs b/rust/src/hosting/app/get.rs index db5ece48..11840ff9 100644 --- a/rust/src/hosting/app/get.rs +++ b/rust/src/hosting/app/get.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::hosting::common::{AppIdArgs, HostingApplication, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; @@ -10,7 +10,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_long("Get details for a single hosting application by ID.") .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[APP_READ]), + .with_scopes(&[APP_READ]) + .with_output_schema::(), |ctx, args: AppIdArgs| async move { let app_id = args.app_id; let client = make_client(&ctx, &[APP_READ]).await?; diff --git a/rust/src/hosting/app/list.rs b/rust/src/hosting/app/list.rs index 03e6ad28..7abe51f2 100644 --- a/rust/src/hosting/app/list.rs +++ b/rust/src/hosting/app/list.rs @@ -68,8 +68,7 @@ pub(super) fn command() -> RuntimeCommandSpec { } } - let data = json!({ "items": all_items }); - Ok(CommandResult::new(data).with_next_actions(vec![ + Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ next_action( "hosting app get --app-id ", "Get details for an application", diff --git a/rust/src/hosting/app/status.rs b/rust/src/hosting/app/status.rs index e98122fa..135b0b7a 100644 --- a/rust/src/hosting/app/status.rs +++ b/rust/src/hosting/app/status.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::hosting::common::{AppIdArgs, HostingApplicationStatus, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; @@ -14,7 +14,8 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[APP_READ]), + .with_scopes(&[APP_READ]) + .with_output_schema::(), |ctx, args: AppIdArgs| async move { let app_id = args.app_id; let client = make_client(&ctx, &[APP_READ]).await?; diff --git a/rust/src/hosting/app/update.rs b/rust/src/hosting/app/update.rs index 913f26ef..550b150c 100644 --- a/rust/src/hosting/app/update.rs +++ b/rust/src/hosting/app/update.rs @@ -1,7 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; use serde_json::{Value, json}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingApplication, client_err, make_client}; use crate::scopes::HOSTING_APPLICATION_UPDATE as APP_UPDATE; #[derive(Debug, Clone, clap::Args)] @@ -30,7 +30,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[APP_UPDATE]), + .with_scopes(&[APP_UPDATE]) + .with_output_schema::(), |ctx, args: AppUpdateArgs| async move { let app_id = args.app_id; let name = args.name.filter(|s| !s.trim().is_empty()); diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index f24c0b8e..730e2031 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -64,7 +64,7 @@ impl HostingClient { ) -> Result { let mut req = self .client - .request(method, self.url(path)) + .request(method.clone(), self.url(path)) .bearer_auth(&self.token) .header("x-request-id", Self::new_request_id()); @@ -74,6 +74,8 @@ impl HostingClient { if let Some(body) = body { req = req.json(&body); + } else if matches!(method, Method::POST | Method::PUT | Method::PATCH) { + req = req.json(&json!({})); } let request = req.build()?; diff --git a/rust/src/hosting/common.rs b/rust/src/hosting/common.rs index 598f6029..80aad3fe 100644 --- a/rust/src/hosting/common.rs +++ b/rust/src/hosting/common.rs @@ -12,34 +12,103 @@ output_schema!(HostingAppSummary { }); output_schema!(HostingDeploymentSummary { - "id": "string"; + "deploymentId": "string"; "status": "string"; "createdAt": "string"; + "updatedAt": "string"; + "gitHash": "string"; }); output_schema!(HostingSecretSummary { "name": "string"; - "scope": "string"; + "systemManaged": "boolean"; }); output_schema!(HostingDomainSummary { "id": "string"; "hostname": "string"; - "status": "string"; + "role": "string"; + "verificationStatus": "string"; }); output_schema!(HostingSubscriptionSummary { - "id": "string"; - "planName": "string"; + "subscriptionId": "string"; + "hostingProduct": "string"; + "tier": "string"; + "label": "string"; "status": "string"; + "slotLimit": "number"; + "attachedCount": "number"; + "availableSlots": "number"; + "paidThroughDate": "string"; +}); + +output_schema!(HostingSubscriptionAttachment { + "subscriptionId": "string"; + "hostingProduct": "string"; + "attachState": "string"; }); output_schema!(HostingLogEntry { "timestamp": "string"; "level": "string"; + "source": "string"; "message": "string"; }); +output_schema!(HostingApplication { + "id": "string"; + "name": "string"; + "appType": "string"; + "status": "string"; + "urls": "object"; + "createdAt": "string"; + "updatedAt": "string"; +}); + +output_schema!(HostingAppOperation { + "operationId": "string"; + "status": "string"; +}); + +output_schema!(HostingApplicationStatus { + "status": "string"; + "variants": "array"; +}); + +output_schema!(HostingSourceImport { + "id": "string"; + "importType": "string"; + "status": "string"; + "createdAt": "string"; +}); + +output_schema!(HostingDomain { + "id": "string"; + "hostname": "string"; + "role": "string"; + "verificationStatus": "string"; +}); + +output_schema!(HostingGitHubProfile { + "connected": "boolean"; + "appInstalled": "boolean"; + "appInstallUrl": "string"; +}); + +output_schema!(HostingRepository { + "fullName": "string"; +}); + +output_schema!(HostingBranch { + "name": "string"; +}); + +output_schema!(HostingRuntime { + "runtime": "string"; + "version": "string"; +}); + pub fn client_err(e: ClientError) -> CliCoreError { crate::error::GddyError::from(e).into_cli_error() } diff --git a/rust/src/hosting/deployment/get.rs b/rust/src/hosting/deployment/get.rs index ae49f91f..518bad86 100644 --- a/rust/src/hosting/deployment/get.rs +++ b/rust/src/hosting/deployment/get.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingDeploymentSummary, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; @@ -21,7 +21,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_long("Get the current status and details of a specific deployment.") .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[APP_READ]), + .with_scopes(&[APP_READ]) + .with_output_schema::(), |ctx, args: DeploymentGetArgs| async move { let app_id = args.app_id.clone(); let deployment_id = args.deployment_id; diff --git a/rust/src/hosting/deployment/list.rs b/rust/src/hosting/deployment/list.rs index 0a3878a8..e4f9b181 100644 --- a/rust/src/hosting/deployment/list.rs +++ b/rust/src/hosting/deployment/list.rs @@ -26,7 +26,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Read) .with_scopes(&[APP_READ]) - .with_default_fields("id,status,createdAt") + .with_default_fields("deploymentId,status,createdAt") .with_output_schema::(), |ctx, args: DeploymentListArgs| async move { let app_id = args.app_id; @@ -60,16 +60,14 @@ pub(super) fn command() -> RuntimeCommandSpec { } } - Ok( - CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ - next_action( - "hosting deployment get --app-id --deployment-id ", - "Get full details of a deployment", - ) - .with_param("app-id", NextActionParam::required()) - .with_param("deployment-id", NextActionParam::required()), - ]), - ) + Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ + next_action( + "hosting deployment get --app-id --deployment-id ", + "Get full details of a deployment", + ) + .with_param("app-id", NextActionParam::required()) + .with_param("deployment-id", NextActionParam::required()), + ])) }, ) } diff --git a/rust/src/hosting/deployment/publish.rs b/rust/src/hosting/deployment/publish.rs index 2bc72d67..ca023bdd 100644 --- a/rust/src/hosting/deployment/publish.rs +++ b/rust/src/hosting/deployment/publish.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::hosting::common::{AppIdArgs, HostingDeploymentSummary, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_DEPLOYMENT_EXECUTE as DEPLOY_EXECUTE; @@ -15,7 +15,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[DEPLOY_EXECUTE]), + .with_scopes(&[DEPLOY_EXECUTE]) + .with_output_schema::(), |ctx, args: AppIdArgs| async move { let app_id = args.app_id; let client = make_client(&ctx, &[DEPLOY_EXECUTE]).await?; @@ -24,7 +25,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .await .map_err(client_err)?; let deployment_id = data - .get("id") + .get("deploymentId") .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); diff --git a/rust/src/hosting/domain/attach.rs b/rust/src/hosting/domain/attach.rs index 8e5d647d..b6116757 100644 --- a/rust/src/hosting/domain/attach.rs +++ b/rust/src/hosting/domain/attach.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingDomain, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_DOMAIN_WRITE as DOMAIN_WRITE; @@ -25,7 +25,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[DOMAIN_WRITE]), + .with_scopes(&[DOMAIN_WRITE]) + .with_output_schema::(), |ctx, args: DomainAttachArgs| async move { let app_id = args.app_id.clone(); let client = make_client(&ctx, &[DOMAIN_WRITE]).await?; diff --git a/rust/src/hosting/domain/get.rs b/rust/src/hosting/domain/get.rs index cc82f9bd..bb898a78 100644 --- a/rust/src/hosting/domain/get.rs +++ b/rust/src/hosting/domain/get.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingDomain, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_DOMAIN_READ as DOMAIN_READ; @@ -21,7 +21,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_long("Get the details and status of a domain attached to a hosting application.") .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[DOMAIN_READ]), + .with_scopes(&[DOMAIN_READ]) + .with_output_schema::(), |ctx, args: DomainGetArgs| async move { let app_id = args.app_id.clone(); let domain_id = args.domain_id.clone(); diff --git a/rust/src/hosting/domain/list.rs b/rust/src/hosting/domain/list.rs index 10174aa4..80124f6e 100644 --- a/rust/src/hosting/domain/list.rs +++ b/rust/src/hosting/domain/list.rs @@ -26,7 +26,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Read) .with_scopes(&[DOMAIN_READ]) - .with_default_fields("id,hostname,status") + .with_default_fields("id,hostname,verificationStatus") .with_output_schema::(), |ctx, args: DomainListArgs| async move { let app_id = args.app_id.clone(); @@ -60,15 +60,13 @@ pub(super) fn command() -> RuntimeCommandSpec { } } - Ok( - CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ - next_action( - "hosting domain attach --app-id --hostname ", - "Attach a new domain", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ]), - ) + Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ + next_action( + "hosting domain attach --app-id --hostname ", + "Attach a new domain", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) }, ) } diff --git a/rust/src/hosting/github/branches.rs b/rust/src/hosting/github/branches.rs index 969a6265..f1727a0d 100644 --- a/rust/src/hosting/github/branches.rs +++ b/rust/src/hosting/github/branches.rs @@ -1,7 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::{Value, json}; -use crate::hosting::common::{client_err, make_client, next_page_token}; +use crate::hosting::common::{HostingBranch, client_err, make_client, next_page_token}; use crate::next_action::next_action; use crate::scopes::HOSTING_GITHUB_READ as GH_READ; @@ -30,7 +30,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Read) .with_scopes(&[GH_READ]) - .with_default_fields("name"), + .with_default_fields("name") + .with_output_schema::(), |ctx, args: BranchesArgs| async move { let limit = args.limit; let client = make_client(&ctx, &[GH_READ]).await?; @@ -67,17 +68,18 @@ pub(super) fn command() -> RuntimeCommandSpec { } } - Ok( - CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ + Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ next_action( "hosting import create --app-id --repo --branch ", "Import from this branch", ) .with_param("app-id", NextActionParam::required()) - .with_param("repo", NextActionParam::value(format!("{}/{}", args.owner, args.repo))) + .with_param( + "repo", + NextActionParam::value(format!("{}/{}", args.owner, args.repo)), + ) .with_param("branch", NextActionParam::required()), - ]), - ) + ])) }, ) } diff --git a/rust/src/hosting/github/repos.rs b/rust/src/hosting/github/repos.rs index 49dfe048..b3f7da27 100644 --- a/rust/src/hosting/github/repos.rs +++ b/rust/src/hosting/github/repos.rs @@ -1,7 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::{Value, json}; -use crate::hosting::common::{client_err, make_client, next_page_token}; +use crate::hosting::common::{HostingRepository, client_err, make_client, next_page_token}; use crate::next_action::next_action; use crate::scopes::HOSTING_GITHUB_READ as GH_READ; @@ -22,7 +22,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Read) .with_scopes(&[GH_READ]) - .with_default_fields("fullName"), + .with_default_fields("fullName") + .with_output_schema::(), |ctx, args: ReposArgs| async move { let limit = args.limit; let client = make_client(&ctx, &[GH_READ]).await?; @@ -54,16 +55,14 @@ pub(super) fn command() -> RuntimeCommandSpec { } } - Ok( - CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ - next_action( - "hosting github branches --owner --repo ", - "List branches for a repository", - ) - .with_param("owner", NextActionParam::required()) - .with_param("repo", NextActionParam::required()), - ]), - ) + Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ + next_action( + "hosting github branches --owner --repo ", + "List branches for a repository", + ) + .with_param("owner", NextActionParam::required()) + .with_param("repo", NextActionParam::required()), + ])) }, ) } diff --git a/rust/src/hosting/github/status.rs b/rust/src/hosting/github/status.rs index f57d6fa9..479505ea 100644 --- a/rust/src/hosting/github/status.rs +++ b/rust/src/hosting/github/status.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingGitHubProfile, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_GITHUB_READ as GH_READ; @@ -13,7 +13,8 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[GH_READ]), + .with_scopes(&[GH_READ]) + .with_output_schema::(), |ctx, _args: ()| async move { let client = make_client(&ctx, &[GH_READ]).await?; let data = client.get_github_connection().await.map_err(client_err)?; diff --git a/rust/src/hosting/log/list.rs b/rust/src/hosting/log/list.rs index c3c9e78d..450f116f 100644 --- a/rust/src/hosting/log/list.rs +++ b/rust/src/hosting/log/list.rs @@ -83,7 +83,7 @@ pub(super) fn command() -> RuntimeCommandSpec { } } - Ok(CommandResult::new(json!({ "items": all_items }))) + Ok(CommandResult::new(json!(all_items))) }, ) } diff --git a/rust/src/hosting/nodejs/app/list.rs b/rust/src/hosting/nodejs/app/list.rs index 8e851b43..1ceac0d2 100644 --- a/rust/src/hosting/nodejs/app/list.rs +++ b/rust/src/hosting/nodejs/app/list.rs @@ -1,4 +1,5 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; use crate::hosting::nodejs::{HostingAppSummary, client_err, make_client}; use crate::next_action::next_action; @@ -16,7 +17,12 @@ pub(super) fn command() -> RuntimeCommandSpec { |ctx| async move { let client = make_client(&ctx, &[APPS_READ]).await?; let data = client.list_apps().await.map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ + let items: Vec = data + .get("apps") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + Ok(CommandResult::new(json!(items)).with_next_actions(vec![ next_action( "hosting nodejs app get --app-id ", "Get details for an application", diff --git a/rust/src/hosting/operation/get.rs b/rust/src/hosting/operation/get.rs index ceb92b5d..ea11df45 100644 --- a/rust/src/hosting/operation/get.rs +++ b/rust/src/hosting/operation/get.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingAppOperation, client_err, make_client}; use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; #[derive(Debug, Clone, clap::Args)] @@ -19,7 +19,8 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[APP_READ]), + .with_scopes(&[APP_READ]) + .with_output_schema::(), |ctx, args: OperationGetArgs| async move { let client = make_client(&ctx, &[APP_READ]).await?; let data = client diff --git a/rust/src/hosting/runtime/get.rs b/rust/src/hosting/runtime/get.rs index ce0def68..fd21fdf4 100644 --- a/rust/src/hosting/runtime/get.rs +++ b/rust/src/hosting/runtime/get.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::hosting::common::{AppIdArgs, HostingRuntime, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; @@ -13,7 +13,8 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[APP_READ]), + .with_scopes(&[APP_READ]) + .with_output_schema::(), |ctx, args: AppIdArgs| async move { let app_id = args.app_id; let client = make_client(&ctx, &[APP_READ]).await?; diff --git a/rust/src/hosting/secrets/create.rs b/rust/src/hosting/secrets/create.rs index 9ed2cfe9..5065e168 100644 --- a/rust/src/hosting/secrets/create.rs +++ b/rust/src/hosting/secrets/create.rs @@ -1,7 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::json; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingSecretSummary, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SECRET_WRITE as SECRET_WRITE; @@ -34,7 +34,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[SECRET_WRITE]), + .with_scopes(&[SECRET_WRITE]) + .with_output_schema::(), |ctx, args: SecretCreateArgs| async move { let app_id = args.app_id.clone(); let body = json!({ diff --git a/rust/src/hosting/secrets/list.rs b/rust/src/hosting/secrets/list.rs index be9dc3a2..43c7da83 100644 --- a/rust/src/hosting/secrets/list.rs +++ b/rust/src/hosting/secrets/list.rs @@ -1,4 +1,5 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; use crate::hosting::common::{HostingSecretSummary, client_err, make_client}; use crate::next_action::next_action; @@ -25,7 +26,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Read) .with_scopes(&[SECRET_READ]) - .with_default_fields("name,scope") + .with_default_fields("name,systemManaged") .with_output_schema::(), |ctx, args: SecretsListArgs| async move { let app_id = args.app_id.clone(); @@ -34,7 +35,12 @@ pub(super) fn command() -> RuntimeCommandSpec { .list_secrets(&app_id, args.variant.as_deref()) .await .map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ + let items: Vec = data + .get("items") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + Ok(CommandResult::new(json!(items)).with_next_actions(vec![ next_action( "hosting secrets create --app-id --name --value ", "Add a new secret", diff --git a/rust/src/hosting/secrets/sync.rs b/rust/src/hosting/secrets/sync.rs index c8b629f0..a605e5a8 100644 --- a/rust/src/hosting/secrets/sync.rs +++ b/rust/src/hosting/secrets/sync.rs @@ -1,7 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::{Value, json}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingSecretSummary, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SECRET_WRITE as SECRET_WRITE; @@ -39,7 +39,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[SECRET_WRITE]), + .with_scopes(&[SECRET_WRITE]) + .with_output_schema::(), |ctx, args: SecretSyncArgs| async move { let app_id = args.app_id.clone(); diff --git a/rust/src/hosting/secrets/update.rs b/rust/src/hosting/secrets/update.rs index 582a9f3a..f42ba388 100644 --- a/rust/src/hosting/secrets/update.rs +++ b/rust/src/hosting/secrets/update.rs @@ -1,7 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::json; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingSecretSummary, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SECRET_WRITE as SECRET_WRITE; @@ -34,7 +34,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[SECRET_WRITE]), + .with_scopes(&[SECRET_WRITE]) + .with_output_schema::(), |ctx, args: SecretUpdateArgs| async move { let app_id = args.app_id.clone(); let body = json!({ diff --git a/rust/src/hosting/source/github.rs b/rust/src/hosting/source/github.rs index 312856ea..55c61a47 100644 --- a/rust/src/hosting/source/github.rs +++ b/rust/src/hosting/source/github.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingSourceImport, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SOURCE_WRITE as SOURCE_WRITE; @@ -34,7 +34,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[SOURCE_WRITE]), + .with_scopes(&[SOURCE_WRITE]) + .with_output_schema::(), |ctx, args: SourceGithubArgs| async move { let app_id = args.app_id.clone(); let client = make_client(&ctx, &[SOURCE_WRITE]).await?; diff --git a/rust/src/hosting/source/status.rs b/rust/src/hosting/source/status.rs index 0fd24afc..4482cc16 100644 --- a/rust/src/hosting/source/status.rs +++ b/rust/src/hosting/source/status.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingSourceImport, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SOURCE_READ as SOURCE_READ; @@ -25,7 +25,8 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[SOURCE_READ]), + .with_scopes(&[SOURCE_READ]) + .with_output_schema::(), |ctx, args: SourceStatusArgs| async move { let app_id = args.app_id.clone(); let client = make_client(&ctx, &[SOURCE_READ]).await?; diff --git a/rust/src/hosting/source/upload.rs b/rust/src/hosting/source/upload.rs index bff6a775..c897e501 100644 --- a/rust/src/hosting/source/upload.rs +++ b/rust/src/hosting/source/upload.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingSourceImport, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SOURCE_WRITE as SOURCE_WRITE; @@ -39,7 +39,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[SOURCE_WRITE]), + .with_scopes(&[SOURCE_WRITE]) + .with_output_schema::(), |ctx, args: SourceUploadArgs| async move { let app_id = args.app_id; let file = args.file; diff --git a/rust/src/hosting/subscription/attach.rs b/rust/src/hosting/subscription/attach.rs index ef812909..3babfe35 100644 --- a/rust/src/hosting/subscription/attach.rs +++ b/rust/src/hosting/subscription/attach.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{client_err, make_client}; +use crate::hosting::common::{HostingSubscriptionAttachment, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SUBSCRIPTION_WRITE as SUB_WRITE; @@ -28,7 +28,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Mutate) .mutates(true) - .with_scopes(&[SUB_WRITE]), + .with_scopes(&[SUB_WRITE]) + .with_output_schema::(), |ctx, args: SubscriptionAttachArgs| async move { let app_id = args.app_id.clone(); let client = make_client(&ctx, &[SUB_WRITE]).await?; diff --git a/rust/src/hosting/subscription/get.rs b/rust/src/hosting/subscription/get.rs index 71f6776f..d1ee2315 100644 --- a/rust/src/hosting/subscription/get.rs +++ b/rust/src/hosting/subscription/get.rs @@ -1,6 +1,6 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use crate::hosting::common::{AppIdArgs, client_err, make_client}; +use crate::hosting::common::{AppIdArgs, HostingSubscriptionAttachment, client_err, make_client}; use crate::next_action::next_action; use crate::scopes::HOSTING_SUBSCRIPTION_READ as SUB_READ; @@ -15,7 +15,8 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("hosting") .with_tier(Tier::Read) - .with_scopes(&[SUB_READ]), + .with_scopes(&[SUB_READ]) + .with_output_schema::(), |ctx, args: AppIdArgs| async move { let app_id = args.app_id; let client = make_client(&ctx, &[SUB_READ]).await?; diff --git a/rust/src/hosting/subscription/list.rs b/rust/src/hosting/subscription/list.rs index 4921f252..0192ebec 100644 --- a/rust/src/hosting/subscription/list.rs +++ b/rust/src/hosting/subscription/list.rs @@ -27,7 +27,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("hosting") .with_tier(Tier::Read) .with_scopes(&[SUB_READ]) - .with_default_fields("id,planName,status") + .with_default_fields("subscriptionId,hostingProduct,status,availableSlots") .with_output_schema::(), |ctx, args: SubscriptionListArgs| async move { let limit = args.limit; @@ -60,16 +60,14 @@ pub(super) fn command() -> RuntimeCommandSpec { } } - Ok( - CommandResult::new(json!({ "items": all_items })).with_next_actions(vec![ - next_action( - "hosting subscription attach --app-id --subscription-id ", - "Attach a subscription to an application", - ) - .with_param("app-id", NextActionParam::required()) - .with_param("subscription-id", NextActionParam::required()), - ]), - ) + Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ + next_action( + "hosting subscription attach --app-id --subscription-id ", + "Attach an application to a hosting plan", + ) + .with_param("app-id", NextActionParam::required()) + .with_param("subscription-id", NextActionParam::required()), + ])) }, ) } diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 4fb874e5..70bfb4d0 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -150,7 +150,7 @@ declare_scopes! { HOSTING_DOMAIN_WRITE => "hosting.domain:write", /// Read hosting subscriptions (`hosting subscription list/get`). HOSTING_SUBSCRIPTION_READ => "hosting.subscription:read", - /// Attach a subscription to an application (`hosting subscription attach`). + /// Attach an an application to a hosting plan (`hosting subscription attach`). HOSTING_SUBSCRIPTION_WRITE => "hosting.subscription:write", /// Read mailboxes and check mailbox-creation eligibility (`email list`). From c030799d8a92f96472468fd271f988872ac30109 Mon Sep 17 00:00:00 2001 From: James Henderson Date: Sat, 5 Sep 2026 01:53:59 +0100 Subject: [PATCH 5/5] docs: improve help text to make the hosting CLI experience more intuitive for agents --- rust/src/hosting/app/create.rs | 5 +++-- rust/src/hosting/app/get.rs | 6 +++++- rust/src/hosting/app/status.rs | 4 +++- rust/src/hosting/common.rs | 4 ++++ rust/src/hosting/deployment/publish.rs | 8 +++++--- rust/src/hosting/domain/attach.rs | 3 ++- rust/src/hosting/log/list.rs | 14 +++++++------- rust/src/hosting/log/mod.rs | 2 +- rust/src/hosting/mod.rs | 10 +++++++++- rust/src/hosting/operation/get.rs | 4 +++- rust/src/hosting/runtime/get.rs | 4 ++-- rust/src/hosting/runtime/mod.rs | 4 ++-- rust/src/hosting/secrets/sync.rs | 8 +++++--- rust/src/hosting/source/status.rs | 6 +++--- 14 files changed, 54 insertions(+), 28 deletions(-) diff --git a/rust/src/hosting/app/create.rs b/rust/src/hosting/app/create.rs index 887ebb86..537862e9 100644 --- a/rust/src/hosting/app/create.rs +++ b/rust/src/hosting/app/create.rs @@ -23,8 +23,9 @@ pub(super) fn command() -> RuntimeCommandSpec { "Provision a new hosting application slot. Because no app ID exists \ until provisioning completes, this returns an operation ID. \ Poll `hosting operation get --operation-id ` until \ - status is COMPLETED or FAILED. The completed operation result \ - includes the created application with its ID.", + status is COMPLETED or FAILED. On COMPLETED, the operation's \ + `application` field carries the created app; use `application.id` \ + as the --app-id for all subsequent calls.", ) .with_system("hosting") .with_tier(Tier::Mutate) diff --git a/rust/src/hosting/app/get.rs b/rust/src/hosting/app/get.rs index 11840ff9..ba7348dd 100644 --- a/rust/src/hosting/app/get.rs +++ b/rust/src/hosting/app/get.rs @@ -7,7 +7,11 @@ use crate::scopes::HOSTING_APPLICATION_READ as APP_READ; pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("get", "Get a hosting application") - .with_long("Get details for a single hosting application by ID.") + .with_long( + "Get details for a single hosting application by ID. \ + The `urls` object exposes the reachable URLs for each environment \ + variant (preview and publish).", + ) .with_system("hosting") .with_tier(Tier::Read) .with_scopes(&[APP_READ]) diff --git a/rust/src/hosting/app/status.rs b/rust/src/hosting/app/status.rs index 135b0b7a..7217927a 100644 --- a/rust/src/hosting/app/status.rs +++ b/rust/src/hosting/app/status.rs @@ -10,7 +10,9 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_long( "Get the runtime status of a hosting application's environments \ (preview and publish). Use this to check whether an environment \ - is ACTIVE, IDLE, or in a transitional state after a restart or deployment.", + is ACTIVE, IDLE, or in a transitional state after a restart or deployment. \ + The `variants` array contains one entry per environment (PREVIEW and PUBLISH) \ + with the runtime status for that environment.", ) .with_system("hosting") .with_tier(Tier::Read) diff --git a/rust/src/hosting/common.rs b/rust/src/hosting/common.rs index 80aad3fe..0cff3001 100644 --- a/rust/src/hosting/common.rs +++ b/rust/src/hosting/common.rs @@ -69,6 +69,10 @@ output_schema!(HostingApplication { output_schema!(HostingAppOperation { "operationId": "string"; "status": "string"; + "application": "object"; + "error": "object"; + "createdAt": "string"; + "links": "array"; }); output_schema!(HostingApplicationStatus { diff --git a/rust/src/hosting/deployment/publish.rs b/rust/src/hosting/deployment/publish.rs index ca023bdd..32691f19 100644 --- a/rust/src/hosting/deployment/publish.rs +++ b/rust/src/hosting/deployment/publish.rs @@ -8,9 +8,11 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("publish", "Trigger a deployment") .with_long( - "Build the latest imported source and deploy it to the publish environment. \ - Returns immediately with a deployment ID. Poll `hosting deployment get` \ - until status is COMPLETED or FAILED.", + "Promote the current PREVIEW build to PUBLISH. Hosting uses a two-stage model: \ + `hosting source upload` (or `hosting source github`) refreshes PREVIEW, \ + and `hosting deployment publish` builds that source and rolls it out to PUBLISH — \ + which is why there is no --variant flag here. Returns immediately with a \ + deployment ID; poll `hosting deployment get` until status is COMPLETED or FAILED.", ) .with_system("hosting") .with_tier(Tier::Mutate) diff --git a/rust/src/hosting/domain/attach.rs b/rust/src/hosting/domain/attach.rs index b6116757..6110bc90 100644 --- a/rust/src/hosting/domain/attach.rs +++ b/rust/src/hosting/domain/attach.rs @@ -20,7 +20,8 @@ pub(super) fn command() -> RuntimeCommandSpec { CommandSpec::from_args::("attach", "Attach a domain to an application") .with_long( "Attach a fully-qualified domain name to a hosting application. \ - The domain must be registered and have DNS pointing to GoDaddy hosting.", + The domain must be registered and have DNS pointing to GoDaddy hosting. \ + Returns immediately — poll `hosting domain get` until status is ACTIVE.", ) .with_system("hosting") .with_tier(Tier::Mutate) diff --git a/rust/src/hosting/log/list.rs b/rust/src/hosting/log/list.rs index 450f116f..7296b93a 100644 --- a/rust/src/hosting/log/list.rs +++ b/rust/src/hosting/log/list.rs @@ -12,18 +12,18 @@ struct LogListArgs { /// Environment to retrieve logs from (PREVIEW or PUBLISH). Defaults to PREVIEW. #[arg(long, value_name = "VARIANT", value_parser = ["PREVIEW", "PUBLISH"])] - target: Option, + variant: Option, /// Return only entries at or after this ISO 8601 timestamp (e.g. 2024-01-01T00:00:00Z). #[arg(long, value_name = "DATETIME")] since: Option, - /// Filter by log source stream. - #[arg(long, value_name = "SOURCE")] + /// Filter by log source stream (STDOUT, STDERR, or ALL). + #[arg(long, value_name = "SOURCE", value_parser = ["STDOUT", "STDERR", "ALL"])] source: Option, - /// Filter by severity level. - #[arg(long, value_name = "LEVEL")] + /// Filter by severity level (INFO, WARN, or ERROR). + #[arg(long, value_name = "LEVEL", value_parser = ["INFO", "WARN", "ERROR"])] level: Option, /// Maximum number of log entries to return (max 500). Omit to return all. @@ -36,7 +36,7 @@ pub(super) fn command() -> RuntimeCommandSpec { CommandSpec::from_args::("list", "List application logs") .with_long( "Retrieve log entries for a hosting application. Results are autopaginated. \ - Use --target to select the environment, --since for a time window, \ + Use --variant to select the environment, --since for a time window, \ --source and --level to filter by stream and severity.", ) .with_system("hosting") @@ -58,7 +58,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let response = client .list_logs( &args.app_id, - args.target.as_deref(), + args.variant.as_deref(), args.since.as_deref(), args.source.as_deref(), args.level.as_deref(), diff --git a/rust/src/hosting/log/mod.rs b/rust/src/hosting/log/mod.rs index f9b4fb8b..62411cc5 100644 --- a/rust/src/hosting/log/mod.rs +++ b/rust/src/hosting/log/mod.rs @@ -5,7 +5,7 @@ use cli_engine::{GroupSpec, RuntimeGroupSpec}; pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new(GroupSpec::new("log", "View application logs").with_long( "Retrieve log entries for a hosting application. \ - Use --target to select the environment, --since to set a time window, \ + Use --variant to select the environment, --since to set a time window, \ and --level/--source to filter.", )) .with_command(list::command()) diff --git a/rust/src/hosting/mod.rs b/rust/src/hosting/mod.rs index 51325167..e8333001 100644 --- a/rust/src/hosting/mod.rs +++ b/rust/src/hosting/mod.rs @@ -31,7 +31,15 @@ pub fn module() -> Module { • domain — Domains attached to an application\n\ • subscription — Hosting plan subscriptions\n\ • operation — Poll async operations\n\ - • nodejs — Node.js PaaS applications (legacy; use `app` for new work)", + \n\ + Typical flow:\n\ + 1. `hosting app create` — provision the app (returns an operation to poll)\n\ + 2. `hosting source upload` — upload a zip archive, poll `hosting source status`\n\ + 3. Preview the app at the PREVIEW URL from `hosting app get`\n\ + 4. `hosting subscription attach` — attach a hosting plan\n\ + 5. `hosting deployment publish` — build and deploy to PUBLISH, poll `hosting deployment get`\n\ + \n\ + Terminology: `variant` refers to an environment — PREVIEW (staging) or PUBLISH (production).", ), ) .with_group(app::group()) diff --git a/rust/src/hosting/operation/get.rs b/rust/src/hosting/operation/get.rs index ea11df45..5a9dc508 100644 --- a/rust/src/hosting/operation/get.rs +++ b/rust/src/hosting/operation/get.rs @@ -15,7 +15,9 @@ pub(super) fn command() -> RuntimeCommandSpec { CommandSpec::from_args::("get", "Get an operation") .with_long( "Poll an async operation by ID. Operations are returned by `hosting app create` \ - and `hosting deployment publish`. Keep polling until status is COMPLETED or FAILED.", + and `hosting deployment publish`. Keep polling until status is COMPLETED or FAILED. \ + On COMPLETED, the `application` field carries the created application (with `application.id` \ + as the app ID to use for subsequent calls). On FAILED, the `error` field describes why.", ) .with_system("hosting") .with_tier(Tier::Read) diff --git a/rust/src/hosting/runtime/get.rs b/rust/src/hosting/runtime/get.rs index fd21fdf4..505f1d94 100644 --- a/rust/src/hosting/runtime/get.rs +++ b/rust/src/hosting/runtime/get.rs @@ -8,8 +8,8 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("get", "Get application runtime configuration") .with_long( - "Get the runtime configuration for a hosting application, \ - including environment variables, entry point, and resource limits.", + "Get the runtime name and version for a hosting application \ + (e.g. runtime=nodejs, version=22).", ) .with_system("hosting") .with_tier(Tier::Read) diff --git a/rust/src/hosting/runtime/mod.rs b/rust/src/hosting/runtime/mod.rs index 7753af0d..9a6f3b8a 100644 --- a/rust/src/hosting/runtime/mod.rs +++ b/rust/src/hosting/runtime/mod.rs @@ -5,8 +5,8 @@ use cli_engine::{GroupSpec, RuntimeGroupSpec}; pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( GroupSpec::new("runtime", "View application runtime configuration").with_long( - "Get the runtime configuration for a hosting application, \ - including environment variables, entry point, and resource limits.", + "Get the runtime name and version for a hosting application \ + (e.g. runtime=nodejs, version=22).", ), ) .with_command(get::command()) diff --git a/rust/src/hosting/secrets/sync.rs b/rust/src/hosting/secrets/sync.rs index a605e5a8..fcf5529c 100644 --- a/rust/src/hosting/secrets/sync.rs +++ b/rust/src/hosting/secrets/sync.rs @@ -32,9 +32,11 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("sync", "Sync secrets in bulk") .with_long( - "Low-level access to the secrets sync endpoint. Accepts additions, updates, \ - and deletions in a single call. At least one of --additions, --updates, \ - or --deletions is required. Each value is a JSON array.", + "Apply many secret changes in a single call. Prefer this over separate \ + `create`/`update`/`delete` invocations when changing several secrets at once — \ + it is faster and applies as one batch. Accepts additions, updates, and \ + deletions together; at least one of --additions, --updates, or --deletions \ + is required. Each value is a JSON array.", ) .with_system("hosting") .with_tier(Tier::Mutate) diff --git a/rust/src/hosting/source/status.rs b/rust/src/hosting/source/status.rs index 4482cc16..d310d4ea 100644 --- a/rust/src/hosting/source/status.rs +++ b/rust/src/hosting/source/status.rs @@ -10,7 +10,7 @@ struct SourceStatusArgs { #[arg(long = "app-id", value_name = "APP_ID")] app_id: String, - /// Import ID returned by `hosting source import`. + /// Import ID returned by `hosting source upload` or `hosting source github`. #[arg(long = "import-id", value_name = "IMPORT_ID")] import_id: String, } @@ -19,8 +19,8 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("status", "Get the status of a source import") .with_long( - "Poll the status of a source import started with `hosting source import`. \ - Keep polling until status is COMPLETED or FAILED, \ + "Poll the status of a source import started with `hosting source upload` or \ + `hosting source github`. Keep polling until status is COMPLETED or FAILED, \ then use `hosting deployment publish` to deploy.", ) .with_system("hosting")