Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ xcloud health
xcloud user show
xcloud user tokens
xcloud user revoke-token <token-id> --yes
xcloud integrations cloudflare
xcloud blueprints list

xcloud servers list
Expand All @@ -145,6 +146,9 @@ xcloud servers monitoring <server-uuid>
xcloud servers tasks <server-uuid>
xcloud servers php-versions <server-uuid>
xcloud servers sudo-users <server-uuid>
xcloud servers create-sudo-user <server-uuid> --data '{"username":"deploy"}' --yes
xcloud servers delete-sudo-user <server-uuid> <sudo-user-uuid> --yes
xcloud servers create-wordpress-site <server-uuid> --data '{"domain":"example.com"}' --yes
xcloud servers power-cycle <server-uuid> --yes

xcloud sites list
Expand All @@ -153,10 +157,12 @@ xcloud sites backups <site-uuid>
xcloud sites status <site-uuid>
xcloud sites ssl <site-uuid>
xcloud sites domain <site-uuid>
xcloud sites monitoring <site-uuid>
xcloud sites events <site-uuid>
xcloud sites deployment-logs <site-uuid>
xcloud sites git <site-uuid>
xcloud sites ssh <site-uuid>
xcloud sites update-ssh <site-uuid> --data '{"public_key":"ssh-ed25519 ..."}' --yes
xcloud sites backup <site-uuid> --yes
xcloud sites purge-cache <site-uuid> --yes
```
Expand All @@ -177,6 +183,18 @@ xcloud enterprise servers create --data '{"name":"Demo"}' --yes
xcloud enterprise servers update <server-uuid> --data '{"name":"New"}' --yes
xcloud enterprise servers delete <server-uuid> --yes

xcloud enterprise sites list
xcloud enterprise sites show <site-uuid>
xcloud enterprise sites create --data '{"domain":"example.com"}' --yes
xcloud enterprise sites update <site-uuid> --data '{"domain":"example.org"}' --yes
xcloud enterprise sites delete <site-uuid> --yes
xcloud enterprise sites suspend <site-uuid> --yes
xcloud enterprise sites staging <site-uuid> --yes
xcloud enterprise sites publish-staging <site-uuid> --yes
xcloud enterprise sites purge-cache <site-uuid> --yes
xcloud enterprise sites ssh-keys <site-uuid>
xcloud enterprise sites create-ssh-key <site-uuid> --data '{"public_key":"ssh-ed25519 ..."}' --yes

xcloud enterprise users list
xcloud enterprise users show <user-uuid>
xcloud enterprise users create --data '{"email":"user@example.com"}' --yes
Expand Down Expand Up @@ -206,6 +224,7 @@ xcloud enterprise addon patchstack delete <site-uuid> --yes

```bash
xcloud api get /servers --query page=2 --output json
xcloud api put /sites/<uuid>/ssh --data '{"public_key":"ssh-ed25519 ..."}' --yes
xcloud api post /sites/<uuid>/backup --data '{}' --yes
xcloud --api-flavor enterprise api get /servers --output json
```
Expand Down
20 changes: 7 additions & 13 deletions bin/xcloud.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 <path> [--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 <path> [--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:
Expand Down Expand Up @@ -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 <path>');
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 <path>');
dangerous = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
} else if (args[0] === 'health') {
[method, path] = ['GET', '/health'];
} else {
Expand All @@ -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]);
}

Expand Down
81 changes: 57 additions & 24 deletions lib/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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, '');
Expand Down
53 changes: 51 additions & 2 deletions test/routes-http-output.test.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
Expand Down
Loading