diff --git a/CLAUDE.md b/CLAUDE.md index 0f2403d..e08df57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ All source code lives under `rust/`. - **Format**: `cargo fmt` (from `rust/`) - **Check**: `cargo check` (from `rust/`) - **Regenerate API catalog**: `cargo run -p generate-api-catalog` (from `rust/`) +- **Regenerate Node.js Hosting spec**: `./rust/scripts/regenerate-hosting-spec.sh` ## Architecture @@ -37,6 +38,11 @@ rust/src/ api_explorer/mod.rs — api list/describe/search/call commands actions_catalog/mod.rs — embedded action schema catalog extension/mod.rs — esbuild bundler + security scanner (SEC101–SEC115) + hosting/ + nodejs/ + mod.rs — hosting nodejs command group + client.rs — Node.js Hosting REST client + scopes.rs — OAuth scope constants webhook/mod.rs — webhook commands config/mod.rs — godaddy.toml read/write auth.rs — auth module registration diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4e2123f..24c6d92 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -23,7 +23,7 @@ fancy-regex = "0.14" open = "5" phonenumber = "0.3" regex = { version = "1", features = ["std"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } sha2 = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rust/schemas/api/hosting-nodejs.json b/rust/schemas/api/hosting-nodejs.json new file mode 100644 index 0000000..44f8f7d --- /dev/null +++ b/rust/schemas/api/hosting-nodejs.json @@ -0,0 +1,1376 @@ +{ + "$defs": { + "PublicAppCreationJob": { + "additionalProperties": false, + "properties": { + "job": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "status": { + "enum": [ + "pending", + "active", + "failed" + ], + "type": "string" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + } + }, + "required": [ + "job" + ], + "type": "object" + }, + "PublicAppCreationStatus": { + "additionalProperties": false, + "properties": { + "app": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "urls": { + "additionalProperties": false, + "properties": { + "preview": { + "type": "string" + }, + "publish": { + "type": "string" + } + }, + "type": "object" + }, + "variants": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "name", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "variantsWithLifecycle": { + "items": { + "additionalProperties": false, + "properties": { + "lifecycleState": { + "type": "string" + }, + "status": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "variant", + "status" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "status", + "createdAt", + "updatedAt", + "urls" + ], + "type": "object" + }, + "job": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "status": { + "enum": [ + "pending", + "active", + "failed" + ], + "type": "string" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + } + }, + "required": [ + "job" + ], + "type": "object" + }, + "PublicAppList": { + "additionalProperties": false, + "properties": { + "apps": { + "items": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "urls": { + "additionalProperties": false, + "properties": { + "preview": { + "type": "string" + }, + "publish": { + "type": "string" + } + }, + "type": "object" + }, + "variants": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "name", + "status" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "status", + "createdAt", + "updatedAt", + "urls" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "apps" + ], + "type": "object" + }, + "PublicAppResponse": { + "additionalProperties": false, + "properties": { + "app": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "urls": { + "additionalProperties": false, + "properties": { + "preview": { + "type": "string" + }, + "publish": { + "type": "string" + } + }, + "type": "object" + }, + "variants": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "name", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "variantsWithLifecycle": { + "items": { + "additionalProperties": false, + "properties": { + "lifecycleState": { + "type": "string" + }, + "status": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "variant", + "status" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "status", + "createdAt", + "updatedAt", + "urls" + ], + "type": "object" + } + }, + "required": [ + "app" + ], + "type": "object" + }, + "PublicAppStatus": { + "additionalProperties": false, + "properties": { + "appStatus": { + "type": "string" + }, + "variants": { + "items": { + "additionalProperties": false, + "properties": { + "lifecycleState": { + "type": "string" + }, + "status": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "variant", + "status" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "appStatus", + "variants" + ], + "type": "object" + }, + "PublicCreateAppBody": { + "additionalProperties": false, + "properties": { + "datacenter": { + "enum": [ + "p3", + "sxb1" + ], + "type": "string" + }, + "name": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9 _.'&(),!#:+/-]*$", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PublicCustomerLogs": { + "additionalProperties": false, + "properties": { + "logs": { + "items": { + "additionalProperties": false, + "properties": { + "level": { + "type": "string" + }, + "message": { + "type": "string" + }, + "source": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "timestamp", + "source", + "message", + "level" + ], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "number" + } + }, + "required": [ + "logs", + "total" + ], + "type": "object" + }, + "PublicDeploymentsList": { + "additionalProperties": false, + "properties": { + "deployments": { + "items": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "string" + }, + "errorMessage": { + "type": "string" + }, + "gitHash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "status": { + "enum": [ + "pending", + "deploying", + "deployed", + "failed" + ], + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "status", + "gitHash", + "createdAt", + "updatedAt" + ], + "type": "object" + }, + "type": "array" + }, + "hasMore": { + "type": "boolean" + }, + "totalCount": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "deployments", + "totalCount", + "hasMore" + ], + "type": "object" + }, + "PublicPatchAppBody": { + "additionalProperties": false, + "description": "At least one of name or rootPath must be provided.", + "minProperties": 1, + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9 _.'&(),!#:+/-]*$", + "type": "string" + }, + "rootPath": { + "maxLength": 2048, + "type": "string" + } + }, + "type": "object" + }, + "PublicPublishResponse": { + "additionalProperties": false, + "properties": { + "commitHash": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "deploymentId", + "status" + ], + "type": "object" + }, + "PublicRollbackBody": { + "additionalProperties": false, + "properties": { + "deploymentId": { + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + } + }, + "required": [ + "deploymentId" + ], + "type": "object" + }, + "PublicRollbackResponse": { + "additionalProperties": false, + "properties": { + "commitHash": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "rollbackFrom": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "rollbackFrom", + "commitHash", + "deploymentId", + "status" + ], + "type": "object" + }, + "PublicSecretsList": { + "additionalProperties": false, + "properties": { + "shared": { + "additionalProperties": false, + "properties": { + "secrets": { + "items": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "systemManaged": { + "type": "boolean" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "name", + "systemManaged" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "secrets" + ], + "type": "object" + }, + "variants": { + "additionalProperties": false, + "properties": { + "preview": { + "additionalProperties": false, + "properties": { + "secrets": { + "items": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "systemManaged": { + "type": "boolean" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "name", + "systemManaged" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "secrets" + ], + "type": "object" + }, + "publish": { + "additionalProperties": false, + "properties": { + "secrets": { + "items": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "systemManaged": { + "type": "boolean" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "name", + "systemManaged" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "secrets" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "shared", + "variants" + ], + "type": "object" + }, + "PublicSecretsUpdateBody": { + "additionalProperties": false, + "properties": { + "operations": { + "additionalProperties": false, + "properties": { + "additions": { + "default": [], + "items": { + "additionalProperties": false, + "properties": { + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "value": { + "maxLength": 8192, + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "maxItems": 50, + "type": "array" + }, + "deletions": { + "default": [], + "items": { + "additionalProperties": false, + "properties": { + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "maxItems": 50, + "type": "array" + }, + "updates": { + "default": [], + "items": { + "additionalProperties": false, + "properties": { + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "value": { + "maxLength": 8192, + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "maxItems": 50, + "type": "array" + } + }, + "required": [ + "additions", + "updates", + "deletions" + ], + "type": "object" + }, + "variant": { + "default": "preview", + "enum": [ + "preview", + "publish" + ], + "type": "string" + } + }, + "required": [ + "variant", + "operations" + ], + "type": "object" + }, + "PublicZipUploadResponse": { + "additionalProperties": false, + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + }, + "PublicZipUploadStatus": { + "additionalProperties": false, + "properties": { + "errorMessage": { + "type": "string" + }, + "errorStage": { + "type": "string" + }, + "progressMessage": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + }, + "baseUrl": "/v1/hosting/nodejs", + "description": "Gateway-integrated API for customer apps. Authenticate with a forwarded customer session (Gasket auth), a developer portal API key (`Authorization: sso-key` on the public gateway URL), or an OAuth Bearer access token. When using Bearer auth, each operation requires the OAuth scope listed on that operation. API keys do not use OAuth scopes. After publish or domain changes, poll GET …/deployments and GET …/status. After zip upload, poll GET …/source/status with the returned jobId.", + "endpoints": [ + { + "method": "GET", + "operationId": "listApps", + "path": "/apps", + "responses": { + "200": { + "description": "App list", + "schema": { + "$ref": "#/$defs/PublicAppList" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + } + }, + "scopes": [ + "hosting.paas.apps:read" + ], + "summary": "List apps" + }, + { + "method": "POST", + "operationId": "createApp", + "path": "/apps", + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "$ref": "#/$defs/PublicCreateAppBody" + } + }, + "responses": { + "202": { + "description": "App creation accepted; poll the returned job for status", + "schema": { + "$ref": "#/$defs/PublicAppCreationJob" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + } + }, + "scopes": [ + "hosting.paas.apps:create" + ], + "summary": "Create app" + }, + { + "method": "GET", + "operationId": "getAppCreationStatus", + "parameters": [ + { + "in": "path", + "name": "jobId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/jobs/{jobId}", + "responses": { + "200": { + "description": "App creation status (app present once active)", + "schema": { + "$ref": "#/$defs/PublicAppCreationStatus" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.apps:create" + ], + "summary": "Get app creation status" + }, + { + "method": "GET", + "operationId": "getApp", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}", + "responses": { + "200": { + "description": "App details", + "schema": { + "$ref": "#/$defs/PublicAppResponse" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.apps:read" + ], + "summary": "Get app" + }, + { + "method": "PATCH", + "operationId": "patchApp", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}", + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "$ref": "#/$defs/PublicPatchAppBody" + } + }, + "responses": { + "200": { + "description": "App updated", + "schema": { + "$ref": "#/$defs/PublicAppResponse" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.apps:update" + ], + "summary": "Update app metadata" + }, + { + "method": "DELETE", + "operationId": "deleteApp", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}", + "responses": { + "204": { + "description": "App deleted" + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.apps:delete" + ], + "summary": "Delete app" + }, + { + "method": "GET", + "operationId": "listDeployments", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 50, + "minimum": 1, + "type": "integer" + } + } + ], + "path": "/apps/{appId}/deployments", + "responses": { + "200": { + "description": "Deployments", + "schema": { + "$ref": "#/$defs/PublicDeploymentsList" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.apps:read" + ], + "summary": "List deployments" + }, + { + "method": "POST", + "operationId": "publishApp", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}/deployments", + "responses": { + "200": { + "description": "Publish started", + "schema": { + "$ref": "#/$defs/PublicPublishResponse" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.deploy:execute" + ], + "summary": "Publish app (deploy latest code)" + }, + { + "method": "GET", + "operationId": "getAppLogs", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "target", + "required": true, + "schema": { + "enum": [ + "preview", + "publish" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "source", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "since", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "lines", + "required": false, + "schema": { + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "path": "/apps/{appId}/logs", + "responses": { + "200": { + "description": "Log entries", + "schema": { + "$ref": "#/$defs/PublicCustomerLogs" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.logs:read" + ], + "summary": "Get application logs" + }, + { + "method": "POST", + "operationId": "rollbackApp", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}/rollback", + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "$ref": "#/$defs/PublicRollbackBody" + } + }, + "responses": { + "200": { + "description": "Rollback started", + "schema": { + "$ref": "#/$defs/PublicRollbackResponse" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "403": { + "description": "Rollback not enabled" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.deploy:execute" + ], + "summary": "Rollback to a previous deployment" + }, + { + "method": "GET", + "operationId": "listAppSecrets", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "variant", + "required": false, + "schema": { + "enum": [ + "preview", + "publish" + ], + "type": "string" + } + } + ], + "path": "/apps/{appId}/secrets", + "responses": { + "200": { + "description": "Secret names and metadata (values never returned)", + "schema": { + "$ref": "#/$defs/PublicSecretsList" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.secrets:write" + ], + "summary": "List app secrets (metadata only)" + }, + { + "method": "POST", + "operationId": "updateAppSecrets", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}/secrets", + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "$ref": "#/$defs/PublicSecretsUpdateBody" + } + }, + "responses": { + "200": { + "description": "Updated secret metadata", + "schema": { + "$ref": "#/$defs/PublicSecretsList" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.secrets:write" + ], + "summary": "Add, update, or delete app secrets" + }, + { + "method": "POST", + "operationId": "uploadAppSource", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}/source", + "requestBody": { + "contentType": "multipart/form-data", + "required": true, + "schema": { + "properties": { + "zipFile": { + "format": "binary", + "type": "string" + } + }, + "required": [ + "zipFile" + ], + "type": "object" + } + }, + "responses": { + "200": { + "description": "Upload accepted", + "schema": { + "$ref": "#/$defs/PublicZipUploadResponse" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.code:write" + ], + "summary": "Upload app source (zip)" + }, + { + "method": "GET", + "operationId": "getAppSourceUploadStatus", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "jobId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}/source/status", + "responses": { + "200": { + "description": "Upload status", + "schema": { + "$ref": "#/$defs/PublicZipUploadStatus" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.code:write" + ], + "summary": "Poll zip upload status" + }, + { + "method": "GET", + "operationId": "getAppStatus", + "parameters": [ + { + "in": "path", + "name": "appId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "path": "/apps/{appId}/status", + "responses": { + "200": { + "description": "App status", + "schema": { + "$ref": "#/$defs/PublicAppStatus" + } + }, + "401": { + "description": "Unauthorized (missing session, invalid API key or Bearer token, or insufficient OAuth scope)" + }, + "404": { + "description": "Not found" + } + }, + "scopes": [ + "hosting.paas.apps:read" + ], + "summary": "Get app status" + } + ], + "name": "hosting-nodejs", + "title": "Node.js Hosting Public API", + "version": "1.0.0" +} \ No newline at end of file diff --git a/rust/schemas/api/manifest.json b/rust/schemas/api/manifest.json index b994d53..89e31d4 100644 --- a/rust/schemas/api/manifest.json +++ b/rust/schemas/api/manifest.json @@ -100,6 +100,11 @@ "file": "customer-profiles.json", "title": "Customers API", "endpointCount": 9 + }, + "hosting-nodejs": { + "file": "hosting-nodejs.json", + "title": "Node.js Hosting Public API", + "endpointCount": 15 } } } \ No newline at end of file diff --git a/rust/schemas/openapi/hosting-nodejs-public-v1.yaml b/rust/schemas/openapi/hosting-nodejs-public-v1.yaml new file mode 100644 index 0000000..03e9977 --- /dev/null +++ b/rust/schemas/openapi/hosting-nodejs-public-v1.yaml @@ -0,0 +1,991 @@ +openapi: 3.0.3 +info: + title: Node.js Hosting Public API + version: 1.0.0 + description: "Gateway-integrated API for customer apps. Authenticate with a + forwarded customer session (Gasket auth), a developer portal API key + (`Authorization: sso-key` on the public gateway URL), or an OAuth Bearer + access token. When using Bearer auth, each operation requires the OAuth + scope listed on that operation. API keys do not use OAuth scopes. After + publish or domain changes, poll GET …/deployments and GET …/status. After + zip upload, poll GET …/source/status with the returned jobId." +servers: + - url: /v1/hosting/nodejs +paths: + /apps: + get: + operationId: listApps + summary: List apps + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:read + responses: + "200": + description: App list + content: + application/json: + schema: + $ref: "#/components/schemas/PublicAppList" + "401": &a1 + description: Unauthorized (missing session, invalid API key or Bearer token, or + insufficient OAuth scope) + post: + operationId: createApp + summary: Create app + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:create + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublicCreateAppBody" + responses: + "202": + description: App creation accepted; poll the returned job for status + content: + application/json: + schema: + $ref: "#/components/schemas/PublicAppCreationJob" + "401": *a1 + /apps/jobs/{jobId}: + get: + operationId: getAppCreationStatus + summary: Get app creation status + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:create + parameters: + - name: jobId + in: path + required: true + schema: + type: string + responses: + "200": + description: App creation status (app present once active) + content: + application/json: + schema: + $ref: "#/components/schemas/PublicAppCreationStatus" + "401": *a1 + "404": + description: Not found + /apps/{appId}: + get: + operationId: getApp + summary: Get app + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:read + parameters: + - &a2 + name: appId + in: path + required: true + schema: + type: string + responses: + "200": + description: App details + content: + application/json: + schema: + $ref: "#/components/schemas/PublicAppResponse" + "401": *a1 + "404": + description: Not found + patch: + operationId: patchApp + summary: Update app metadata + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:update + parameters: + - *a2 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublicPatchAppBody" + responses: + "200": + description: App updated + content: + application/json: + schema: + $ref: "#/components/schemas/PublicAppResponse" + "401": *a1 + "404": + description: Not found + delete: + operationId: deleteApp + summary: Delete app + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:delete + parameters: + - *a2 + responses: + "204": + description: App deleted + "401": *a1 + "404": + description: Not found + /apps/{appId}/deployments: + get: + operationId: listDeployments + summary: List deployments + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:read + parameters: + - *a2 + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + responses: + "200": + description: Deployments + content: + application/json: + schema: + $ref: "#/components/schemas/PublicDeploymentsList" + "401": *a1 + "404": + description: Not found + post: + operationId: publishApp + summary: Publish app (deploy latest code) + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.deploy:execute + parameters: + - *a2 + responses: + "200": + description: Publish started + content: + application/json: + schema: + $ref: "#/components/schemas/PublicPublishResponse" + "401": *a1 + "404": + description: Not found + /apps/{appId}/status: + get: + operationId: getAppStatus + summary: Get app status + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.apps:read + parameters: + - *a2 + responses: + "200": + description: App status + content: + application/json: + schema: + $ref: "#/components/schemas/PublicAppStatus" + "401": *a1 + "404": + description: Not found + /apps/{appId}/source: + post: + operationId: uploadAppSource + summary: Upload app source (zip) + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.code:write + parameters: + - *a2 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - zipFile + properties: + zipFile: + type: string + format: binary + responses: + "200": + description: Upload accepted + content: + application/json: + schema: + $ref: "#/components/schemas/PublicZipUploadResponse" + "401": *a1 + "404": + description: Not found + /apps/{appId}/source/status: + get: + operationId: getAppSourceUploadStatus + summary: Poll zip upload status + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.code:write + parameters: + - *a2 + - name: jobId + in: query + required: true + schema: + type: string + responses: + "200": + description: Upload status + content: + application/json: + schema: + $ref: "#/components/schemas/PublicZipUploadStatus" + "401": *a1 + "404": + description: Not found + /apps/{appId}/secrets: + get: + operationId: listAppSecrets + summary: List app secrets (metadata only) + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.secrets:write + parameters: + - *a2 + - name: variant + in: query + required: false + schema: + type: string + enum: + - preview + - publish + responses: + "200": + description: Secret names and metadata (values never returned) + content: + application/json: + schema: + $ref: "#/components/schemas/PublicSecretsList" + "401": *a1 + "404": + description: Not found + post: + operationId: updateAppSecrets + summary: Add, update, or delete app secrets + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.secrets:write + parameters: + - *a2 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublicSecretsUpdateBody" + responses: + "200": + description: Updated secret metadata + content: + application/json: + schema: + $ref: "#/components/schemas/PublicSecretsList" + "401": *a1 + "404": + description: Not found + /apps/{appId}/logs: + get: + operationId: getAppLogs + summary: Get application logs + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.logs:read + parameters: + - *a2 + - name: target + in: query + required: true + schema: + type: string + enum: + - preview + - publish + - name: source + in: query + required: true + schema: + type: string + - name: since + in: query + required: true + schema: + type: string + - name: lines + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + responses: + "200": + description: Log entries + content: + application/json: + schema: + $ref: "#/components/schemas/PublicCustomerLogs" + "401": *a1 + "404": + description: Not found + /apps/{appId}/rollback: + post: + operationId: rollbackApp + summary: Rollback to a previous deployment + security: + - cookieAuth: [] + - apiKeyAuth: [] + - oauth2: + - hosting.paas.deploy:execute + parameters: + - *a2 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublicRollbackBody" + responses: + "200": + description: Rollback started + content: + application/json: + schema: + $ref: "#/components/schemas/PublicRollbackResponse" + "401": *a1 + "403": + description: Rollback not enabled + "404": + description: Not found +components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: auth_idp + description: Customer session cookie forwarded by API Gateway + apiKeyAuth: + type: http + scheme: sso-key + description: "Developer portal API key on the public gateway URL: + `Authorization: sso-key {key}:{secret}`" + oauth2: + type: oauth2 + description: GoDaddy OAuth access token (Bearer) + flows: + clientCredentials: + tokenUrl: https://oauth.api.godaddy.com/v2/oauth2/token + scopes: + hosting.paas.analytics:read: Access application analytics. + hosting.paas.apps:create: Create applications and poll creation job status. + hosting.paas.apps:delete: Delete applications. + hosting.paas.apps:read: Read application details, list apps, deployments, and status. + hosting.paas.apps:update: Update application metadata and runtime configuration. + hosting.paas.code:read: Read, list, and export files; read database schemas, + tables, and data; read Git history. + hosting.paas.code:write: Write files; run zip uploads; sync GitHub; run database imports. + hosting.paas.deploy:execute: Publish, rollback, restart, and deploy applications. + hosting.paas.github:execute: Configure GitHub connections (auth, disconnect, + pull, repository access). + hosting.paas.health:read: Access application health and diagnose functionality. + hosting.paas.logs:read: Access application logs. + hosting.paas.secrets:write: Manage application secrets. + schemas: + PublicAppList: + type: object + properties: + apps: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + status: + type: string + createdAt: + type: string + updatedAt: + type: string + urls: + type: object + properties: + preview: + type: string + publish: + type: string + additionalProperties: false + variants: + type: array + items: + type: object + properties: + name: + type: string + status: + type: string + required: + - name + - status + additionalProperties: false + required: + - id + - name + - status + - createdAt + - updatedAt + - urls + additionalProperties: false + required: + - apps + additionalProperties: false + PublicAppResponse: + type: object + properties: + app: + type: object + properties: + id: + type: string + name: + type: string + status: + type: string + createdAt: + type: string + updatedAt: + type: string + urls: + type: object + properties: + preview: + type: string + publish: + type: string + additionalProperties: false + variants: + type: array + items: + type: object + properties: + name: + type: string + status: + type: string + required: + - name + - status + additionalProperties: false + variantsWithLifecycle: + type: array + items: + type: object + properties: + variant: + type: string + status: + type: string + lifecycleState: + type: string + required: + - variant + - status + additionalProperties: false + required: + - id + - name + - status + - createdAt + - updatedAt + - urls + additionalProperties: false + required: + - app + additionalProperties: false + PublicDeploymentsList: + type: object + properties: + deployments: + type: array + items: + type: object + properties: + id: + type: string + status: + type: string + enum: + - pending + - deploying + - deployed + - failed + gitHash: + type: string + createdAt: + type: string + updatedAt: + type: string + errorMessage: + type: string + required: + - id + - status + - gitHash + - createdAt + - updatedAt + additionalProperties: false + totalCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + hasMore: + type: boolean + required: + - deployments + - totalCount + - hasMore + additionalProperties: false + PublicAppStatus: + type: object + properties: + appStatus: + type: string + variants: + type: array + items: + type: object + properties: + variant: + type: string + status: + type: string + lifecycleState: + type: string + required: + - variant + - status + additionalProperties: false + required: + - appStatus + - variants + additionalProperties: false + PublicCreateAppBody: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 200 + pattern: ^[a-zA-Z0-9][a-zA-Z0-9 _.'&(),!#:+/-]*$ + datacenter: + type: string + enum: + - p3 + - sxb1 + required: + - name + additionalProperties: false + PublicAppCreationJob: + type: object + properties: + job: + type: object + properties: + id: + type: string + status: + type: string + enum: + - pending + - active + - failed + required: + - id + - status + additionalProperties: false + required: + - job + additionalProperties: false + PublicAppCreationStatus: + type: object + properties: + job: + type: object + properties: + id: + type: string + status: + type: string + enum: + - pending + - active + - failed + required: + - id + - status + additionalProperties: false + app: + type: object + properties: + id: + type: string + name: + type: string + status: + type: string + createdAt: + type: string + updatedAt: + type: string + urls: + type: object + properties: + preview: + type: string + publish: + type: string + additionalProperties: false + variants: + type: array + items: + type: object + properties: + name: + type: string + status: + type: string + required: + - name + - status + additionalProperties: false + variantsWithLifecycle: + type: array + items: + type: object + properties: + variant: + type: string + status: + type: string + lifecycleState: + type: string + required: + - variant + - status + additionalProperties: false + required: + - id + - name + - status + - createdAt + - updatedAt + - urls + additionalProperties: false + required: + - job + additionalProperties: false + PublicPatchAppBody: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 200 + pattern: ^[a-zA-Z0-9][a-zA-Z0-9 _.'&(),!#:+/-]*$ + rootPath: + type: string + maxLength: 2048 + additionalProperties: false + minProperties: 1 + description: At least one of name or rootPath must be provided. + PublicPublishResponse: + type: object + properties: + deploymentId: + type: string + status: + type: string + commitHash: + type: string + required: + - deploymentId + - status + additionalProperties: false + PublicZipUploadResponse: + type: object + properties: + jobId: + type: string + required: + - jobId + additionalProperties: false + PublicZipUploadStatus: + type: object + properties: + status: + type: string + progressMessage: + type: string + errorMessage: + type: string + errorStage: + type: string + required: + - status + additionalProperties: false + PublicSecretsList: + type: object + properties: + shared: + type: object + properties: + secrets: + type: array + items: + type: object + properties: + name: + type: string + systemManaged: + type: boolean + createdAt: + type: string + updatedAt: + type: string + required: + - name + - systemManaged + additionalProperties: false + required: + - secrets + additionalProperties: false + variants: + type: object + properties: + preview: + type: object + properties: + secrets: + type: array + items: + type: object + properties: + name: + type: string + systemManaged: + type: boolean + createdAt: + type: string + updatedAt: + type: string + required: + - name + - systemManaged + additionalProperties: false + required: + - secrets + additionalProperties: false + publish: + type: object + properties: + secrets: + type: array + items: + type: object + properties: + name: + type: string + systemManaged: + type: boolean + createdAt: + type: string + updatedAt: + type: string + required: + - name + - systemManaged + additionalProperties: false + required: + - secrets + additionalProperties: false + additionalProperties: false + required: + - shared + - variants + additionalProperties: false + PublicSecretsUpdateBody: + type: object + properties: + variant: + default: preview + type: string + enum: + - preview + - publish + operations: + type: object + properties: + additions: + default: [] + maxItems: 50 + type: array + items: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 256 + value: + type: string + maxLength: 8192 + required: + - name + - value + additionalProperties: false + updates: + default: [] + maxItems: 50 + type: array + items: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 256 + value: + type: string + maxLength: 8192 + required: + - name + - value + additionalProperties: false + deletions: + default: [] + maxItems: 50 + type: array + items: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 256 + required: + - name + additionalProperties: false + required: + - additions + - updates + - deletions + additionalProperties: false + required: + - variant + - operations + additionalProperties: false + PublicCustomerLogs: + type: object + properties: + logs: + type: array + items: + type: object + properties: + timestamp: + type: string + source: + type: string + message: + type: string + level: + type: string + required: + - timestamp + - source + - message + - level + additionalProperties: false + total: + type: number + required: + - logs + - total + additionalProperties: false + PublicRollbackBody: + type: object + properties: + deploymentId: + type: string + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + required: + - deploymentId + additionalProperties: false + PublicRollbackResponse: + type: object + properties: + rollbackFrom: + type: string + commitHash: + type: string + deploymentId: + type: string + status: + type: string + required: + - rollbackFrom + - commitHash + - deploymentId + - status + additionalProperties: false diff --git a/rust/scripts/regenerate-hosting-spec.sh b/rust/scripts/regenerate-hosting-spec.sh new file mode 100755 index 0000000..f6a34da --- /dev/null +++ b/rust/scripts/regenerate-hosting-spec.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Regenerate the vendored Node.js Hosting OpenAPI spec and embedded API catalog. +# +# The hosting API publishes its spec on prod only (OTE has no spec endpoint): +# https://api.godaddy.com/v1/hosting/nodejs/openapi.yaml +# +# Overrides: +# HOSTING_SPEC_URL — download from this URL instead (local Katana/dev only; not OTE) +# HOSTING_SPEC_PATH — copy from a local file (e.g. hosting-web-apps checkout) +# +# Requires: curl (unless HOSTING_SPEC_PATH is set), cargo +set -euo pipefail + +rust_root="$(cd "$(dirname "$0")/.." && pwd)" +out="$rust_root/schemas/openapi/hosting-nodejs-public-v1.yaml" +default_url="https://api.godaddy.com/v1/hosting/nodejs/openapi.yaml" + +if [ -n "${HOSTING_SPEC_PATH:-}" ]; then + echo "==> Copying spec from HOSTING_SPEC_PATH=$HOSTING_SPEC_PATH" + cp "$HOSTING_SPEC_PATH" "$out" +elif [ -n "${HOSTING_SPEC_URL:-}" ]; then + echo "==> Downloading spec from HOSTING_SPEC_URL=$HOSTING_SPEC_URL" + curl -fsSL "$HOSTING_SPEC_URL" -o "$out" +else + echo "==> Downloading prod hosting OpenAPI spec" + curl -fsSL "$default_url" -o "$out" +fi + +echo "==> Wrote $out" +echo "==> Regenerating API catalog" +(cd "$rust_root" && cargo run -p generate-api-catalog) +echo "==> Done" diff --git a/rust/src/api_explorer/mod.rs b/rust/src/api_explorer/mod.rs index 3dc38c7..fdf30e8 100644 --- a/rust/src/api_explorer/mod.rs +++ b/rust/src/api_explorer/mod.rs @@ -149,6 +149,10 @@ const DOMAIN_FILES: &[(&str, &str)] = &[ "transactions", include_str!("../../schemas/api/transactions.json"), ), + ( + "hosting-nodejs", + include_str!("../../schemas/api/hosting-nodejs.json"), + ), ]; fn catalog() -> &'static [Domain] { diff --git a/rust/src/hosting/mod.rs b/rust/src/hosting/mod.rs new file mode 100644 index 0000000..82c4a24 --- /dev/null +++ b/rust/src/hosting/mod.rs @@ -0,0 +1,16 @@ +pub mod nodejs; + +use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; + +pub fn module() -> Module { + Module::new("Hosting", |_ctx| { + RuntimeGroupSpec::new( + 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)", + ), + ) + .with_group(nodejs::nodejs_group()) + }) +} diff --git a/rust/src/hosting/nodejs/client.rs b/rust/src/hosting/nodejs/client.rs new file mode 100644 index 0000000..60eaee2 --- /dev/null +++ b/rust/src/hosting/nodejs/client.rs @@ -0,0 +1,328 @@ +use std::path::Path; + +use reqwest::{Client, Method}; +use serde_json::{Value, json}; + +use crate::application::client::make_http_client; + +const BASE_PATH: &str = "/v1/hosting/nodejs"; + +#[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), + #[error("failed to read {path}: {source}")] + Io { + path: String, + source: std::io::Error, + }, +} + +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 resp = req.send().await?; + let status = resp.status().as_u16(); + + if status == 204 { + return Ok(json!(null)); + } + + let text = resp.text().await?; + if !(200..300).contains(&status) { + return Err(ClientError::Http { status, body: text }); + } + + if text.is_empty() { + return Ok(json!(null)); + } + + serde_json::from_str(&text).map_err(|e| ClientError::Http { + status, + body: format!("invalid JSON response: {e}"), + }) + } + + pub async fn list_apps(&self) -> Result { + self.send_json(Method::GET, "/apps", &[], 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, body: Value) -> Result { + self.send_json(Method::POST, "/apps", &[], Some(body)).await + } + + pub async fn patch_app(&self, app_id: &str, body: Value) -> Result { + self.send_json(Method::PATCH, &format!("/apps/{app_id}"), &[], Some(body)) + .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_creation_status(&self, job_id: &str) -> Result { + self.send_json(Method::GET, &format!("/apps/jobs/{job_id}"), &[], None) + .await + } + + pub async fn list_deployments( + &self, + app_id: &str, + limit: Option, + ) -> Result { + let mut query = Vec::new(); + if let Some(limit) = limit { + query.push(("limit", limit.to_string())); + } + self.send_json( + Method::GET, + &format!("/apps/{app_id}/deployments"), + &query, + None, + ) + .await + } + + pub async fn publish_app(&self, app_id: &str) -> Result { + self.send_json( + Method::POST, + &format!("/apps/{app_id}/deployments"), + &[], + 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 upload_source(&self, app_id: &str, zip_path: &Path) -> Result { + let form = reqwest::multipart::Form::new() + .file("zipFile", zip_path) + .await + .map_err(|e| ClientError::Io { + path: zip_path.display().to_string(), + source: e, + })?; + + let resp = self + .client + .post(self.url(&format!("/apps/{app_id}/source"))) + .bearer_auth(&self.token) + .header("x-request-id", Self::new_request_id()) + .multipart(form) + .send() + .await?; + + let status = resp.status().as_u16(); + let text = resp.text().await?; + if !(200..300).contains(&status) { + return Err(ClientError::Http { status, body: text }); + } + + serde_json::from_str(&text).map_err(|e| ClientError::Http { + status, + body: format!("invalid JSON response: {e}"), + }) + } + + pub async fn get_source_upload_status( + &self, + app_id: &str, + job_id: &str, + ) -> Result { + self.send_json( + Method::GET, + &format!("/apps/{app_id}/source/status"), + &[("jobId", job_id.to_owned())], + None, + ) + .await + } + + pub async fn list_secrets( + &self, + app_id: &str, + variant: Option<&str>, + ) -> Result { + let mut query = Vec::new(); + if let Some(variant) = variant { + query.push(("variant", variant.to_owned())); + } + self.send_json( + Method::GET, + &format!("/apps/{app_id}/secrets"), + &query, + None, + ) + .await + } + + pub async fn update_secrets(&self, app_id: &str, body: Value) -> Result { + self.send_json( + Method::POST, + &format!("/apps/{app_id}/secrets"), + &[], + Some(body), + ) + .await + } + + pub async fn get_logs( + &self, + app_id: &str, + target: &str, + source: &str, + since: &str, + lines: Option, + ) -> Result { + let mut query = vec![ + ("target", target.to_owned()), + ("source", source.to_owned()), + ("since", since.to_owned()), + ]; + if let Some(lines) = lines { + query.push(("lines", lines.to_string())); + } + self.send_json(Method::GET, &format!("/apps/{app_id}/logs"), &query, None) + .await + } +} + +#[cfg(test)] +mod tests { + 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_bearer_auth() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/hosting/nodejs/apps") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(json!({ "apps": [] })); + }) + .await; + + let body = client(&server.base_url()) + .list_apps() + .await + .expect("list apps"); + + mock.assert_async().await; + assert_eq!(body["apps"], json!([])); + } + + #[tokio::test] + async fn create_app_posts_json_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/nodejs/apps") + .json_body(json!({ "name": "demo" })); + then.status(202).json_body(json!({ + "job": { "id": "job-1", "status": "pending" } + })); + }) + .await; + + let body = client(&server.base_url()) + .create_app(json!({ "name": "demo" })) + .await + .expect("create app"); + + mock.assert_async().await; + assert_eq!(body["job"]["id"], "job-1"); + } + + #[tokio::test] + async fn upload_source_sends_multipart() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/hosting/nodejs/apps/app-1/source") + .matches(|req| { + req.body + .as_ref() + .map(|b| String::from_utf8_lossy(b).contains("zipFile")) + .unwrap_or(false) + }); + then.status(200).json_body(json!({ "jobId": "upload-1" })); + }) + .await; + + let zip_path = std::env::temp_dir().join("gddy-hosting-upload-test.zip"); + std::fs::write(&zip_path, b"zip bytes").expect("write temp zip"); + + let body = client(&server.base_url()) + .upload_source("app-1", &zip_path) + .await + .expect("upload source"); + + let _ = std::fs::remove_file(&zip_path); + + mock.assert_async().await; + assert_eq!(body["jobId"], "upload-1"); + } +} diff --git a/rust/src/hosting/nodejs/mod.rs b/rust/src/hosting/nodejs/mod.rs new file mode 100644 index 0000000..da64b82 --- /dev/null +++ b/rust/src/hosting/nodejs/mod.rs @@ -0,0 +1,702 @@ +pub mod client; +pub mod scopes; + +use cli_engine::{ + CliCoreError, CommandContext, CommandResult, CommandSpec, GroupSpec, NextAction, + NextActionParam, Result, RuntimeCommandSpec, RuntimeGroupSpec, Tier, +}; +use serde_json::{Value, json}; + +use crate::{ + application::client::api_url_for_env, hosting::nodejs::client::HostingClient, + output_schema::output_schema, +}; + +use self::scopes::{ + APPS_CREATE, APPS_DELETE, APPS_READ, APPS_UPDATE, CODE_WRITE, DEPLOY_EXECUTE, LOGS_READ, + SECRETS_WRITE, +}; + +output_schema!(HostingAppSummary { + "id": "string"; + "name": "string"; + "status": "string"; +}); + +output_schema!(HostingJobSummary { + "id": "string"; + "status": "string"; +}); + +async fn make_client(ctx: &CommandContext, scopes: &[&str]) -> 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)) +} + +fn client_err(e: client::ClientError) -> CliCoreError { + CliCoreError::message(e.to_string()) +} + +fn required_str(ctx: &CommandContext, key: &str, label: &str) -> Result { + optional_str(ctx, key).ok_or_else(|| CliCoreError::message(format!("{label} is required"))) +} + +fn optional_str(ctx: &CommandContext, key: &str) -> Option { + ctx.args + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +fn optional_u32(ctx: &CommandContext, key: &str) -> Option { + let v = ctx.args.get(key)?; + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) +} + +pub fn nodejs_group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("nodejs", "Manage Node.js hosting applications").with_long( + "Work with Node.js PaaS applications on GoDaddy hosting.\n\ + \n\ + Create apps, upload source, publish deployments, manage secrets, and \ + read logs. Async operations return job IDs — use the matching status \ + commands to poll until complete.", + ), + ) + .with_group(app_group()) + .with_group(job_group()) + .with_group(deployment_group()) + .with_command(status_command()) + .with_group(source_group()) + .with_group(secrets_group()) + .with_command(logs_command()) +} + +fn app_group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("app", "Manage hosting applications")) + .with_command(app_list_command()) + .with_command(app_get_command()) + .with_command(app_create_command()) + .with_command(app_update_command()) + .with_command(app_delete_command()) +} + +fn job_group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("job", "Poll app creation jobs")) + .with_command(job_get_command()) +} + +fn deployment_group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("deployment", "Manage app deployments")) + .with_command(deployment_list_command()) + .with_command(deployment_publish_command()) +} + +fn source_group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("source", "Upload and track app source")) + .with_command(source_upload_command()) + .with_command(source_status_command()) +} + +fn secrets_group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("secrets", "Manage app secrets")) + .with_command(secrets_list_command()) + .with_command(secrets_update_command()) +} + +fn app_list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List Node.js hosting applications") + .with_long("List all Node.js hosting applications in your account.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_READ]) + .with_default_fields("id,name,status") + .with_output_schema::(), + |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![ + NextAction::new( + "hosting nodejs app get --app-id ", + "Get details for an application", + ) + .with_param("app-id", NextActionParam::required()), + ])) + }, + ) +} + +fn app_get_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("get", "Get a Node.js hosting application") + .with_long("Get details for one Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_READ]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let client = make_client(&ctx, &[APPS_READ]).await?; + let data = client.get_app(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + NextAction::new( + "hosting nodejs deployment list --app-id ", + "List deployments for this application", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())), + NextAction::new( + "hosting nodejs status --app-id ", + "Get application status", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} + +fn app_create_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("create", "Create a Node.js hosting application") + .with_long( + "Create a Node.js hosting application. Returns a creation job; poll \ + `hosting nodejs job get` until the job is active.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[APPS_CREATE]) + .with_arg( + clap::Arg::new("name") + .long("name") + .value_name("NAME") + .required(true) + .help("Application name"), + ) + .with_arg( + clap::Arg::new("datacenter") + .long("datacenter") + .value_name("DATACENTER") + .value_parser(["p3", "sxb1"]) + .help("Datacenter (p3 or sxb1)"), + ), + |ctx| async move { + let name = required_str(&ctx, "name", "--name")?; + let mut body = json!({ "name": name }); + if let Some(datacenter) = optional_str(&ctx, "datacenter") { + body["datacenter"] = json!(datacenter); + } + let client = make_client(&ctx, &[APPS_CREATE]).await?; + let data = client.create_app(body).await.map_err(client_err)?; + let job_id = data + .pointer("/job/id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut actions = vec![ + NextAction::new( + "hosting nodejs job get --job-id ", + "Poll app creation status", + ) + .with_param("job-id", NextActionParam::required()), + ]; + if !job_id.is_empty() { + actions[0] = NextAction::new( + "hosting nodejs job get --job-id ", + "Poll app creation status", + ) + .with_param("job-id", NextActionParam::value(job_id)); + } + Ok(CommandResult::new(data).with_next_actions(actions)) + }, + ) +} + +fn app_update_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("update", "Update Node.js hosting application metadata") + .with_long("Update application metadata (name and/or root path).") + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[APPS_UPDATE]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ) + .with_arg( + clap::Arg::new("name") + .long("name") + .value_name("NAME") + .help("New application name"), + ) + .with_arg( + clap::Arg::new("root-path") + .long("root-path") + .value_name("PATH") + .help("Application root path"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let name = optional_str(&ctx, "name"); + let root_path = optional_str(&ctx, "root-path"); + if name.is_none() && root_path.is_none() { + return Err(CliCoreError::message( + "at least one of --name or --root-path is required", + )); + } + let mut body = serde_json::Map::new(); + if let Some(name) = name { + body.insert("name".to_owned(), json!(name)); + } + if let Some(root_path) = root_path { + body.insert("rootPath".to_owned(), json!(root_path)); + } + let client = make_client(&ctx, &[APPS_UPDATE]).await?; + let data = client + .patch_app(&app_id, Value::Object(body)) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} + +fn app_delete_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("delete", "Delete a Node.js hosting application") + .with_long("Permanently delete a Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Destructive) + .mutates(true) + .with_scopes(&[APPS_DELETE]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let client = make_client(&ctx, &[APPS_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![NextAction::new( + "hosting nodejs app list", + "List remaining applications", + )], + ), + ) + }, + ) +} + +fn job_get_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("get", "Get app creation job status") + .with_long( + "Poll an app creation job until status is active (app provisioned) or failed.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_CREATE]) + .with_default_fields("id,status") + .with_output_schema::() + .with_arg( + clap::Arg::new("job-id") + .long("job-id") + .value_name("JOB_ID") + .required(true) + .help("App creation job ID"), + ), + |ctx| async move { + let job_id = required_str(&ctx, "job-id", "--job-id")?; + let client = make_client(&ctx, &[APPS_CREATE]).await?; + let data = client + .get_app_creation_status(&job_id) + .await + .map_err(client_err)?; + let status = data + .pointer("/job/status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut actions = vec![ + NextAction::new( + "hosting nodejs job get --job-id ", + "Re-check job status", + ) + .with_param("job-id", NextActionParam::value(job_id)), + ]; + if let Some(app_id) = data.pointer("/app/id").and_then(|v| v.as_str()) { + actions.push( + NextAction::new( + "hosting nodejs app get --app-id ", + "View the provisioned application", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ); + } else if status == "pending" || status == "active" { + // keep poll action only + } + Ok(CommandResult::new(data).with_next_actions(actions)) + }, + ) +} + +fn deployment_list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List application deployments") + .with_long("List deployments for a Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_READ]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ) + .with_arg( + clap::Arg::new("limit") + .long("limit") + .value_name("N") + .value_parser(clap::value_parser!(u32).range(1..=50)) + .help("Maximum deployments to return (1-50)"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let limit = optional_u32(&ctx, "limit"); + let client = make_client(&ctx, &[APPS_READ]).await?; + let data = client + .list_deployments(&app_id, limit) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + NextAction::new( + "hosting nodejs deployment publish --app-id ", + "Publish the latest source", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} + +fn deployment_publish_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("publish", "Publish application (deploy latest code)") + .with_long( + "Deploy the latest uploaded source for a Node.js hosting application. \ + Poll `hosting nodejs status` and `hosting nodejs deployment list` for progress.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[DEPLOY_EXECUTE]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let client = make_client(&ctx, &[DEPLOY_EXECUTE]).await?; + let data = client.publish_app(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + NextAction::new( + "hosting nodejs status --app-id ", + "Check application status", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())), + NextAction::new( + "hosting nodejs deployment list --app-id ", + "List deployments", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} + +fn status_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("status", "Get application status") + .with_long("Get runtime status for a Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_READ]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let client = make_client(&ctx, &[APPS_READ]).await?; + let data = client.get_app_status(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} + +fn source_upload_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("upload", "Upload application source (zip)") + .with_long( + "Upload a zip archive of application source. Returns a job ID; poll \ + `hosting nodejs source status` until complete.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[CODE_WRITE]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ) + .with_arg( + clap::Arg::new("file") + .long("file") + .short('f') + .value_name("PATH") + .required(true) + .help("Path to the zip file"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let file = required_str(&ctx, "file", "--file")?; + let path = std::path::Path::new(&file); + if !path.is_file() { + return Err(CliCoreError::message(format!("zip file not found: {file}"))); + } + let client = make_client(&ctx, &[CODE_WRITE]).await?; + let data = client + .upload_source(&app_id, path) + .await + .map_err(client_err)?; + let upload_job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or(""); + let action = if upload_job_id.is_empty() { + NextAction::new( + "hosting nodejs source status --app-id --job-id ", + "Poll zip upload status", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("job-id", NextActionParam::required()) + } else { + NextAction::new( + "hosting nodejs source status --app-id --job-id ", + "Poll zip upload status", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("job-id", NextActionParam::value(upload_job_id)) + }; + Ok(CommandResult::new(data).with_next_actions(vec![action])) + }, + ) +} + +fn source_status_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("status", "Poll zip upload status") + .with_long("Poll the status of a zip source upload job.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[CODE_WRITE]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ) + .with_arg( + clap::Arg::new("job-id") + .long("job-id") + .value_name("JOB_ID") + .required(true) + .help("Upload job ID"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let job_id = required_str(&ctx, "job-id", "--job-id")?; + let client = make_client(&ctx, &[CODE_WRITE]).await?; + let data = client + .get_source_upload_status(&app_id, &job_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + NextAction::new( + "hosting nodejs source status --app-id --job-id ", + "Poll again while upload is in progress", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())) + .with_param("job-id", NextActionParam::value(job_id)), + NextAction::new( + "hosting nodejs deployment publish --app-id ", + "Publish after upload completes", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} + +fn secrets_list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List application secrets (metadata only)") + .with_long("List secret names and metadata. Secret values are never returned.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[SECRETS_WRITE]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ) + .with_arg( + clap::Arg::new("variant") + .long("variant") + .value_name("VARIANT") + .value_parser(["preview", "publish"]) + .help("Variant filter (preview or publish)"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let variant = optional_str(&ctx, "variant"); + let client = make_client(&ctx, &[SECRETS_WRITE]).await?; + let data = client + .list_secrets(&app_id, variant.as_deref()) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} + +fn secrets_update_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("update", "Add, update, or delete application secrets") + .with_long( + "Update secrets from a JSON file matching the PublicSecretsUpdateBody schema.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SECRETS_WRITE]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ) + .with_arg( + clap::Arg::new("file") + .long("file") + .short('f') + .value_name("PATH") + .required(true) + .help("JSON file with secret operations"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let file = required_str(&ctx, "file", "--file")?; + let content = std::fs::read_to_string(&file).map_err(|e| { + CliCoreError::message(format!("failed to read secrets file '{file}': {e}")) + })?; + let body: Value = serde_json::from_str(&content).map_err(|e| { + CliCoreError::message(format!("invalid JSON in secrets file '{file}': {e}")) + })?; + let client = make_client(&ctx, &[SECRETS_WRITE]).await?; + let data = client + .update_secrets(&app_id, body) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} + +fn logs_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("logs", "Get application logs") + .with_long("Fetch log entries for a Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[LOGS_READ]) + .with_arg( + clap::Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .required(true) + .help("Application ID"), + ) + .with_arg( + clap::Arg::new("target") + .long("target") + .value_name("TARGET") + .required(true) + .value_parser(["preview", "publish"]) + .help("Log target (preview or publish)"), + ) + .with_arg( + clap::Arg::new("source") + .long("source") + .value_name("SOURCE") + .required(true) + .help("Log source identifier"), + ) + .with_arg( + clap::Arg::new("since") + .long("since") + .value_name("TIMESTAMP") + .required(true) + .help("Return logs since this ISO timestamp"), + ) + .with_arg( + clap::Arg::new("lines") + .long("lines") + .value_name("N") + .value_parser(clap::value_parser!(u32).range(1..=500)) + .help("Maximum log lines to return (1-500)"), + ), + |ctx| async move { + let app_id = required_str(&ctx, "app-id", "--app-id")?; + let target = required_str(&ctx, "target", "--target")?; + let source = required_str(&ctx, "source", "--source")?; + let since = required_str(&ctx, "since", "--since")?; + let lines = optional_u32(&ctx, "lines"); + let client = make_client(&ctx, &[LOGS_READ]).await?; + let data = client + .get_logs(&app_id, &target, &source, &since, lines) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} diff --git a/rust/src/hosting/nodejs/scopes.rs b/rust/src/hosting/nodejs/scopes.rs new file mode 100644 index 0000000..c0699f3 --- /dev/null +++ b/rust/src/hosting/nodejs/scopes.rs @@ -0,0 +1,10 @@ +//! OAuth scopes for the Node.js Hosting Public API. + +pub const APPS_READ: &str = "hosting.paas.apps:read"; +pub const APPS_CREATE: &str = "hosting.paas.apps:create"; +pub const APPS_UPDATE: &str = "hosting.paas.apps:update"; +pub const APPS_DELETE: &str = "hosting.paas.apps:delete"; +pub const CODE_WRITE: &str = "hosting.paas.code:write"; +pub const DEPLOY_EXECUTE: &str = "hosting.paas.deploy:execute"; +pub const SECRETS_WRITE: &str = "hosting.paas.secrets:write"; +pub const LOGS_READ: &str = "hosting.paas.logs:read"; diff --git a/rust/src/main.rs b/rust/src/main.rs index e946341..bd5e074 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -9,6 +9,7 @@ mod domain; mod env; mod environments; mod extension; +mod hosting; mod output_schema; mod payments; mod webhook; @@ -39,6 +40,7 @@ async fn main() -> ExitCode { • dns — view and edit a domain's DNS records\n \ • api — explore and call GoDaddy REST API endpoints directly\n \ • application — build, configure, and deploy platform applications\n \ + • hosting — manage Node.js PaaS applications (create, upload, deploy)\n \ • payments — manage the payment methods used for purchases\n\ \n\ Most commands need authentication; run `gddy auth login` first (or just run a\n\ @@ -88,6 +90,7 @@ async fn main() -> ExitCode { .with_module(dns::module()) .with_module(domain::module()) .with_module(env::module()) + .with_module(hosting::module()) .with_module(payments::module()) .with_module(webhook::module()), ); diff --git a/rust/tools/generate-api-catalog/src/main.rs b/rust/tools/generate-api-catalog/src/main.rs index d37d078..7e55a38 100644 --- a/rust/tools/generate-api-catalog/src/main.rs +++ b/rust/tools/generate-api-catalog/src/main.rs @@ -41,6 +41,12 @@ const BOOTSTRAP_REPOS: &[&str] = &[ const LEGACY_REPOS: &[&str] = &["location.addresses-specification"]; +/// Local vendored OpenAPI specs (domain key, path relative to this crate's manifest dir). +const LOCAL_SPECS: &[(&str, &str)] = &[( + "hosting-nodejs", + "../../schemas/openapi/hosting-nodejs-public-v1.yaml", +)]; + const HTTP_METHODS: &[&str] = &[ "get", "post", "put", "patch", "delete", "options", "head", "trace", ]; @@ -535,6 +541,42 @@ fn discover_spec_sources() -> Result<(Vec, PathBuf)> { Ok((sources, tmpdir)) } +fn local_spec_sources() -> Result> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut sources = Vec::new(); + for (domain, rel_path) in LOCAL_SPECS { + let spec_file = manifest_dir.join(rel_path); + if !spec_file.exists() { + eprintln!( + "WARNING: local spec {} not found — skipping", + spec_file.display() + ); + continue; + } + let src = std::fs::read_to_string(&spec_file) + .with_context(|| format!("failed to read {}", spec_file.display()))?; + let spec = parse_yaml_or_json(&src, &spec_file)?; + let raw_version = spec + .get("info") + .and_then(|i| i.get("version")) + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0"); + let spec_version = if raw_version.starts_with('v') { + raw_version.to_owned() + } else { + format!("v{raw_version}") + }; + sources.push(SpecSource { + domain: (*domain).to_owned(), + repo_name: format!("local:{domain}"), + spec_file, + spec_version, + graphql_only: false, + }); + } + Ok(sources) +} + fn regex_is_match_commerce_spec(name: &str) -> bool { // ^commerce\.[a-z0-9-]+-specification$ if let Some(rest) = name.strip_prefix("commerce.") @@ -1649,7 +1691,8 @@ fn main() -> Result<()> { std::fs::create_dir_all(&output_dir).context("failed to create output dir")?; eprintln!("Discovering specification repositories..."); - let (sources, tmpdir) = discover_spec_sources()?; + let (mut sources, tmpdir) = discover_spec_sources()?; + sources.extend(local_spec_sources()?); if sources.is_empty() { bail!("no specification repositories discovered — refusing to overwrite catalog output");