diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31fda95..82143d0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index 01812c3..4f9621d 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Always use `altis-cli help` for the most up-to-date list of commands. * `access add ` - Add or invite a user. Example: `altis-cli instance access add example-dev-01 user@example.com --role developer` * `access remove ` - Remove a user. Example: `altis-cli instance access remove example-dev-01 42 --yes` * `maintenance get ` - Show maintenance details. Example: `altis-cli instance maintenance get example-dev-01` - * `maintenance set ` - Update maintenance contact. Example: `altis-cli instance maintenance set example-dev-01 --contact ops@example.com` + * `maintenance set ` - Add a maintenance contact, preserving existing contacts and ignoring duplicates. Example: `altis-cli instance maintenance set example-dev-01 --contact ops@example.com` * `reports ` - List reports. Example: `altis-cli instance reports example-dev-01` ## Releasing @@ -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()` 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. diff --git a/lib/api-error.ts b/lib/api-error.ts new file mode 100644 index 0000000..5960484 --- /dev/null +++ b/lib/api-error.ts @@ -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; + } +} diff --git a/lib/authorize.ts b/lib/authorize.ts index 7bfc040..a218597 100644 --- a/lib/authorize.ts +++ b/lib/authorize.ts @@ -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; @@ -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, @@ -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 ) .then( data => { - const token = (data as { access_token: string }).access_token; + const token = data.access_token; return token; } ); } diff --git a/lib/commands/config/status.ts b/lib/commands/config/status.ts index 52294cb..b468189 100644 --- a/lib/commands/config/status.ts +++ b/lib/commands/config/status.ts @@ -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}`); diff --git a/lib/commands/instance/index.ts b/lib/commands/instance/index.ts index 05481af..c2b10c3 100644 --- a/lib/commands/instance/index.ts +++ b/lib/commands/instance/index.ts @@ -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 { @@ -16,7 +16,7 @@ const handler = async (argv: CliArguments) => { const v = new Vantage(argv.config); if (!group || group === 'list') { - const data = await fetchJSON(v, 'stack/instances'); + const data = await v.request('stack/instances'); if (argv.json) { printJSON(data); return; @@ -30,7 +30,7 @@ const handler = async (argv: CliArguments) => { if (group === 'info') { requireArg(action, 'Usage: altis-cli instance info '); - const data = await fetchJSON(v, `stack/instances/${action}`); + const data = await v.request(`stack/instances/${action}` as const); if (argv.json) { printJSON(data); return; @@ -41,7 +41,7 @@ const handler = async (argv: CliArguments) => { if (group === 'reports') { requireArg(action, 'Usage: altis-cli instance reports '); - const data = await fetchJSON(v, `stack/instances/${action}/reports`); + const data = await v.request(`stack/instances/${action}/reports` as const); if (argv.json) { printJSON(data); return; @@ -60,9 +60,9 @@ const handler = async (argv: CliArguments) => { if (group === 'maintenance') { requireArg(action, 'Usage: altis-cli instance maintenance '); 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(v, url); + const data = await v.request(url); if (argv.json) { printJSON(data); return; @@ -73,25 +73,28 @@ const handler = async (argv: CliArguments) => { if (action !== 'set') { throw new Error('Usage: altis-cli instance maintenance '); } - const data = await fetchJSON<{ pending?: boolean }>(v, url, { + const email = argv.contact?.trim(); + requireArg(email, '--contact 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 '); 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(v, url); + const data = await v.request(url); if (argv.json) { printJSON(data); return; @@ -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') { @@ -135,7 +138,7 @@ const handler = async (argv: CliArguments) => { return; } try { - await fetchJSON(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.'); @@ -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, diff --git a/lib/commands/stack/backup.ts b/lib/commands/stack/backup.ts index 5514357..37054c4 100644 --- a/lib/commands/stack/backup.ts +++ b/lib/commands/stack/backup.ts @@ -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 ) { +function startBackup( vantage: Vantage, stack: string, args: Pick ) { 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 ); @@ -33,7 +32,7 @@ const handler = function ( argv: CliArguments ) { return; } - const opts = { + const opts: Pick = { database: argv.database ? 1 : 0, uploads: argv.uploads ? 1 : 0, }; diff --git a/lib/commands/stack/backups.ts b/lib/commands/stack/backups.ts index 27f03b5..1ab4398 100644 --- a/lib/commands/stack/backups.ts +++ b/lib/commands/stack/backups.ts @@ -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) ) + v.request( `stack/applications/${ stack }/backups` ) .then( backups => { status.succeed(); diff --git a/lib/commands/stack/build-cache.ts b/lib/commands/stack/build-cache.ts index 835a091..e88cf3d 100644 --- a/lib/commands/stack/build-cache.ts +++ b/lib/commands/stack/build-cache.ts @@ -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) => { @@ -13,7 +13,7 @@ const handler = async (argv: CliArguments) => { return; } const v = new Vantage(argv.config); - await fetchJSON(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.')); }; diff --git a/lib/commands/stack/build.ts b/lib/commands/stack/build.ts index 38d4b37..53fe97c 100644 --- a/lib/commands/stack/build.ts +++ b/lib/commands/stack/build.ts @@ -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) => { @@ -16,7 +16,7 @@ const handler = async (argv: CliArguments) => { let data; try { - data = await fetchJSON(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.'); diff --git a/lib/commands/stack/builds.ts b/lib/commands/stack/builds.ts index c60fada..8eb3c47 100644 --- a/lib/commands/stack/builds.ts +++ b/lib/commands/stack/builds.ts @@ -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(v, `stack/applications/${app}/builds`); + const builds = await v.request(`stack/applications/${app}/builds` as const); if (argv.json) { printJSON(builds); return; diff --git a/lib/commands/stack/database-tables.ts b/lib/commands/stack/database-tables.ts index 407be15..35f541b 100644 --- a/lib/commands/stack/database-tables.ts +++ b/lib/commands/stack/database-tables.ts @@ -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(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); diff --git a/lib/commands/stack/deploy.ts b/lib/commands/stack/deploy.ts index 9f413c8..e446379 100644 --- a/lib/commands/stack/deploy.ts +++ b/lib/commands/stack/deploy.ts @@ -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 => { @@ -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) ) + v.request( `stack/applications/${ stack }/builds` ) .then( builds => { status.succeed(); diff --git a/lib/commands/stack/deploys.ts b/lib/commands/stack/deploys.ts index 0d828de..7382430 100644 --- a/lib/commands/stack/deploys.ts +++ b/lib/commands/stack/deploys.ts @@ -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(v, `stack/applications/${app}/deploys`); + const deploys = await v.request(`stack/applications/${app}/deploys` as const); if (argv.json) { printJSON(deploys); return; diff --git a/lib/commands/stack/domains.ts b/lib/commands/stack/domains.ts index 0f10f39..456fd61 100644 --- a/lib/commands/stack/domains.ts +++ b/lib/commands/stack/domains.ts @@ -1,7 +1,7 @@ import type { Domain } from '../../types/api.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import chalk from 'chalk'; -import { getApp, fetchJSON, confirm, printJSON, printTable } from './util.ts'; +import { getApp, confirm, printJSON, printTable } from './util.ts'; import Vantage from '../../vantage.ts'; const handler = async (argv: CliArguments) => { @@ -11,10 +11,10 @@ const handler = async (argv: CliArguments) => { } const app = await getApp(argv); const v = new Vantage(argv.config); - const base = `stack/applications/${app}/domains`; + const base = `stack/applications/${app}/domains` as const; if (action === 'list') { - const data = await fetchJSON(v, base); + const data = await v.request(base); if (argv.json) { printJSON(data); return; @@ -35,10 +35,9 @@ const handler = async (argv: CliArguments) => { } if (action === 'add') { - const data = await fetchJSON(v, base, { + const data = await v.request(base, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ domains, origin: argv.origin }), + body: { domains, origin: argv.origin }, }); if (argv.json) { printJSON(data); @@ -54,12 +53,12 @@ const handler = async (argv: CliArguments) => { console.log(chalk.yellow('Cancelled.')); return; } - await fetchJSON(v, `${base}/${encodeURIComponent(domain)}`, { method: 'DELETE' }); + await v.request(`${base}/${encodeURIComponent(domain)}` as const, { method: 'DELETE' }); console.log(chalk.green('Domain removed.')); return; } - await fetchJSON(v, `${base}/${encodeURIComponent(domain)}/retry-failed-domain`, { method: 'POST' }); + await v.request(`${base}/${encodeURIComponent(domain)}/retry-failed-domain` as const, { method: 'POST' }); console.log(chalk.green('Domain retry requested.')); }; diff --git a/lib/commands/stack/import.ts b/lib/commands/stack/import.ts index a9b1de7..9442569 100644 --- a/lib/commands/stack/import.ts +++ b/lib/commands/stack/import.ts @@ -1,8 +1,9 @@ +import type { DatabaseImportBody, UploadsImportBody } from '../../types/api-routes.ts'; import type { ImportSession, TaskResult } from '../../types/api.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import chalk from 'chalk'; import inquirer from 'inquirer'; -import { fetchJSON, parseList, parseReplacements, streamLog } from './util.ts'; +import { parseList, parseReplacements, streamLog } from './util.ts'; import Vantage from '../../vantage.ts'; const handler = async (argv: CliArguments) => { @@ -23,7 +24,7 @@ const handler = async (argv: CliArguments) => { } // Offer to resume an existing session if one exists. - const sessions = await fetchJSON(v, `stack/applications/${target}/${route}`).catch(() => []); + const sessions = await v.request(`stack/applications/${target}/${route}` as const).catch(() => []); if (Array.isArray(sessions) && sessions.length > 0) { const { choice } = await inquirer.prompt({ type: 'list', @@ -42,36 +43,22 @@ const handler = async (argv: CliArguments) => { return; } } - const body: Record = { - from_application: argv.from, - }; + let data; if (type === 'database') { - if (argv.tables) { - body.tables = parseList(argv.tables); - } - if (argv.replace) { - body.replacements = parseReplacements(argv.replace); - } - if (argv.useMydumper) { - body.use_mydumper = true; - } - if (argv.postSync) { - body.post_sync = true; - } + const body: DatabaseImportBody = { + from_application: argv.from!, + tables: argv.tables ? parseList(argv.tables) : undefined, + replacements: argv.replace ? parseReplacements(argv.replace) : undefined, + use_mydumper: argv.useMydumper || undefined, + post_sync: argv.postSync || undefined, + }; + data = await v.request(`stack/applications/${target}/import-database`, { method: 'POST', query: { stream: true }, body }); } else { - if (argv.uploadsPath) { - body.uploads_path = argv.uploadsPath; - } - if (argv.method) { - body.method = argv.method; - } + if (argv.method !== undefined && argv.method !== 'diff' && argv.method !== 'legacy') throw new Error('Invalid import method.'); + const body: UploadsImportBody = { from_application: argv.from!, path: argv.uploadsPath, method: argv.method }; + data = await v.request(`stack/applications/${target}/import-uploads`, { method: 'POST', query: { stream: true }, body }); } - const data = await fetchJSON(v, `stack/applications/${target}/${route}?stream=true`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); const log = typeof data === 'string' ? data : data.log || data.task_id || ''; console.log(chalk.yellow(`Import started, resume later with:`)); console.log(chalk.yellow(` altis-cli app import ${type} ${target} --resume ${log}\n`)); diff --git a/lib/commands/stack/info.ts b/lib/commands/stack/info.ts index 81b5229..dece74d 100644 --- a/lib/commands/stack/info.ts +++ b/lib/commands/stack/info.ts @@ -47,8 +47,7 @@ const handler = function ( argv: CliArguments ) { const status = ora( `Fetching information for ${stack}` ); status.start(); - v.fetch( `stack/applications/${ stack }?_embed` ) - .then( resp => (resp.json() as Promise) ) + v.request( `stack/applications/${ stack }`, { query: { _embed: true } } ) .then( data => { status.stop(); diff --git a/lib/commands/stack/ip-list.ts b/lib/commands/stack/ip-list.ts index d7ba6b6..0193fe8 100644 --- a/lib/commands/stack/ip-list.ts +++ b/lib/commands/stack/ip-list.ts @@ -2,7 +2,7 @@ import { errorCode } from '../../types/errors.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import fs from 'fs'; import chalk from 'chalk'; -import { getApp, fetchJSON, confirm, printJSON, printTable } from './util.ts'; +import { getApp, confirm, printJSON, printTable } from './util.ts'; import Vantage from '../../vantage.ts'; function readValues(file: string | undefined, inline: string | (string | number)[] = []) { @@ -25,16 +25,16 @@ const handler = async (argv: CliArguments) => { if (!['get', 'set'].includes(action)) { throw new Error('Usage: altis-cli app ip-list [app] --type allow|deny'); } - if (!argv.type || !['allow', 'deny'].includes(argv.type)) { + if (!argv.type || (argv.type !== 'allow' && argv.type !== 'deny')) { throw new Error('--type must be "allow" or "deny".'); } const app = await getApp(argv); const v = new Vantage(argv.config); - const url = `stack/applications/${app}/iplists/${argv.type}`; + const url = `stack/applications/${app}/iplists/${argv.type}` as const; const fetchIPList = async () => { try { - return await fetchJSON>(v, url); + return await v.request(url); } catch (err) { if (errorCode(err) === 'feature_disabled') { throw new Error('IP management is not enabled for this application.'); @@ -49,23 +49,22 @@ const handler = async (argv: CliArguments) => { printJSON(data); return; } - const values = data.ip_addresses || data; + const values = data; printTable(values.map(ip => ({ ip }))); return; } const currentData = await fetchIPList(); - const current = currentData.ip_addresses || currentData || []; + const current = currentData; const next = readValues(argv.file, argv.ip); printDiff(current, next); if (!argv.yes && !await confirm(`Replace ${argv.type} IP list for ${app}?`)) { console.log(chalk.yellow('Cancelled.')); return; } - const data = await fetchJSON>(v, url, { + const data = await v.request(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ip_addresses: next }), + body: { ip_addresses: next }, }); if (argv.json) { printJSON(data); diff --git a/lib/commands/stack/list.ts b/lib/commands/stack/list.ts index 439ac94..5167e14 100644 --- a/lib/commands/stack/list.ts +++ b/lib/commands/stack/list.ts @@ -11,7 +11,7 @@ const handler = function ( argv: CliArguments ) { const { config } = argv; const v = new Vantage( config ); - const vPromise = v.fetch( 'stack/applications' ).then( resp => (resp.json() as Promise) ); + const vPromise = v.request( 'stack/applications' ); const status = ora( 'Fetching applications…' ); status.start(); diff --git a/lib/commands/stack/local-setup.ts b/lib/commands/stack/local-setup.ts index 97c93f5..a08275f 100644 --- a/lib/commands/stack/local-setup.ts +++ b/lib/commands/stack/local-setup.ts @@ -15,8 +15,7 @@ const handler = (argv: CliArguments) => { const v = new Vantage(argv.config); const status = ora(`Loading repository for ${stack}…`); status.start(); - v.fetch(`stack/applications/${stack}`) - .then(resp => (resp.json() as Promise)) + v.request(`stack/applications/${stack}` as const) .then((repo: ApplicationResponse) => { status.succeed(); const url = repo['git-deployment']['url']; diff --git a/lib/commands/stack/logs.ts b/lib/commands/stack/logs.ts index cd9b68e..0251262 100644 --- a/lib/commands/stack/logs.ts +++ b/lib/commands/stack/logs.ts @@ -2,10 +2,10 @@ import { tailStream } from '../../log-tail.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import fs from 'fs'; import chalk from 'chalk'; -import { getApp, buildQuery, parseKeyValueArgs, printJSON } from './util.ts'; +import { getApp, parseKeyValueArgs, printJSON } from './util.ts'; import Vantage from '../../vantage.ts'; -const LOG_ROUTES: Record = { +const LOG_ROUTES = { php: 'logs/php', nginx: 'logs/nginx', access: 'logs/access', @@ -19,7 +19,7 @@ const LOG_ROUTES: Record = { 'es-index': 'logs/elasticsearch/index', 'es-error': 'logs/elasticsearch/error', nodejs: 'nodejs/logs/nodejs', -}; +} as const; const STREAM_LOGS = new Set(['php', 'nginx', 'nodejs']); function printLogs(logs: unknown) { @@ -65,7 +65,7 @@ const handler = async (argv: CliArguments) => { const v = new Vantage(argv.config); const type = argv.type || ''; - if (!LOG_ROUTES[type]) { + if (!(type in LOG_ROUTES)) { throw new Error(`Unsupported log type "${type}". Supported types: ${Object.keys(LOG_ROUTES).join(', ')}`); } if (argv.tail) { @@ -85,22 +85,21 @@ const handler = async (argv: CliArguments) => { throw new Error('--status is only supported for cron and email logs.'); } - const params = { - after: argv.after, - before: argv.before, - filterPattern: argv.search, - next_token: argv.nextToken, - format: argv.format, - download: argv.output ? true : undefined, - filter: argv.filter ? parseKeyValueArgs(argv.filter) : undefined, - status: argv.status, - }; - const url = `stack/applications/${app}/${LOG_ROUTES[type]}${buildQuery(params)}`; + const range = { after: argv.after, before: argv.before }; + const params = type === 'access' + ? { ...range, format: argv.format as 'json' | 'csv' | undefined, download: argv.output ? true : undefined, filter: argv.filter ? parseKeyValueArgs(argv.filter) : undefined } + : type === 'cron' || type === 'email' + ? { ...range, search: argv.search, status: argv.status as ('fail' | 'success')[] | undefined } + : STREAM_LOGS.has(type) + ? { ...range, search: argv.search, next_token: argv.nextToken } + : { ...range, search: argv.search }; + + const url = `stack/applications/${app}/${LOG_ROUTES[type as keyof typeof LOG_ROUTES]}` as const; if (type === 'access') { - const resp = await v.fetch(url); + const resp = await v.requestRaw(url, { query: params }); if (!resp.ok) { - const data = await (resp.json().catch(() => ({})) as Promise<{ message?: string }>); + const data = await resp.json().catch(() => ({ message: undefined })); throw new Error(data.message || resp.statusText); } if (argv.output) { @@ -124,9 +123,9 @@ const handler = async (argv: CliArguments) => { return; } - const resp = await v.fetch(url); + const resp = await v.requestRaw(url, { query: params }); if (!resp.ok) { - const errData = await (resp.json().catch(() => ({})) as Promise<{ message?: string }>); + const errData = await resp.json().catch(() => ({ message: undefined })); throw new Error(errData.message || resp.statusText); } const data = await resp.json(); diff --git a/lib/commands/stack/packages.ts b/lib/commands/stack/packages.ts index 29affa9..5e1e225 100644 --- a/lib/commands/stack/packages.ts +++ b/lib/commands/stack/packages.ts @@ -1,7 +1,7 @@ import { errorStatus } from '../../types/errors.ts'; import type { Package } 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) => { @@ -10,7 +10,8 @@ const handler = async (argv: CliArguments) => { const suffix = argv.build ? `/${argv.build}` : ''; let packages; try { - packages = await fetchJSON(v, `stack/applications/${app}/packages${suffix}`); + const url = argv.build ? `stack/applications/${app}/packages/${argv.build}` as const : `stack/applications/${app}/packages` as const; + packages = await v.request(url); } catch (err) { if (errorStatus(err) === 404) { throw new Error('No active build found for this application.'); diff --git a/lib/commands/stack/php-logs.ts b/lib/commands/stack/php-logs.ts index 57b4537..ab565c7 100644 --- a/lib/commands/stack/php-logs.ts +++ b/lib/commands/stack/php-logs.ts @@ -3,7 +3,6 @@ import type { LogResponse } from '../../types/api.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import chalk from 'chalk'; import ora from 'ora'; -import { stringify } from 'querystring'; import Vantage from '../../vantage.ts'; const handler = function (argv: CliArguments) { @@ -38,16 +37,15 @@ const handler = function (argv: CliArguments) { if (argv.after) { args.after = argv.after; } - const query = stringify(args); - const url = `stack/applications/${stack}/logs/php${query ? `?${query}` : ''}`; - v.fetch(url).then(resp => { + const url = `stack/applications/${stack}/logs/php` as const; + v.requestRaw(url, { query: args }).then(resp => { // hm-stack currently returns a 500 for invalid stacks, so we have to // assume any failure is a 404: https://github.com/humanmade/hm-stack/issues/367 if (!resp.ok) { status.fail(`Invalid stack ${stack}`); throw new Error(`Invalid stack ${stack}`); } - return (resp.json() as Promise); + return (resp.json()); }).then(logs => { status.succeed(chalk.bold.green('Complete!')); logs.map(log => console.log(log.message)); diff --git a/lib/commands/stack/scp.ts b/lib/commands/stack/scp.ts index b68f003..abdee0d 100644 --- a/lib/commands/stack/scp.ts +++ b/lib/commands/stack/scp.ts @@ -397,8 +397,8 @@ const handler = function ( argv: CliArguments ) { const status = ora( `Connecting to ${stack}` ); status.start(); - const url = `stack/applications/${stack}/cli/sessions`; - v.fetch( url, { method: 'POST' } ).then( async resp => { + const url = `stack/applications/${stack}/cli/sessions` as const; + v.requestRaw( url, { method: 'POST' } ).then( async resp => { // hm-stack currently returns a 500 for invalid stacks, so we have to // assume any failure is a 404: https://github.com/humanmade/hm-stack/issues/367 if ( ! resp.ok ) { @@ -407,7 +407,7 @@ const handler = function ( argv: CliArguments ) { } try { - const data = await (resp.json() as Promise); + const data = await (resp.json()); const conn = new Connection( data, { logLevel: (argv.verbose || 0) > 0 ? 3 - (argv.verbose || 0) : LEVELS.ERROR, } ); diff --git a/lib/commands/stack/sites.ts b/lib/commands/stack/sites.ts index baf438d..87aacce 100644 --- a/lib/commands/stack/sites.ts +++ b/lib/commands/stack/sites.ts @@ -1,12 +1,12 @@ import type { DatabaseTables } 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 data = await fetchJSON( v, `stack/applications/${ app }/database-tables` ); + const data = await v.request(`stack/applications/${ app }/database-tables` as const ); const sites = data.sites || []; if ( argv.json ) { printJSON( sites ); diff --git a/lib/commands/stack/ssh.ts b/lib/commands/stack/ssh.ts index 4ad57b9..b5e3295 100644 --- a/lib/commands/stack/ssh.ts +++ b/lib/commands/stack/ssh.ts @@ -168,8 +168,8 @@ const handler = function ( argv: CliArguments ) { const status = ora( `Starting session on ${stack}` ); status.start(); - const url = `stack/applications/${stack}/cli/sessions`; - v.fetch( url, { method: 'POST' } ).then( resp => { + const url = `stack/applications/${stack}/cli/sessions` as const; + v.requestRaw( url, { method: 'POST' } ).then( resp => { // hm-stack currently returns a 500 for invalid stacks, so we have to // assume any failure is a 404: https://github.com/humanmade/hm-stack/issues/367 if ( ! resp.ok ) { @@ -177,7 +177,7 @@ const handler = function ( argv: CliArguments ) { return; } - return (resp.json() as Promise).then( data => { + return (resp.json()).then( data => { status.stop(); connect( data ); }); diff --git a/lib/commands/stack/sync-local/all.ts b/lib/commands/stack/sync-local/all.ts index d861a38..753f89f 100644 --- a/lib/commands/stack/sync-local/all.ts +++ b/lib/commands/stack/sync-local/all.ts @@ -1,3 +1,4 @@ +import type { BackupQuery } from '../../../types/api-routes.ts'; import { errorMessage } from '../../../types/errors.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../../types/cli.ts'; import chalk from 'chalk'; @@ -180,7 +181,7 @@ const handler = async function ( argv: CliArguments ) { resolvedUploadsPath = `sites/${ siteIds[0] }`; } } - const opts = { + const opts: Omit = { database: 1, uploads: 1, ...( tableList.length ? { tables: tableList } : {} ), diff --git a/lib/commands/stack/sync-local/database.ts b/lib/commands/stack/sync-local/database.ts index 9f3255f..08c7c69 100644 --- a/lib/commands/stack/sync-local/database.ts +++ b/lib/commands/stack/sync-local/database.ts @@ -1,3 +1,4 @@ +import type { BackupQuery } from '../../../types/api-routes.ts'; import { errorMessage } from '../../../types/errors.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../../types/cli.ts'; import chalk from 'chalk'; @@ -160,7 +161,7 @@ const handler = async function ( argv: CliArguments ) { const siteTables = await resolveTablesForSiteIds( v, app, siteIdList ); tableList = [ ...new Set( [ ...tableList, ...siteTables ] ) ]; } - const opts = { + const opts: Omit = { database: 1, uploads: 0, ...( tableList.length ? { tables: tableList } : {} ), diff --git a/lib/commands/stack/sync-local/uploads.ts b/lib/commands/stack/sync-local/uploads.ts index 0f2147c..72028ca 100644 --- a/lib/commands/stack/sync-local/uploads.ts +++ b/lib/commands/stack/sync-local/uploads.ts @@ -1,3 +1,4 @@ +import type { BackupQuery } from '../../../types/api-routes.ts'; import { errorMessage } from '../../../types/errors.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../../types/cli.ts'; import chalk from 'chalk'; @@ -108,7 +109,7 @@ const handler = async function ( argv: CliArguments ) { // Create backup if needed if ( ! backup && ! logId ) { const backupSpinner = ora( `Creating remote uploads backup for ${ chalk.bold( app ) }…` ).start(); - const opts = { + const opts: Omit = { database: 0, uploads: 1, ...( resolvedUploadsPath ? { uploads_path: resolvedUploadsPath } : {} ), diff --git a/lib/commands/stack/sync-local/util.ts b/lib/commands/stack/sync-local/util.ts index 1a46076..93a74fc 100644 --- a/lib/commands/stack/sync-local/util.ts +++ b/lib/commands/stack/sync-local/util.ts @@ -12,7 +12,7 @@ import ora from 'ora'; import path from 'path'; import progressStream from 'progress-stream'; import { pipeline } from 'stream/promises'; -import { stringify } from 'querystring'; +import type { BackupQuery } from '../../../types/api-routes.ts'; import { createGunzip } from 'zlib'; import { createRequire } from 'module'; import { streamLog } from '../util.ts'; @@ -71,10 +71,10 @@ export async function confirm( message: string ) { // --- Remote backup creation --- -export async function startBackup( v: Vantage, stack: string, opts: Record ) { - const url = `stack/applications/${ stack }/backups?${ stringify( { ...opts, stream: 'true' } ) }`; +export async function startBackup( v: Vantage, stack: string, opts: Omit ) { + const url = `stack/applications/${ stack }/backups` as const; - const resp = await v.fetch( url, { method: 'POST' } ); + const resp = await v.requestRaw( url, { method: 'POST', query: { ...opts, stream: true } } ); const text = await resp.text(); if ( ! resp.ok ) { throw new Error( `Backup request failed: ${ text }` ); @@ -85,11 +85,11 @@ export async function startBackup( v: Vantage, stack: string, opts: Record new Date(b.date).getTime() >= startTime.getTime() ) .sort( ( a, b ) => new Date(b.date).getTime() - new Date(a.date).getTime() ); @@ -100,9 +100,9 @@ export async function findCompletedBackup( v: Vantage, stack: string, startTime: // --- Get latest existing backup --- export async function getLatestBackup( v: Vantage, stack: string ) { - const resp = await v.fetch( `stack/applications/${ stack }/backups` ); + const resp = await v.requestRaw( `stack/applications/${ stack }/backups` ); if ( ! resp.ok ) return null; - const backups = await resp.json() as Backup[]; + const backups = await resp.json(); if ( ! backups.length ) return null; return backups.sort( ( a, b ) => new Date(b.date).getTime() - new Date(a.date).getTime() )[ 0 ]; } @@ -307,11 +307,11 @@ export async function runPostSync( localPath: string ) { // --- Resolve table names for multisite site IDs --- export async function resolveTablesForSiteIds( v: Vantage, app: string, siteIds: string[] ) { - const resp = await v.fetch( `stack/applications/${ app }/database-tables` ); + const resp = await v.requestRaw( `stack/applications/${ app }/database-tables` ); if ( ! resp.ok ) { throw new Error( `Failed to fetch table list: ${ resp.status }` ); } - const data = await resp.json() as string[] | { tables: string[] }; + const data = await resp.json(); const tables = Array.isArray( data ) ? data : ( data.tables || [] ); // Derive the table prefix from the blogs table (e.g. "wp_blogs" → prefix "wp_") diff --git a/lib/commands/stack/tasks.ts b/lib/commands/stack/tasks.ts index 1305c3a..8193820 100644 --- a/lib/commands/stack/tasks.ts +++ b/lib/commands/stack/tasks.ts @@ -1,7 +1,7 @@ import type { RunningTasksResponse } from '../../types/api.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import chalk from 'chalk'; -import { fetchJSON, confirm, printJSON, printTable, streamLog } from './util.ts'; +import { confirm, printJSON, printTable, streamLog } from './util.ts'; import Vantage from '../../vantage.ts'; const handler = async (argv: CliArguments) => { @@ -15,7 +15,7 @@ const handler = async (argv: CliArguments) => { console.log(chalk.yellow('Cancelled.')); return; } - const data = await fetchJSON(v, `stack/running-tasks/${encodeURIComponent(argv.id)}`, { method: 'DELETE' }); + const data = await v.request(`stack/running-tasks/${encodeURIComponent(argv.id)}` as const, { method: 'DELETE' }); if (argv.json) { printJSON(data); return; @@ -33,7 +33,7 @@ const handler = async (argv: CliArguments) => { return; } - const tasks = await fetchJSON(v, 'stack/running-tasks'); + const tasks = await v.request('stack/running-tasks'); const filtered = argv.action ? tasks.filter(task => task.application === argv.action) : tasks; if (argv.json) { printJSON(filtered); diff --git a/lib/commands/stack/ua-blocklist.ts b/lib/commands/stack/ua-blocklist.ts index 85cd9f6..3e7ccc0 100644 --- a/lib/commands/stack/ua-blocklist.ts +++ b/lib/commands/stack/ua-blocklist.ts @@ -2,7 +2,7 @@ import { errorCode } from '../../types/errors.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import fs from 'fs'; import chalk from 'chalk'; -import { getApp, fetchJSON, confirm, printJSON, printTable } from './util.ts'; +import { getApp, confirm, printJSON, printTable } from './util.ts'; import Vantage from '../../vantage.ts'; function readValues(file: string | undefined, inline: string | (string | number)[] = []) { @@ -27,11 +27,11 @@ const handler = async (argv: CliArguments) => { } const app = await getApp(argv); const v = new Vantage(argv.config); - const url = `stack/applications/${app}/ua-blocklist`; + const url = `stack/applications/${app}/ua-blocklist` as const; const fetchUABlocklist = async () => { try { - return await fetchJSON(v, url); + return await v.request(url); } catch (err) { if (errorCode(err) === 'feature_disabled') { throw new Error('User-agent blocking is not enabled for this application.'); @@ -46,23 +46,22 @@ const handler = async (argv: CliArguments) => { printJSON(data); return; } - const values = Array.isArray(data) ? data : data.patterns; + const values = data; printTable(values.map(pattern => ({ pattern }))); return; } const currentData = await fetchUABlocklist(); - const current = Array.isArray(currentData) ? currentData : currentData.patterns || []; + const current = currentData; const next = readValues(argv.file, argv.pattern); printDiff(current, next); if (!argv.yes && !await confirm(`Replace user-agent blocklist for ${app}?`)) { console.log(chalk.yellow('Cancelled.')); return; } - const data = await fetchJSON(v, url, { + const data = await v.request(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ patterns: next }), + body: { patterns: next }, }); if (argv.json) { printJSON(data); diff --git a/lib/commands/stack/uploads-prefixes.ts b/lib/commands/stack/uploads-prefixes.ts index 9eafa72..cd3311e 100644 --- a/lib/commands/stack/uploads-prefixes.ts +++ b/lib/commands/stack/uploads-prefixes.ts @@ -1,12 +1,12 @@ 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 | { prefix: string })[] | { prefixes?: (string | { prefix: string })[]; uploads_prefixes?: (string | { prefix: string })[] }>(v, `stack/applications/${app}/uploads-prefixes`); - const prefixes = Array.isArray(data) ? data : (data.prefixes || data.uploads_prefixes || []); + const data = await v.request(`stack/applications/${app}/uploads-prefixes` as const); + const prefixes = data.prefixes; if (argv.json) { printJSON(prefixes); return; diff --git a/lib/commands/stack/util.ts b/lib/commands/stack/util.ts index e5d8dfe..fd81cb3 100644 --- a/lib/commands/stack/util.ts +++ b/lib/commands/stack/util.ts @@ -1,5 +1,4 @@ import type { Options as TableOptions } from 'columnify'; -import type { RequestInit } from 'node-fetch'; import type { CliArguments, CommandBuilder } from '../../types/cli.ts'; import chalk from 'chalk'; import columnify from 'columnify'; @@ -7,15 +6,7 @@ import inquirer from 'inquirer'; import ora from 'ora'; import Vantage, { getStacks } from '../../vantage.ts'; -export class ApiError extends Error { - status: number; - code?: string; - constructor(message: string, status: number, code?: string) { - super(message); - this.status = status; - this.code = code; - } -} +export { ApiError } from '../../api-error.ts'; interface LogStatus { spinner: ReturnType; step: string; @@ -52,28 +43,6 @@ export function getApp(argv: CliArguments) { return getStack(argv); } -export async function fetchJSON(vantage: Vantage, url: string, opts: RequestInit = {}): Promise { - const resp = await vantage.fetch(url, opts); - let data: unknown = null; - const text = await resp.text(); - if (text) { - try { - data = JSON.parse(text); - } catch (e) { - data = text; - } - } - - if (!resp.ok) { - const errorData = data && typeof data === 'object' ? data as { message?: unknown; code?: unknown } : {}; - const message = typeof errorData.message === 'string' ? errorData.message : text || resp.statusText; - const err = new ApiError(message, resp.status, typeof errorData.code === 'string' ? errorData.code : undefined); - throw err; - } - - return data as T; -} - export function buildQuery(params: Record = {}) { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { diff --git a/lib/commands/stack/variables.ts b/lib/commands/stack/variables.ts index 7be800e..ebb8bf3 100644 --- a/lib/commands/stack/variables.ts +++ b/lib/commands/stack/variables.ts @@ -3,7 +3,7 @@ import type { Variable } from '../../types/api.ts'; import type { CliArguments, CommandBuilder, CommandDefinition } from '../../types/cli.ts'; import fs from 'fs'; import chalk from 'chalk'; -import { getApp, fetchJSON, confirm, printJSON, printTable } from './util.ts'; +import { getApp, confirm, printJSON, printTable } from './util.ts'; import Vantage from '../../vantage.ts'; function readStdin() { @@ -15,15 +15,15 @@ const handler = async (argv: CliArguments) => { if (!['list', 'get', 'set', 'delete'].includes(action)) { throw new Error('Usage: altis-cli app variables [app] --type app|build'); } - if (!argv.type || !['app', 'build'].includes(argv.type)) { + if (!argv.type || (argv.type !== 'app' && argv.type !== 'build')) { throw new Error('--type must be "app" or "build".'); } const app = await getApp(argv); const v = new Vantage(argv.config); - const base = `stack/applications/${app}/variables/${argv.type}`; + const base = `stack/applications/${app}/variables/${argv.type}` as const; if (action === 'list') { - const data = await fetchJSON(v, base); + const data = await v.request(base); if (argv.json) { printJSON(data); return; @@ -42,7 +42,7 @@ const handler = async (argv: CliArguments) => { } if (action === 'get') { - const data = await fetchJSON(v, `${base}/${encodeURIComponent(argv.name)}`); + const data = await v.request(`${base}/${encodeURIComponent(argv.name)}` as const); if (argv.json) { printJSON(data); return; @@ -56,7 +56,7 @@ const handler = async (argv: CliArguments) => { console.log(chalk.yellow('Cancelled.')); return; } - await fetchJSON(v, `${base}/${encodeURIComponent(argv.name)}`, { method: 'DELETE' }); + await v.request(`${base}/${encodeURIComponent(argv.name)}` as const, { method: 'DELETE' }); console.log(chalk.green('Variable deleted.')); return; } @@ -75,7 +75,7 @@ const handler = async (argv: CliArguments) => { }; let exists = false; try { - await fetchJSON(v, `${base}/${encodeURIComponent(argv.name)}`); + await v.request(`${base}/${encodeURIComponent(argv.name)}` as const); exists = true; } catch (err) { if (errorStatus(err) !== 404) { @@ -85,11 +85,9 @@ const handler = async (argv: CliArguments) => { if (exists && argv.noSecret) { throw new Error('Secret status cannot be changed for an existing variable.'); } - const data = await fetchJSON(v, exists ? `${base}/${encodeURIComponent(argv.name)}` : base, { - method: exists ? 'PUT' : 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(exists ? { value } : body), - }); + const data = exists + ? await v.request(`${base}/${encodeURIComponent(argv.name)}` as const, { method: 'PUT', body: { value } }) + : await v.request(base, { method: 'POST', body }); if (argv.json) { printJSON(data); return; diff --git a/lib/commands/stack/xray.ts b/lib/commands/stack/xray.ts index 0ba718e..0490023 100644 --- a/lib/commands/stack/xray.ts +++ b/lib/commands/stack/xray.ts @@ -11,7 +11,7 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import logUpdate from 'log-update'; import ora from 'ora'; -import { getApp, fetchJSON, buildQuery, printJSON, printTable } from './util.ts'; +import { getApp, printJSON, printTable } from './util.ts'; import Vantage from '../../vantage.ts'; const SPARK = '▁▂▃▄▅▆▇█'; @@ -37,7 +37,7 @@ async function fetchNetworkSlug(v: Vantage, app: string, config: Config) { if (cached && Date.now() < cached.ts + APP_INFO_TTL) return cached.slug; } try { - const data = await fetchJSON(v, `stack/applications/${app}`); + const data = await v.request(`stack/applications/${app}` as const); const slug = data['altis-instance'] || null; if (config && config.cache) { config.cache.set(fileKey, { ts: Date.now(), slug }); @@ -52,7 +52,7 @@ async function fetchStatsWithCache(v: Vantage, app: string, after: string, befor const key = `${app}:${after}:${before}`; const cached = statsCache.get(key); if (cached && Date.now() < cached.ts + STATS_TTL) return cached.data; - const data = await fetchJSON(v, `stack/applications/${app}/xray/statistics${buildQuery({ after, before })}`); + const data = await v.request(`stack/applications/${app}/xray/statistics` as const, { query: { after, before } }); statsCache.set(key, { ts: Date.now(), data }); return data; } @@ -61,7 +61,7 @@ async function fetchGraphWithCache(v: Vantage, app: string, after: string, befor const key = `${app}:${after}:${before}`; const cached = graphCache.get(key); if (cached && Date.now() < cached.ts + GRAPH_TTL) return cached.data; - const data = await fetchJSON(v, `stack/applications/${app}/xray/service-graph${buildQuery({ after, before })}`); + const data = await v.request(`stack/applications/${app}/xray/service-graph` as const, { query: { after, before } }); graphCache.set(key, { ts: Date.now(), data }); return data; } @@ -69,7 +69,7 @@ async function fetchGraphWithCache(v: Vantage, app: string, after: string, befor async function fetchTraceDetail(v: Vantage, app: string, traceId: string) { const key = `${app}:${traceId}`; if (traceDetailCache.has(key)) return traceDetailCache.get(key)!; - const data = await fetchJSON(v, `stack/applications/${app}/xray/traces/${encodeURIComponent(traceId)}`); + const data = await v.request(`stack/applications/${app}/xray/traces/${encodeURIComponent(traceId)}` as const); traceDetailCache.set(key, data); return data; } @@ -610,13 +610,13 @@ async function fetchSummaries(v: Vantage, app: string, argv: CliArguments) { filter: filter || undefined, previous_token: argv.nextToken || undefined, }; - const url = `stack/applications/${app}/xray/traces/summaries${buildQuery(params)}`; - const resp = await v.fetch(url); + const url = `stack/applications/${app}/xray/traces/summaries` as const; + const resp = await v.requestRaw(url, { query: params }); if (!resp.ok) { - const data = await resp.json().catch(() => ({})) as { message?: string }; + const data = await resp.json().catch(() => ({ message: undefined })); throw new Error(data.message || resp.statusText); } - const traces = await resp.json() as TraceSummary[]; + const traces = await resp.json(); const nextToken = extractNextToken(resp.headers); return { traces, nextToken }; } diff --git a/lib/types/api-routes.ts b/lib/types/api-routes.ts new file mode 100644 index 0000000..b6f52a0 --- /dev/null +++ b/lib/types/api-routes.ts @@ -0,0 +1,90 @@ +import type { RequestInit, Response } from 'node-fetch'; +import type { ApplicationInfo, ApplicationResponse, Backup, Build, CliSession, DatabaseTables, Deploy, Domain, ImportSession, InstanceAccessRow, InstanceAccessResponse, InstanceMaintenanceResponse, InstanceReport, InstanceResponse, LogResponse, Package, RunningTasksResponse, TaskResult, UserResponse, Variable } from './api.ts'; +import type { ServiceGraph, Statistic, Trace, TraceSummary } from './xray.ts'; + +type Read = { GET: { response: R; query: Q } }; +type Write = { response: R; body: B; query: Q }; +type Task = string | TaskResult; +export interface BackupQuery { database?: boolean | 0 | 1; uploads?: boolean | 0 | 1; stream?: boolean; tables?: string[]; mydumper?: boolean; site_id?: number; uploads_path?: string; stream_json?: boolean; } +export interface DatabaseImportBody { from_application: string; tables?: string[]; replacements?: Record; use_mydumper?: boolean; post_sync?: boolean; } +export interface UploadsImportBody { from_application: string; path?: string; method?: 'diff' | 'legacy'; } +interface LogQuery { after?: string; before?: string; search?: string; } +interface PaginatedLogQuery extends LogQuery { next_token?: string; stream?: boolean; } +interface StatusLogQuery extends LogQuery { status?: ('fail' | 'success')[]; } +interface AccessLogQuery { after?: string; before?: string; format?: 'json' | 'csv'; download?: boolean; filter?: Record; } +interface TimeQuery { after?: string; before?: string; } + +/** Backend contracts for every REST operation currently used by the CLI. */ +export interface ApiRoutes { + 'stack/applications': Read; + 'stack/applications/:app': Read; + 'stack/applications/:app/backups': Read & { POST: Write }; + 'stack/applications/:app/builds': Read & { POST: Write }; + 'stack/applications/:app/builds/build-cache': { DELETE: Write }; + 'stack/applications/:app/deploys': Read & { POST: Write }; + 'stack/applications/:app/import-database': Read & { POST: Write }; + 'stack/applications/:app/import-uploads': Read & { POST: Write }; + 'stack/applications/:app/variables/:variableType': Read & { POST: Write }; + 'stack/applications/:app/variables/:variableType/:name': Read & { PUT: Write; DELETE: Write }; + 'stack/applications/:app/domains': Read & { POST: Write }; + 'stack/applications/:app/domains/:domain': { DELETE: Write }; + 'stack/applications/:app/domains/:domain/retry-failed-domain': { POST: Write }; + 'stack/applications/:app/iplists/:ipType': Read & { POST: Write }; + 'stack/applications/:app/ua-blocklist': Read & { POST: Write }; + 'stack/applications/:app/database-tables': Read; + 'stack/applications/:app/uploads-prefixes': Read<{ prefixes: string[] }>; + 'stack/applications/:app/packages': Read; + 'stack/applications/:app/packages/:build': Read; + 'stack/applications/:app/cli/sessions': { POST: Write }; + 'stack/applications/:app/logs/:logType': Read; + 'stack/applications/:app/logs/php': Read; + 'stack/applications/:app/logs/nginx': Read; + 'stack/applications/:app/logs/cron': Read; + 'stack/applications/:app/logs/email': Read; + 'stack/applications/:app/logs/access': Read; + 'stack/applications/:app/logs/elasticsearch/:esType': Read; + 'stack/applications/:app/nodejs/logs/nodejs': Read; + 'stack/applications/:app/xray/statistics': Read; + 'stack/applications/:app/xray/service-graph': Read; + 'stack/applications/:app/xray/traces/summaries': Read; + 'stack/applications/:app/xray/traces/:trace': Read; + 'stack/instances': Read; + 'stack/instances/:instance': Read; + 'stack/instances/:instance/reports': Read; + 'stack/instances/:instance/maintenance': Read & { PATCH: Write> }; + 'stack/instances/:instance/access': Read & { POST: Write<{ pending?: boolean }, { email: string; role?: 'user' | 'developer' | 'admin' }> }; + 'stack/instances/:instance/access/:user': { DELETE: Write }; + 'stack/running-tasks': Read; + 'stack/running-tasks/:task': { DELETE: Write }; + 'wp/v2/users/me': Read; +} + +type MatchSegment

= R extends ':variableType' ? P extends 'app' | 'build' ? true : false + : R extends ':ipType' ? P extends 'allow' | 'deny' ? true : false + : R extends ':logType' ? P extends 'php' | 'nginx' | 'access' | 'cron' | 'raw-cron' | 'email' | 'db-general' | 'db-error' | 'db-slowquery' ? true : false + : R extends ':esType' ? P extends 'search' | 'index' | 'error' ? true : false + : R extends `:${string}` ? P extends '' ? false : true : P extends R ? true : false; +type MatchPath

= P extends `${infer H}/${infer T}` + ? R extends `${infer RH}/${infer RT}` ? MatchSegment extends true ? MatchPath : false : false + : R extends `${string}/${string}` ? false : MatchSegment; +type Candidates

= P extends unknown ? { [R in keyof ApiRoutes]: MatchPath extends true ? R : never }[keyof ApiRoutes] : never; +type Pattern = R extends `${infer H}/${infer T}` ? `${H extends `:${string}` ? string : H}/${Pattern}` : R extends `:${string}` ? string : R; +type Narrower = C extends unknown ? Pattern extends Pattern ? C extends R ? never : C : never : never; +type MostSpecific = R extends unknown ? [Narrower] extends [never] ? R : never : never; +type Canonical

= P extends `/api/${infer R}` ? R : P; +type Route

= P extends unknown ? MostSpecific>> : never; +type Operation

= M extends keyof ApiRoutes[Route

] ? ApiRoutes[Route

][M] : never; +type Field = K extends keyof O ? O[K] : never; +type OptionalField = [Field] extends [never] ? { [F in K]?: never } : K extends 'body' ? { body: Field } : { query?: Field }; +export type ApiOptions

= [Operation] extends [never] ? never : Omit & + (M extends 'GET' ? { method?: M } : { method: M }) & OptionalField, 'body'> & OptionalField, 'query'>; +export type ApiResponse

= Field, 'response'>; +export type KnownPath

= P extends unknown ? P extends `${string}?${string}` | `${string}#${string}` ? never : [Route

] extends [never] ? never : P : never; +export type ApiMethod

= keyof ApiRoutes[Route

] & string; +export interface ApiErrorResponse { message?: string; code?: string; data?: { status?: number }; } +export type TypedResponse = Omit & ( + { ok: true; json(): Promise } | { ok: false; json(): Promise } +); + +export type KnownGetPath

= 'GET' extends ApiMethod

? KnownPath

: never; +export interface TransportOptions extends Omit { body?: unknown; query?: object; } diff --git a/lib/vantage.ts b/lib/vantage.ts index aa0044c..0a6f07e 100644 --- a/lib/vantage.ts +++ b/lib/vantage.ts @@ -1,3 +1,5 @@ +import { ApiError } from './api-error.ts'; +import type { ApiOptions, ApiResponse, KnownPath, KnownGetPath, TypedResponse, TransportOptions } from './types/api-routes.ts'; import type Config from './config.ts'; import type { RequestInit, Response } from 'node-fetch'; import type { ApplicationResponse } from './types/rest-api.ts'; @@ -117,7 +119,48 @@ class VantageAPI { return this.authPromise; } - fetch( url: string, opts: RequestInit = {} ): Promise { + request(path: KnownGetPath

, options?: ApiOptions

): Promise>; + request(path: KnownPath

, options: { method: M } & ApiOptions, NoInfer>): Promise>; + async request(path: string, options?: TransportOptions): Promise { + const response = await this.sendRequest(path, options); + const text = await response.text(); + let data: unknown = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + if (!response.ok) { + const error = data && typeof data === 'object' ? data as { message?: unknown; code?: unknown } : {}; + throw new ApiError(typeof error.message === 'string' ? error.message : text || response.statusText, response.status, typeof error.code === 'string' ? error.code : undefined); + } + return data; + } + + requestRaw(path: KnownGetPath

, options?: ApiOptions

): Promise>>; + requestRaw(path: KnownPath

, options: { method: M } & ApiOptions, NoInfer>): Promise>>; + requestRaw(path: string, options?: TransportOptions): Promise { + return this.sendRequest(path, options); + } + + private async sendRequest(path: string, options: TransportOptions = {}): Promise { + const { query, body, ...transport } = options; + const url = new URL(path, BASE_URL); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) value.forEach(item => url.searchParams.append(`${key}[]`, String(item))); + else if (typeof value === 'object') Object.entries(value).forEach(([child, item]) => url.searchParams.append(`${key}[${child}]`, String(item))); + else url.searchParams.set(key, String(value)); + } + } + const opts: RequestInit = { ...transport }; + if (body !== undefined) { + opts.body = JSON.stringify(body); + opts.headers = { ...opts.headers, 'Content-Type': 'application/json' }; + } + return this.fetch(url.href, opts); + } + + private fetch( url: string, opts: RequestInit = {} ): Promise { const absUrl = new URL( url, BASE_URL ).href; return this.authenticate().then( ( { token } ) => { const options = Object.assign( {}, opts, { @@ -133,7 +176,7 @@ class VantageAPI { }); } - getLogStream( args: Record ) { + getLogStream( args: { id: string; log: string } ) { const urlObj = new URL( '/stream-log', BASE_URL ); urlObj.search = new URLSearchParams( args ).toString(); const absUrl = urlObj.href; @@ -163,8 +206,7 @@ function getStacks( config: Config ): Promise { // Cache miss, load again: const vantage = new VantageAPI( config ); - return vantage.fetch( 'stack/applications' ) - .then( resp => resp.json() as Promise ) + return vantage.request( 'stack/applications' ) .then( data => data.map( stack => stack.id ) ) .then( stacks => { config.cache.set( 'stacks', { diff --git a/package.json b/package.json index 1d31de2..68df49e 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test": "node --test test/*.test.js", "smoke": "node scripts/smoke.js", "build": "node scripts/build.js", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.type-tests.json", "pretest": "npm run build", "prepare": "npm run build", "sync-api-types": "node scripts/sync-api-types.js", diff --git a/test/api.test.js b/test/api.test.js index 84e3568..25cd478 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -2,27 +2,29 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { Response } from 'node-fetch'; import Vantage from '../dist/lib/vantage.js'; -import { fetchJSON, ApiError, buildQuery } from '../dist/lib/commands/stack/util.js'; +import { ApiError, buildQuery } from '../dist/lib/commands/stack/util.js'; import buildsCommand from '../dist/lib/commands/stack/builds.js'; -const api = body => ({ fetch: async () => new Response(body) }); +const api = body => { const client = new Vantage({ get: () => undefined }); client.fetch = async () => new Response(body); return client; }; test('API decoding preserves JSON, streaming task IDs, and empty responses', async () => { - assert.deepEqual(await fetchJSON(api('[{"id":"app-dev"}]'), 'stack/applications'), [{ id: 'app-dev' }]); - assert.equal(await fetchJSON(api('app-dev/build/123'), 'builds?stream=true'), 'app-dev/build/123'); - assert.equal(await fetchJSON(api(''), 'delete'), null); + assert.deepEqual(await api('[{"id":"app-dev"}]').request('stack/applications'), [{ id: 'app-dev' }]); + assert.equal(await api('app-dev/build/123').request('stack/applications/app-dev/builds', { method: 'POST', query: { stream: true } }), 'app-dev/build/123'); + assert.equal(await api('').request('stack/applications/app-dev/variables/app/name', { method: 'DELETE' }), null); }); test('API failures preserve the WordPress code and HTTP status', async () => { - const v = { fetch: async () => new Response('{"message":"Forbidden","code":"not-supported"}', { status: 403 }) }; - await assert.rejects(fetchJSON(v, 'builds'), error => { + const v = api(''); + v.fetch = async () => new Response('{"message":"Forbidden","code":"not-supported"}', { status: 403 }); + await assert.rejects(v.request('stack/applications/app-dev/builds'), error => { assert.ok(error instanceof ApiError); assert.equal(error.message, 'Forbidden'); assert.equal(error.code, 'not-supported'); assert.equal(error.status, 403); return true; }); - await assert.rejects(fetchJSON({ fetch: async () => new Response('Bad gateway', { status: 502 }) }, 'builds'), /Bad gateway/); + v.fetch = async () => new Response('Bad gateway', { status: 502 }); + await assert.rejects(v.request('stack/applications/app-dev/builds'), /Bad gateway/); }); test('query encoding retains array filters, nested filters, and zero values', () => { @@ -36,12 +38,26 @@ test('query encoding retains array filters, nested filters, and zero values', () test('compiled builds handler preserves API JSON output and endpoint', async t => { const builds = [{ id: 'build-1', status: 'succeeded', date: '2026-10-12', source_version: '1234567890' }]; const requests = []; - t.mock.method(Vantage.prototype, 'fetch', async url => { + t.mock.method(Vantage.prototype, 'request', async url => { requests.push(url); - return new Response(JSON.stringify(builds)); + return builds; }); const output = t.mock.method(console, 'log', () => {}); await buildsCommand.handler({ stack: 'example-dev-01', config: { get: () => undefined }, json: true }); assert.deepEqual(requests, ['stack/applications/example-dev-01/builds']); assert.deepEqual(JSON.parse(output.mock.calls[0].arguments[0]), builds); }); + +test('log search and status filters use the backend query names and PHP array encoding', async t => { + const { default: logsCommand } = await import('../dist/lib/commands/stack/logs.js'); + const requests = []; + t.mock.method(Vantage.prototype, 'fetch', async url => { + requests.push(new URL(url)); + return new Response('[]'); + }); + t.mock.method(console, 'log', () => {}); + await logsCommand.handler({ stack: 'example-dev', type: 'cron', search: 'failed + retry', status: ['fail', 'success'], config: { get: () => undefined }, json: true }); + assert.equal(requests[0].searchParams.get('search'), 'failed + retry'); + assert.deepEqual(requests[0].searchParams.getAll('status[]'), ['fail', 'success']); + assert.equal(requests[0].searchParams.has('filterPattern'), false); +}); diff --git a/test/deploy.test.js b/test/deploy.test.js new file mode 100644 index 0000000..245c231 --- /dev/null +++ b/test/deploy.test.js @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Response } from 'node-fetch'; +const directory = process.env.ALTIS_TEST_SOURCE ? '../lib' : '../dist/lib'; +const extension = process.env.ALTIS_TEST_SOURCE ? 'ts' : 'js'; +const { default: Vantage } = await import(`${directory}/vantage.${extension}`); +const { default: deploy } = await import(`${directory}/commands/stack/deploy.${extension}`); + +for (const force of [undefined, false, true]) { + test(`deploy only sends force when explicitly enabled: ${force}`, { timeout: 2000 }, async t => { + const requests = []; + t.mock.method(Vantage.prototype, 'fetch', async (url, options) => { + requests.push({ url: new URL(url), options }); + return new Response('deploy-task'); + }); + let started; + const streaming = new Promise(resolve => { started = resolve; }); + t.mock.method(Vantage.prototype, 'getLogStream', () => { + started(); + // Leave log streaming inert; this test exercises the deploy request. + return new Promise(() => {}); + }); + t.mock.method(console, 'log', () => {}); + deploy.handler({ stack: 'example-dev', build: 'build-1', force, config: { get: () => undefined } }); + await streaming; + assert.equal(requests.length, 1); + const { url, options } = requests[0]; + assert.equal(url.pathname, '/api/stack/applications/example-dev/deploys'); + assert.equal(options.method, 'POST'); + assert.equal(url.searchParams.get('stream'), 'true'); + assert.equal(url.searchParams.get('build'), 'build-1'); + assert.equal(url.searchParams.has('force'), force === true); + if (force) assert.equal(url.searchParams.get('force'), 'true'); + }); +} diff --git a/test/maintenance.test.js b/test/maintenance.test.js new file mode 100644 index 0000000..5a4a9b0 --- /dev/null +++ b/test/maintenance.test.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Response } from 'node-fetch'; +const directory = process.env.ALTIS_TEST_SOURCE ? '../lib' : '../dist/lib'; +const extension = process.env.ALTIS_TEST_SOURCE ? 'ts' : 'js'; +const { default: Vantage } = await import(`${directory}/vantage.${extension}`); +const { default: instance } = await import(`${directory}/commands/instance/index.${extension}`); + +const response = { id: 'example', name: 'Example', contacts: [{ email: 'ops@example.com' }, { email: 'support@example.com' }], window: {}, account_managers: [] }; +const setArgs = contact => ({ group: 'maintenance', action: 'set', id: 'example', contact, config: { get: () => undefined }, json: true }); + +for (const contacts of [response.contacts, []]) { + test(`maintenance set appends a contact while preserving ${contacts.length} existing contacts`, async t => { + const requests = []; + const current = { ...response, contacts }; + const updated = { ...current, contacts: [...contacts, { email: 'new@example.com' }] }; + t.mock.method(Vantage.prototype, 'fetch', async (url, options) => { + requests.push({ url, options }); + return new Response(JSON.stringify(options.method === 'PATCH' ? updated : current)); + }); + const output = t.mock.method(console, 'log', () => {}); + await instance.handler(setArgs(' new@example.com ')); + assert.equal(requests.length, 2); + for (const request of requests) assert.match(request.url, /\/api\/stack\/instances\/example\/maintenance$/); + assert.equal(requests[0].options.method, undefined); + assert.equal(requests[0].options.body, undefined); + assert.equal(requests[1].options.method, 'PATCH'); + assert.equal(requests[1].options.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(requests[1].options.body), { contacts: updated.contacts }); + assert.deepEqual(JSON.parse(output.mock.calls[0].arguments[0]), updated); + }); +} + +for (const email of ['ops@example.com', ' OPS@EXAMPLE.COM ']) { + test(`maintenance set leaves an existing contact unchanged: ${email}`, async t => { + const fetch = t.mock.method(Vantage.prototype, 'fetch', async () => new Response(JSON.stringify(response))); + const output = t.mock.method(console, 'log', () => {}); + await instance.handler(setArgs(email)); + assert.equal(fetch.mock.calls.length, 1); + assert.equal(fetch.mock.calls[0].arguments[1].method, undefined); + assert.deepEqual(JSON.parse(output.mock.calls[0].arguments[0]), response); + }); +} + +for (const contact of [undefined, ' ']) { + test(`maintenance set rejects a missing contact without sending a request: ${JSON.stringify(contact)}`, async t => { + const fetch = t.mock.method(Vantage.prototype, 'fetch', () => { throw new Error('unexpected request'); }); + await assert.rejects(instance.handler(setArgs(contact)), /--contact is required/); + assert.equal(fetch.mock.calls.length, 0); + }); +} + +test('maintenance set does not update contacts if reading the existing list fails', async t => { + const fetch = t.mock.method(Vantage.prototype, 'fetch', async () => new Response('{"message":"Forbidden","code":"forbidden"}', { status: 403 })); + await assert.rejects(instance.handler(setArgs('new@example.com')), /Forbidden/); + assert.equal(fetch.mock.calls.length, 1); + assert.equal(fetch.mock.calls[0].arguments[1].method, undefined); +}); + +test('maintenance get retains the returned contacts and window', async t => { + const requests = []; + t.mock.method(Vantage.prototype, 'fetch', async (url, options) => { + requests.push({ url, options }); + return new Response(JSON.stringify(response)); + }); + const output = t.mock.method(console, 'log', () => {}); + await instance.handler({ group: 'maintenance', action: 'get', id: 'example', config: { get: () => undefined }, json: true }); + assert.equal(requests[0].options.body, undefined); + assert.equal(requests[0].options.method, undefined); + assert.deepEqual(JSON.parse(output.mock.calls[0].arguments[0]), response); +}); diff --git a/test/urls.test.js b/test/urls.test.js index 089fa1b..8e0dcfc 100644 --- a/test/urls.test.js +++ b/test/urls.test.js @@ -24,34 +24,34 @@ test('API URLs retain relative paths, authentication and stream query values', a process.env.VANTAGE_URL = `${base}/api/`; const { default: Vantage } = await import(`${directory}/vantage.${extension}`); const vantage = new Vantage({ get: key => key === 'vantage_token' ? 'test-token' : undefined }); - await vantage.fetch('stack/applications?limit=0'); - await vantage.fetch('/health'); - await vantage.fetch(`${base}/absolute`); - const stream = await vantage.getLogStream({ id: 'example-dev', log: 'example-dev/php', filter: 'a+b & c' }); + await vantage.requestRaw('stack/applications'); + await vantage.requestRaw('/api/stack/instances'); + await vantage.requestRaw('stack/applications/app-dev/logs/cron', { query: { search: 'a+b & c', status: ['fail', 'success'] } }); + const stream = await vantage.getLogStream({ id: 'example-dev', log: 'example-dev/php' }); try { await once(stream, 'data'); } finally { stream.destroy(); } - assert.deepEqual(requests.slice(0, 3).map(req => req.url), ['/api/stack/applications?limit=0', '/health', '/absolute']); + assert.deepEqual(requests.slice(0, 3).map(req => req.url), ['/api/stack/applications', '/api/stack/instances', '/api/stack/applications/app-dev/logs/cron?search=a%2Bb+%26+c&status%5B%5D=fail&status%5B%5D=success']); assert.ok(requests.every(req => req.authorization === 'Bearer test-token')); const streamURL = new URL(requests[3].url, base); assert.equal(streamURL.pathname, '/stream-log'); - assert.deepEqual(Object.fromEntries(streamURL.searchParams), { id: 'example-dev', log: 'example-dev/php', filter: 'a+b & c' }); + assert.deepEqual(Object.fromEntries(streamURL.searchParams), { id: 'example-dev', log: 'example-dev/php' }); }); -test('backup query retains repeated values and false and zero options', async () => { +test('backup query retains false and zero options', async () => { const { startBackup } = await import(`${directory}/commands/stack/sync-local/util.${extension}`); let requested; - await startBackup({ fetch: async (url, options) => { + await startBackup({ requestRaw: async (url, options) => { requested = { url, options }; return { ok: true, text: async () => ' task-id ' }; - } }, 'example-dev', { database: 0, uploads: false, filter: ['a+b', 'a & b'] }); + } }, 'example-dev', { database: 0, uploads: false }); const url = new URL(requested.url, 'https://example.com/api/'); + url.search = new URLSearchParams(requested.options.query).toString(); assert.equal(url.pathname, '/api/stack/applications/example-dev/backups'); assert.equal(url.searchParams.get('database'), '0'); assert.equal(url.searchParams.get('uploads'), 'false'); - assert.deepEqual(url.searchParams.getAll('filter'), ['a+b', 'a & b']); assert.equal(url.searchParams.get('stream'), 'true'); assert.equal(requested.options.method, 'POST'); }); diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json new file mode 100644 index 0000000..9594290 --- /dev/null +++ b/tsconfig.type-tests.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["bin/**/*.ts", "lib/**/*.ts", "type-tests/**/*.ts"] +} diff --git a/type-tests/api-routes.ts b/type-tests/api-routes.ts new file mode 100644 index 0000000..6af99e5 --- /dev/null +++ b/type-tests/api-routes.ts @@ -0,0 +1,52 @@ +import type Vantage from '../lib/vantage.ts'; +import type { InstanceMaintenanceResponse, Variable } from '../lib/types/api.ts'; +import type { TraceSummary } from '../lib/types/xray.ts'; + +declare const api: Vantage; +declare const id: string; +const path = `/api/stack/instances/${id}/maintenance` as const; +const read: Promise = api.request(path); +const write: Promise = api.request(path, { method: 'PATCH', body: { contacts: [{ email: 'ops@example.com' }] } }); +const variable: Promise = api.request(`stack/applications/${id}/variables/app/name`, { method: 'PUT', body: { value: 'value' } }); +const summaries: Promise = api.request(`stack/applications/${id}/xray/traces/summaries`); +// @ts-expect-error The singular contact payload is not the backend contract. +api.request(path, { method: 'PATCH', body: { contact: 'ops@example.com' } }); +// @ts-expect-error A maintenance update must supply contacts. +api.request(path, { method: 'PATCH' }); +// @ts-expect-error GET cannot have a body. +api.request(path, { body: { contacts: [] } }); +// @ts-expect-error Maintenance does not support DELETE. +api.request(path, { method: 'DELETE' }); +// @ts-expect-error Serialized bodies bypass type checking and are not accepted. +api.request(path, { method: 'PATCH', body: JSON.stringify({ contacts: [] }) }); +// @ts-expect-error Query keys are checked against the endpoint contract. +api.request(path, { query: { include_auth: true } }); +// @ts-expect-error Unknown paths cannot silently fall back to an untyped request. +api.request('stack/does-not-exist'); +// @ts-expect-error Extra segments do not match a dynamic instance id. +api.request('stack/instances/foo/unknown'); +// @ts-expect-error A POST-only endpoint requires an explicit method. +api.request(`stack/applications/${id}/cli/sessions`); +// @ts-expect-error Raw response access preserves the same body contract. +api.requestRaw(path, { method: 'PATCH', body: { contact: 'ops@example.com' } }); +// @ts-expect-error Commands cannot call the untyped transport. +api.fetch(path); +void [read, write, variable, summaries]; + +// @ts-expect-error Variable types are limited to the backend enum. +api.request(`stack/applications/${id}/variables/invalid`); +// @ts-expect-error An uploads import uses path, not uploads_path. +api.request(`stack/applications/${id}/import-uploads`, { method: 'POST', body: { from_application: 'other', uploads_path: 'sites/2' } }); +// @ts-expect-error Database-only options are not part of an uploads import. +api.request(`stack/applications/${id}/import-uploads`, { method: 'POST', body: { from_application: 'other', tables: [] } }); + +// @ts-expect-error Query parameters belong in the typed query object. +api.request('stack/instances/example?unknown=true'); +declare const mixedPath: 'stack/instances' | 'stack/not-a-route'; +// @ts-expect-error Every member of a union must be a registered path. +api.request(mixedPath); + +// @ts-expect-error The backend calls this query parameter search. +api.request(`stack/applications/${id}/logs/php`, { query: { filterPattern: 'error' } }); +// @ts-expect-error Status filters are not supported by PHP logs. +api.request(`stack/applications/${id}/logs/php`, { query: { status: ['fail'] } });