Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ jobs:
if: matrix.node-version != '20.x'
run: |
npm run test:native
ALTIS_TEST_SOURCE=1 node --throw-deprecation --test test/urls.test.js test/log-tail.test.js
ALTIS_TEST_SOURCE=1 node --throw-deprecation --test test/urls.test.js test/maintenance.test.js test/deploy.test.js test/log-tail.test.js
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Always use `altis-cli help` for the most up-to-date list of commands.
* `access add <instance> <email>` - Add or invite a user. Example: `altis-cli instance access add example-dev-01 user@example.com --role developer`
* `access remove <instance> <user-id>` - Remove a user. Example: `altis-cli instance access remove example-dev-01 42 --yes`
* `maintenance get <instance>` - Show maintenance details. Example: `altis-cli instance maintenance get example-dev-01`
* `maintenance set <instance>` - Update maintenance contact. Example: `altis-cli instance maintenance set example-dev-01 --contact ops@example.com`
* `maintenance set <instance>` - Add a maintenance contact, preserving existing contacts and ignoring duplicates. Example: `altis-cli instance maintenance set example-dev-01 --contact ops@example.com`
* `reports <instance>` - List reports. Example: `altis-cli instance reports example-dev-01`

## Releasing
Expand Down Expand Up @@ -142,3 +142,32 @@ npm test
CLI endpoint additions live in `lib/types/api.ts`; X-Ray summaries and statistics
live in `lib/types/xray.ts`. Neither builds nor installs require a frontend checkout.
`fetchJSON<T>()` describes expected API responses without validating them at runtime.

### Typed API requests

Vantage requests infer request and response contracts from the path and HTTP
method. Contracts for the REST operations used by the CLI live in
`lib/types/api-routes.ts`, with shared responses from the synchronized Vantage
frontend types and request fields checked against the backend endpoint handlers.

```ts
const maintenance = await vantage.request(
`/api/stack/instances/${id}/maintenance`,
{
method: 'PATCH',
body: { contacts: [{ email: contact }] },
},
);
```

GET is the default method. Relative paths such as `stack/instances/${id}/maintenance`
also work with the configured API base. Pass query parameters in `query` and JSON
objects in `body`; the client performs serialization. Use `as const` for a path
stored in a variable to preserve inference. `requestRaw()` checks the same contract
and retains response headers, download bodies, and HTTP status handling. The raw
transport is private; commands cannot choose arbitrary response generics.

`npm run typecheck` includes compile-time regression checks for invalid paths,
methods, queries and payloads. These contracts provide static checking, not runtime
validation of server responses. OAuth uses its own typed form request; signed
backup downloads are binary transfers rather than REST JSON requests.
9 changes: 9 additions & 0 deletions lib/api-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class ApiError extends Error {
status: number;
code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.status = status;
this.code = code;
}
}
11 changes: 7 additions & 4 deletions lib/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import fetch from 'node-fetch';
import open from 'open';
import qs from 'querystring';

interface OAuthTokenRequest { grant_type: 'authorization_code'; code: string; client_id: string; client_secret: string; }
interface OAuthTokenResponse { access_token: string; }

interface AuthorizationOptions {
endpoints: { authorize: string; exchange: string };
key: string;
Expand Down Expand Up @@ -38,7 +41,7 @@ class Authenticator {

exchangeCode( code: string ) {
// Exchange for a token.
const params = {
const params: OAuthTokenRequest = {
grant_type: 'authorization_code',
code,
client_id: this.options.key,
Expand All @@ -49,12 +52,12 @@ class Authenticator {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: qs.stringify( params ),
body: qs.stringify( { ...params } ),
};
return fetch( this.options.endpoints.exchange, opts )
.then( resp => resp.json() )
.then( resp => resp.json() as Promise<OAuthTokenResponse> )
.then( data => {
const token = (data as { access_token: string }).access_token;
const token = data.access_token;
return token;
} );
}
Expand Down
3 changes: 1 addition & 2 deletions lib/commands/config/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ const handler = (argv: CliArguments) => {
const vantage_token = config.get('vantage_token');
if (vantage_token) {
const v = new vantage(config);
v.fetch('/api/wp/v2/users/me')
.then(resp => (resp.json() as Promise<{ slug: string }>))
v.request('/api/wp/v2/users/me')
.then(data => {
console.log(chalk.bold('Altis Cloud:'));
console.log(` ${chalk.bold('User')}: ${data.slug}`);
Expand Down
37 changes: 20 additions & 17 deletions lib/commands/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { errorCode } from '../../types/errors.ts';
import type { InstanceAccessRow, InstanceReport, InstanceResponse } from '../../types/api.ts';
import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts';
import chalk from 'chalk';
import { fetchJSON, confirm, printJSON, printTable } from '../stack/util.ts';
import { confirm, printJSON, printTable } from '../stack/util.ts';
import Vantage from '../../vantage.ts';

function requireArg(value: unknown, message: string): asserts value {
Expand All @@ -16,7 +16,7 @@ const handler = async (argv: CliArguments) => {
const v = new Vantage(argv.config);

if (!group || group === 'list') {
const data = await fetchJSON<InstanceResponse[]>(v, 'stack/instances');
const data = await v.request('stack/instances');
if (argv.json) {
printJSON(data);
return;
Expand All @@ -30,7 +30,7 @@ const handler = async (argv: CliArguments) => {

if (group === 'info') {
requireArg(action, 'Usage: altis-cli instance info <instance>');
const data = await fetchJSON<InstanceResponse>(v, `stack/instances/${action}`);
const data = await v.request(`stack/instances/${action}` as const);
if (argv.json) {
printJSON(data);
return;
Expand All @@ -41,7 +41,7 @@ const handler = async (argv: CliArguments) => {

if (group === 'reports') {
requireArg(action, 'Usage: altis-cli instance reports <instance>');
const data = await fetchJSON<InstanceReport[]>(v, `stack/instances/${action}/reports`);
const data = await v.request(`stack/instances/${action}/reports` as const);
if (argv.json) {
printJSON(data);
return;
Expand All @@ -60,9 +60,9 @@ const handler = async (argv: CliArguments) => {
if (group === 'maintenance') {
requireArg(action, 'Usage: altis-cli instance maintenance <get|set> <instance>');
requireArg(id, 'Instance id is required.');
const url = `stack/instances/${id}/maintenance`;
const url = `stack/instances/${id}/maintenance` as const;
if (action === 'get') {
const data = await fetchJSON<InstanceAccessRow[]>(v, url);
const data = await v.request(url);
if (argv.json) {
printJSON(data);
return;
Expand All @@ -73,25 +73,28 @@ const handler = async (argv: CliArguments) => {
if (action !== 'set') {
throw new Error('Usage: altis-cli instance maintenance <get|set> <instance>');
}
const data = await fetchJSON<{ pending?: boolean }>(v, url, {
const email = argv.contact?.trim();
requireArg(email, '--contact <email> is required.');
const current = await v.request(url);
const alreadyPresent = current.contacts.some(contact => contact.email.toLowerCase() === email.toLowerCase());
const data = alreadyPresent ? current : await v.request(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contact: argv.contact }),
body: { contacts: [...current.contacts, { email }] },
});
if (argv.json) {
printJSON(data);
return;
}
console.log(chalk.green('Maintenance details updated.'));
console.log(chalk.green(alreadyPresent ? 'Maintenance contact already present.' : 'Maintenance details updated.'));
return;
}

if (group === 'access') {
requireArg(action, 'Usage: altis-cli instance access <list|add|remove> <instance>');
requireArg(id, 'Instance id is required.');
const url = `stack/instances/${id}/access`;
const url = `stack/instances/${id}/access` as const;
if (action === 'list') {
const data = await fetchJSON<InstanceAccessRow[]>(v, url);
const data = await v.request(url);
if (argv.json) {
printJSON(data);
return;
Expand All @@ -107,13 +110,13 @@ const handler = async (argv: CliArguments) => {
return;
}
if (action === 'add') {
if (argv.role !== undefined && argv.role !== 'user' && argv.role !== 'developer') throw new Error('Invalid access role.');
requireArg(value, 'Email address is required.');
let data;
try {
data = await fetchJSON<{ pending?: boolean }>(v, url, {
data = await v.request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: value, role: argv.role }),
body: { email: value, role: argv.role },
});
} catch (err) {
if (errorCode(err) === 'cannot-update-self') {
Expand All @@ -135,7 +138,7 @@ const handler = async (argv: CliArguments) => {
return;
}
try {
await fetchJSON<InstanceResponse>(v, `${url}/${encodeURIComponent(value)}`, { method: 'DELETE' });
await v.request(`${url}/${encodeURIComponent(value)}` as const, { method: 'DELETE' });
} catch (err) {
if (errorCode(err) === 'cannot-update-self') {
throw new Error('You cannot remove your own instance access.');
Expand All @@ -155,7 +158,7 @@ export default {
describe: 'Instance commands',
builder: (yargs: CommandBuilder) => yargs
.option('role', { choices: ['user', 'developer'], default: 'user', describe: 'Access role.' })
.option('contact', { type: 'string', describe: 'Maintenance contact.' })
.option('contact', { type: 'string', describe: 'Maintenance contact email to add.' })
.option('yes', { type: 'boolean', describe: 'Skip confirmation.' })
.option('json', { type: 'boolean', describe: 'Print JSON output.' }),
handler,
Expand Down
11 changes: 5 additions & 6 deletions lib/commands/stack/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@ import type { CliArguments, CommandBuilder, CommandDefinition } from '../../type
import chalk from 'chalk';
import inquirer from 'inquirer';
import ora from 'ora';
import { stringify } from 'querystring';
import type { BackupQuery } from '../../types/api-routes.ts';
import { getStack, streamLog } from './util.ts';
import Vantage from '../../vantage.ts';

function startBackup( vantage: Vantage, stack: string, args: Record<string, string | number> ) {
function startBackup( vantage: Vantage, stack: string, args: Pick<BackupQuery, 'database' | 'uploads'> ) {
console.log( `Starting backup for ${stack}...` );

const url = `stack/applications/${stack}/backups?${ stringify( { ...args, stream: 'true' } ) }`;
const url = `stack/applications/${stack}/backups` as const;

const opts = { method: 'POST' };
return vantage.fetch( url, opts ).then( resp => {
return vantage.requestRaw( url, { method: 'POST', query: { ...args, stream: true } } ).then( resp => {
return resp.text().then( text => {
if ( ! resp.ok ) {
throw new Error( text );
Expand All @@ -33,7 +32,7 @@ const handler = function ( argv: CliArguments ) {
return;
}

const opts = {
const opts: Pick<BackupQuery, 'database' | 'uploads'> = {
database: argv.database ? 1 : 0,
uploads: argv.uploads ? 1 : 0,
};
Expand Down
3 changes: 1 addition & 2 deletions lib/commands/stack/backups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ const handler = (argv: CliArguments) => {
const status = ora( `Loading backups for ${ stack }…` );
status.start();

v.fetch( `stack/applications/${ stack }/backups` )
.then( resp => (resp.json() as Promise<Backup[]>) )
v.request( `stack/applications/${ stack }/backups` )
.then( backups => {
status.succeed();

Expand Down
4 changes: 2 additions & 2 deletions lib/commands/stack/build-cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts';
import chalk from 'chalk';
import { getApp, fetchJSON, confirm } from './util.ts';
import { getApp, confirm } from './util.ts';
import Vantage from '../../vantage.ts';

const handler = async (argv: CliArguments) => {
Expand All @@ -13,7 +13,7 @@ const handler = async (argv: CliArguments) => {
return;
}
const v = new Vantage(argv.config);
await fetchJSON<unknown>(v, `stack/applications/${app}/builds/build-cache`, { method: 'DELETE' });
await v.request(`stack/applications/${app}/builds/build-cache` as const, { method: 'DELETE' });
console.log(chalk.green('Build cache cleared.'));
};

Expand Down
4 changes: 2 additions & 2 deletions lib/commands/stack/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { errorCode } from '../../types/errors.ts';
import type { TaskResult } from '../../types/api.ts';
import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts';
import chalk from 'chalk';
import { getApp, fetchJSON, streamLog } from './util.ts';
import { getApp, streamLog } from './util.ts';
import Vantage from '../../vantage.ts';

const handler = async (argv: CliArguments) => {
Expand All @@ -16,7 +16,7 @@ const handler = async (argv: CliArguments) => {

let data;
try {
data = await fetchJSON<TaskResult | string>(v, `stack/applications/${app}/builds?stream=true`, { method: 'POST' });
data = await v.request(`stack/applications/${app}/builds` as const, { query: { stream: true }, method: 'POST' });
} catch (err) {
if (errorCode(err) === 'not-supported') {
throw new Error('Builds are not supported for this application type.');
Expand Down
4 changes: 2 additions & 2 deletions lib/commands/stack/builds.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Build } from '../../types/api.ts';
import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts';
import { getApp, fetchJSON, printJSON, printTable } from './util.ts';
import { getApp, printJSON, printTable } from './util.ts';
import Vantage from '../../vantage.ts';

const handler = async (argv: CliArguments) => {
const app = await getApp(argv);
const v = new Vantage(argv.config);
const builds = await fetchJSON<Build[]>(v, `stack/applications/${app}/builds`);
const builds = await v.request(`stack/applications/${app}/builds` as const);
if (argv.json) {
printJSON(builds);
return;
Expand Down
4 changes: 2 additions & 2 deletions lib/commands/stack/database-tables.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts';
import { getApp, fetchJSON, printJSON, printTable } from './util.ts';
import { getApp, printJSON, printTable } from './util.ts';
import Vantage from '../../vantage.ts';

const handler = async (argv: CliArguments) => {
const app = await getApp(argv);
const v = new Vantage(argv.config);
const data = await fetchJSON<string[] | { tables: string[] }>(v, `stack/applications/${app}/database-tables`);
const data = await v.request(`stack/applications/${app}/database-tables` as const);
const tables = Array.isArray(data) ? data : (data.tables || []);
if (argv.json) {
printJSON(tables);
Expand Down
17 changes: 3 additions & 14 deletions lib/commands/stack/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,9 @@ function deploy( vantage: Vantage, stack: string, force = false, buildId: string

console.log( `Deploying ${stack}...` );

let url = `stack/applications/${stack}/deploys?stream=true`;
if ( force ) {
url += '&force=true';
}

if ( buildId ) {
url += `&build=${ buildId }`;
}

const opts = { method: 'POST' };
return vantage.fetch( url, opts ).then( resp => {
return vantage.requestRaw( `stack/applications/${stack}/deploys`, { method: 'POST', query: { stream: true, force: force || undefined, build: buildId || undefined } } ).then( resp => {
if ( ! resp.ok ) {
return (resp.json() as Promise<{ message?: string; code?: string }>).then( data => {
return resp.json().then( data => {
// If this isn't a WP error, fall back to using the text instead.
if ( ! data.message || ! data.code ) {
return resp.text().then( text => {
Expand Down Expand Up @@ -67,8 +57,7 @@ const handler = (argv: CliArguments) => {
const status = ora( `Loading builds for ${ stack }…` );
status.start();

v.fetch( `stack/applications/${ stack }/builds` )
.then( resp => (resp.json() as Promise<Build[]>) )
v.request( `stack/applications/${ stack }/builds` )
.then( builds => {
status.succeed();

Expand Down
4 changes: 2 additions & 2 deletions lib/commands/stack/deploys.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Deploy } from '../../types/api.ts';
import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts';
import { getApp, fetchJSON, printJSON, printTable } from './util.ts';
import { getApp, printJSON, printTable } from './util.ts';
import Vantage from '../../vantage.ts';

const handler = async (argv: CliArguments) => {
const app = await getApp(argv);
const v = new Vantage(argv.config);
const deploys = await fetchJSON<Deploy[]>(v, `stack/applications/${app}/deploys`);
const deploys = await v.request(`stack/applications/${app}/deploys` as const);
if (argv.json) {
printJSON(deploys);
return;
Expand Down
Loading
Loading