diff --git a/README.md b/README.md index 4e559e1..fd71d7c 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ xcloud health xcloud user show xcloud user tokens xcloud user revoke-token --yes +xcloud integrations cloudflare xcloud blueprints list xcloud servers list @@ -145,6 +146,9 @@ xcloud servers monitoring xcloud servers tasks xcloud servers php-versions xcloud servers sudo-users +xcloud servers create-sudo-user --data '{"username":"deploy"}' --yes +xcloud servers delete-sudo-user --yes +xcloud servers create-wordpress-site --data '{"domain":"example.com"}' --yes xcloud servers power-cycle --yes xcloud sites list @@ -153,10 +157,12 @@ xcloud sites backups xcloud sites status xcloud sites ssl xcloud sites domain +xcloud sites monitoring xcloud sites events xcloud sites deployment-logs xcloud sites git xcloud sites ssh +xcloud sites update-ssh --data '{"public_key":"ssh-ed25519 ..."}' --yes xcloud sites backup --yes xcloud sites purge-cache --yes ``` @@ -177,6 +183,18 @@ xcloud enterprise servers create --data '{"name":"Demo"}' --yes xcloud enterprise servers update --data '{"name":"New"}' --yes xcloud enterprise servers delete --yes +xcloud enterprise sites list +xcloud enterprise sites show +xcloud enterprise sites create --data '{"domain":"example.com"}' --yes +xcloud enterprise sites update --data '{"domain":"example.org"}' --yes +xcloud enterprise sites delete --yes +xcloud enterprise sites suspend --yes +xcloud enterprise sites staging --yes +xcloud enterprise sites publish-staging --yes +xcloud enterprise sites purge-cache --yes +xcloud enterprise sites ssh-keys +xcloud enterprise sites create-ssh-key --data '{"public_key":"ssh-ed25519 ..."}' --yes + xcloud enterprise users list xcloud enterprise users show xcloud enterprise users create --data '{"email":"user@example.com"}' --yes @@ -206,6 +224,7 @@ xcloud enterprise addon patchstack delete --yes ```bash xcloud api get /servers --query page=2 --output json +xcloud api put /sites//ssh --data '{"public_key":"ssh-ed25519 ..."}' --yes xcloud api post /sites//backup --data '{}' --yes xcloud --api-flavor enterprise api get /servers --output json ``` diff --git a/bin/xcloud.js b/bin/xcloud.js index 2969328..9e92fe5 100755 --- a/bin/xcloud.js +++ b/bin/xcloud.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { loadConfig, saveProfile, redact } from '../lib/config.js'; -import { buildURL, resolveRoute } from '../lib/routes.js'; +import { buildRoutePath, buildURL, resolveRoute } from '../lib/routes.js'; import { requestJSON } from '../lib/http.js'; import { confirmDangerous } from '../lib/confirm.js'; import { format, errorPayload } from '../lib/output.js'; @@ -36,9 +36,9 @@ Usage: xcloud --ai [--agent local|hosted] [prompt] xcloud [global flags] health xcloud [global flags] configure --base-url URL --token TOKEN [--api-flavor public|enterprise] - xcloud [global flags] api get|post|patch|delete [--data JSON] [--query k=v] - xcloud [global flags] servers list|show|sites|databases|cron-jobs|monitoring|tasks|php-versions|sudo-users|power-cycle [uuid] - xcloud [global flags] sites list|show|backups|status|ssl|domain|events|deployment-logs|git|ssh|backup|purge-cache [uuid] + xcloud [global flags] api get|post|put|patch|delete [--data JSON] [--query k=v] + xcloud [global flags] servers list|show|sites|databases|cron-jobs|monitoring|tasks|php-versions|sudo-users|create-sudo-user|delete-sudo-user|create-wordpress-site|power-cycle [uuid] + xcloud [global flags] sites list|show|backups|status|ssl|domain|monitoring|events|deployment-logs|git|ssh|update-ssh|backup|purge-cache [uuid] xcloud [global flags] enterprise servers|users|addon ... AI mode: @@ -84,8 +84,8 @@ async function run(argv = process.argv.slice(2)) { if (args[0] === 'api') { method = String(args[1] || '').toUpperCase(); path = args[2]; - if (!['GET', 'POST', 'PATCH', 'DELETE'].includes(method) || !path) throw new Error('api usage: xcloud api get|post|patch|delete '); - dangerous = ['POST', 'PATCH', 'DELETE'].includes(method); + if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method) || !path) throw new Error('api usage: xcloud api get|post|put|patch|delete '); + dangerous = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method); } else if (args[0] === 'health') { [method, path] = ['GET', '/health']; } else { @@ -95,13 +95,7 @@ async function run(argv = process.argv.slice(2)) { const { route, params, key } = resolved; cfg.apiFlavor = flavor; method = route[0]; - if (typeof route[1] === 'function') { - if (!params[0]) throw new Error(`missing required id for command: ${key}`); - path = route[1](params[0]); - } else { - if (params.length) throw new Error(`unexpected argument(s) for command: ${key}`); - path = route[1]; - } + path = buildRoutePath(route, params, key); dangerous = Boolean(route[2]); } diff --git a/lib/routes.js b/lib/routes.js index 9182fb8..b0c9a49 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -10,38 +10,55 @@ export const routes = { health: ['GET', '/health', false], 'user show': ['GET', '/user'], 'user tokens': ['GET', '/user/tokens'], - 'user revoke-token': ['DELETE', id => `/user/tokens/${id}`, true], + 'user revoke-token': ['DELETE', tokenId => `/user/tokens/${tokenId}`, true], + 'integrations cloudflare': ['GET', '/integrations/cloudflare'], 'blueprints list': ['GET', '/blueprints'], 'servers list': ['GET', '/servers'], - 'servers show': ['GET', id => `/servers/${id}`], - 'servers sites': ['GET', id => `/servers/${id}/sites`], - 'servers databases': ['GET', id => `/servers/${id}/databases`], - 'servers cron-jobs': ['GET', id => `/servers/${id}/cron-jobs`], - 'servers monitoring': ['GET', id => `/servers/${id}/monitoring`], - 'servers tasks': ['GET', id => `/servers/${id}/tasks`], - 'servers php-versions': ['GET', id => `/servers/${id}/php-versions`], - 'servers sudo-users': ['GET', id => `/servers/${id}/sudo-users`], - 'servers power-cycle': ['POST', id => `/servers/${id}/reboot`, true], + 'servers show': ['GET', serverId => `/servers/${serverId}`], + 'servers sites': ['GET', serverId => `/servers/${serverId}/sites`], + 'servers databases': ['GET', serverId => `/servers/${serverId}/databases`], + 'servers cron-jobs': ['GET', serverId => `/servers/${serverId}/cron-jobs`], + 'servers monitoring': ['GET', serverId => `/servers/${serverId}/monitoring`], + 'servers tasks': ['GET', serverId => `/servers/${serverId}/tasks`], + 'servers php-versions': ['GET', serverId => `/servers/${serverId}/php-versions`], + 'servers sudo-users': ['GET', serverId => `/servers/${serverId}/sudo-users`], + 'servers create-sudo-user': ['POST', serverId => `/servers/${serverId}/sudo-users`, true], + 'servers delete-sudo-user': ['DELETE', (serverId, sudoUserId) => `/servers/${serverId}/sudo-users/${sudoUserId}`, true], + 'servers create-wordpress-site': ['POST', serverId => `/servers/${serverId}/sites/wordpress`, true], + 'servers power-cycle': ['POST', serverId => `/servers/${serverId}/reboot`, true], 'sites list': ['GET', '/sites'], - 'sites show': ['GET', id => `/sites/${id}`], - 'sites backups': ['GET', id => `/sites/${id}/backups`], - 'sites status': ['GET', id => `/sites/${id}/status`], - 'sites ssl': ['GET', id => `/sites/${id}/ssl`], - 'sites domain': ['GET', id => `/sites/${id}/domain`], - 'sites events': ['GET', id => `/sites/${id}/events`], - 'sites deployment-logs': ['GET', id => `/sites/${id}/deployment-logs`], - 'sites git': ['GET', id => `/sites/${id}/git`], - 'sites ssh': ['GET', id => `/sites/${id}/ssh`], - 'sites backup': ['POST', id => `/sites/${id}/backup`, true], - 'sites purge-cache': ['POST', id => `/sites/${id}/cache/purge`, true] + 'sites show': ['GET', siteId => `/sites/${siteId}`], + 'sites backups': ['GET', siteId => `/sites/${siteId}/backups`], + 'sites status': ['GET', siteId => `/sites/${siteId}/status`], + 'sites ssl': ['GET', siteId => `/sites/${siteId}/ssl`], + 'sites domain': ['GET', siteId => `/sites/${siteId}/domain`], + 'sites monitoring': ['GET', siteId => `/sites/${siteId}/monitoring`], + 'sites events': ['GET', siteId => `/sites/${siteId}/events`], + 'sites deployment-logs': ['GET', siteId => `/sites/${siteId}/deployment-logs`], + 'sites git': ['GET', siteId => `/sites/${siteId}/git`], + 'sites ssh': ['GET', siteId => `/sites/${siteId}/ssh`], + 'sites update-ssh': ['PUT', siteId => `/sites/${siteId}/ssh`, true], + 'sites backup': ['POST', siteId => `/sites/${siteId}/backup`, true], + 'sites purge-cache': ['POST', siteId => `/sites/${siteId}/cache/purge`, true] }, enterprise: { health: ['GET', '/health', false], 'enterprise servers list': ['GET', '/servers'], - 'enterprise servers show': ['GET', id => `/server/${id}`], + 'enterprise servers show': ['GET', serverId => `/server/${serverId}`], 'enterprise servers create': ['POST', '/server', true], - 'enterprise servers update': ['PATCH', id => `/server/${id}`, true], - 'enterprise servers delete': ['DELETE', id => `/server/${id}`, true], + 'enterprise servers update': ['PATCH', serverId => `/server/${serverId}`, true], + 'enterprise servers delete': ['DELETE', serverId => `/server/${serverId}`, true], + 'enterprise sites list': ['GET', '/sites'], + 'enterprise sites show': ['GET', siteId => `/site/${siteId}`], + 'enterprise sites create': ['POST', '/site', true], + 'enterprise sites update': ['PUT', siteId => `/site/${siteId}`, true], + 'enterprise sites delete': ['DELETE', siteId => `/site/${siteId}`, true], + 'enterprise sites suspend': ['POST', siteId => `/site/${siteId}/suspend`, true], + 'enterprise sites staging': ['POST', siteId => `/site/${siteId}/staging`, true], + 'enterprise sites publish-staging': ['POST', siteId => `/site/${siteId}/staging/publish`, true], + 'enterprise sites purge-cache': ['POST', siteId => `/site/${siteId}/cache/purge`, true], + 'enterprise sites ssh-keys': ['GET', siteId => `/site/${siteId}/ssh-keys`], + 'enterprise sites create-ssh-key': ['POST', siteId => `/site/${siteId}/ssh-keys`, true], 'enterprise users list': ['GET', '/users'], 'enterprise users show': ['GET', id => `/user/${id}`], 'enterprise users create': ['POST', '/user', true], @@ -74,6 +91,22 @@ export function resolveRoute(flavor, words) { return null; } +export function buildRoutePath(route, params = [], key = 'command') { + const target = route[1]; + if (typeof target !== 'function') { + if (params.length) throw new Error(`unexpected argument(s) for command: ${key}`); + return target; + } + + const required = target.length; + if (params.length < required) { + const suffix = required === 1 ? 'id' : `${required} ids`; + throw new Error(`missing required ${suffix} for command: ${key}`); + } + if (params.length > required) throw new Error(`unexpected argument(s) for command: ${key}`); + return target(...params); +} + export function buildURL(baseURL, flavor, path, query = []) { const cleanBase = String(baseURL || '').replace(/\/+$/, ''); const prefix = apiPrefix(flavor).replace(/^\/+|\/+$/g, ''); diff --git a/test/routes-http-output.test.js b/test/routes-http-output.test.js index 94536d5..01e1705 100644 --- a/test/routes-http-output.test.js +++ b/test/routes-http-output.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildURL, resolveRoute } from '../lib/routes.js'; +import { buildRoutePath, buildURL, resolveRoute } from '../lib/routes.js'; import { makeHeaders } from '../lib/http.js'; import { errorPayload, format } from '../lib/output.js'; @@ -19,10 +19,59 @@ test('resolveRoute maps safe command names to actual endpoints', () => { const resolved = resolveRoute('public', ['servers', 'power-cycle', 'srv-uuid']); const route = resolved.route; assert.equal(route[0], 'POST'); - assert.equal(route[1](resolved.params[0]), '/servers/srv-uuid/reboot'); + assert.equal(buildRoutePath(route, resolved.params, resolved.key), '/servers/srv-uuid/reboot'); assert.equal(route[2], true); }); +test('resolveRoute supports public API branch additions', () => { + assert.equal( + buildRoutePath(resolveRoute('public', ['integrations', 'cloudflare']).route, []), + '/integrations/cloudflare' + ); + assert.equal( + buildRoutePath( + resolveRoute('public', ['servers', 'delete-sudo-user', 'srv-uuid', 'sudo-uuid']).route, + ['srv-uuid', 'sudo-uuid'] + ), + '/servers/srv-uuid/sudo-users/sudo-uuid' + ); + assert.equal( + buildRoutePath(resolveRoute('public', ['servers', 'create-wordpress-site', 'srv-uuid']).route, ['srv-uuid']), + '/servers/srv-uuid/sites/wordpress' + ); + assert.equal( + buildRoutePath(resolveRoute('public', ['sites', 'monitoring', 'site-uuid']).route, ['site-uuid']), + '/sites/site-uuid/monitoring' + ); + assert.equal( + resolveRoute('public', ['sites', 'update-ssh', 'site-uuid']).route[0], + 'PUT' + ); +}); + +test('resolveRoute supports enterprise site endpoints from API docs', () => { + assert.equal( + buildRoutePath(resolveRoute('enterprise', ['enterprise', 'sites', 'list']).route, []), + '/sites' + ); + assert.equal( + buildRoutePath(resolveRoute('enterprise', ['enterprise', 'sites', 'publish-staging', 'site-uuid']).route, ['site-uuid']), + '/site/site-uuid/staging/publish' + ); + assert.equal( + buildRoutePath(resolveRoute('enterprise', ['enterprise', 'sites', 'create-ssh-key', 'site-uuid']).route, ['site-uuid']), + '/site/site-uuid/ssh-keys' + ); +}); + +test('buildRoutePath rejects missing multi-parameter route args', () => { + const resolved = resolveRoute('public', ['servers', 'delete-sudo-user', 'srv-uuid']); + assert.throws( + () => buildRoutePath(resolved.route, resolved.params, resolved.key), + /missing required 2 ids/ + ); +}); + test('makeHeaders injects bearer token without changing caller token', () => { const headers = makeHeaders('secret-token'); assert.equal(headers.Authorization, 'Bearer secret-token');