@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
261 lines (260 loc) • 10.6 kB
JavaScript
import fetch from "node-fetch";
import { getCliVersion } from "../utils/version.js";
import isTestMode from "../utils/test-mode.js";
export class ControlApi {
accessToken;
controlHost;
logErrors;
constructor(options) {
this.accessToken = options.accessToken;
this.controlHost = options.controlHost || "control.ably.net";
// Respect SUPPRESS_CONTROL_API_ERRORS env var for default behavior
// Explicit options.logErrors will override the env var.
// eslint-disable-next-line unicorn/no-negated-condition
if (options.logErrors !== undefined) {
this.logErrors = options.logErrors;
}
else {
// Determine logErrors based on environment variables
const suppressErrors = process.env.SUPPRESS_CONTROL_API_ERRORS === "true" ||
process.env.CI === "true" ||
isTestMode();
this.logErrors = !suppressErrors;
}
}
// Ask a question to the Ably AI agent
async askHelp(question, conversation) {
const payload = {
question,
...(conversation && { context: conversation.messages }),
};
return this.request("/help", "POST", payload);
}
// Create a new app
async createApp(appData) {
// First get the account ID from /me endpoint
const meResponse = await this.getMe();
const accountId = meResponse.account.id;
// Use correct path with account ID prefix
return this.request(`/accounts/${accountId}/apps`, "POST", appData);
}
// Create a new key for an app
async createKey(appId, keyData) {
return this.request(`/apps/${appId}/keys`, "POST", keyData);
}
async createNamespace(appId, namespaceData) {
return this.request(`/apps/${appId}/namespaces`, "POST", namespaceData);
}
async createQueue(appId, queueData) {
return this.request(`/apps/${appId}/queues`, "POST", queueData);
}
// Create a new rule with typed RuleData interface
async createRule(appId, ruleData) {
return this.request(`/apps/${appId}/rules`, "POST", ruleData);
}
// Delete an app
async deleteApp(appId) {
// Delete app uses /apps/{appId} path
return this.request(`/apps/${appId}`, "DELETE");
}
async deleteNamespace(appId, namespaceId) {
return this.request(`/apps/${appId}/namespaces/${namespaceId}`, "DELETE");
}
async deleteQueue(appId, queueName) {
return this.request(`/apps/${appId}/queues/${queueName}`, "DELETE");
}
async deleteRule(appId, ruleId) {
return this.request(`/apps/${appId}/rules/${ruleId}`, "DELETE");
}
// Get account stats
async getAccountStats(options = {}) {
const queryParams = new URLSearchParams();
if (options.start)
queryParams.append("start", options.start.toString());
if (options.end)
queryParams.append("end", options.end.toString());
if (options.by)
queryParams.append("by", options.by);
if (options.limit)
queryParams.append("limit", options.limit.toString());
if (options.unit)
queryParams.append("unit", options.unit);
const queryString = queryParams.toString()
? `?${queryParams.toString()}`
: "";
// First get the account ID from /me endpoint
const meResponse = await this.getMe();
const accountId = meResponse.account.id;
// Account stats require the account ID in the path
return this.request(`/accounts/${accountId}/stats${queryString}`);
}
// Get an app by ID
async getApp(appId) {
// There's no single app GET endpoint, need to get all apps and filter
const apps = await this.listApps();
const app = apps.find((a) => a.id === appId);
if (!app) {
throw new Error(`App with ID "${appId}" not found`);
}
return app;
}
// Get app stats
async getAppStats(appId, options = {}) {
const queryParams = new URLSearchParams();
if (options.start)
queryParams.append("start", options.start.toString());
if (options.end)
queryParams.append("end", options.end.toString());
if (options.by)
queryParams.append("by", options.by);
if (options.limit)
queryParams.append("limit", options.limit.toString());
if (options.unit)
queryParams.append("unit", options.unit);
const queryString = queryParams.toString()
? `?${queryParams.toString()}`
: "";
// App ID-specific operations don't need account ID in the path
return this.request(`/apps/${appId}/stats${queryString}`);
}
// Get a specific key by ID or key value
async getKey(appId, keyIdOrValue) {
// Check if it's a full key (containing colon) or just an ID
const isFullKey = keyIdOrValue.includes(":");
if (isFullKey) {
// If it's a full key, we need to list all keys and find the matching one
const keys = await this.listKeys(appId);
const matchingKey = keys.find((k) => k.key === keyIdOrValue);
if (!matchingKey) {
throw new Error(`Key "${keyIdOrValue}" not found`);
}
return matchingKey;
}
// If it's just an ID, we can fetch it directly
return this.request(`/apps/${appId}/keys/${keyIdOrValue}`);
}
// Get user and account info
async getMe() {
return this.request("/me");
}
async getNamespace(appId, namespaceId) {
return this.request(`/apps/${appId}/namespaces/${namespaceId}`);
}
async getRule(appId, ruleId) {
return this.request(`/apps/${appId}/rules/${ruleId}`);
}
// Get all apps
async listApps() {
// First get the account ID from /me endpoint
const meResponse = await this.getMe();
const accountId = meResponse.account.id;
// Use correct path with account ID prefix
return this.request(`/accounts/${accountId}/apps`);
}
// List all keys for an app
async listKeys(appId) {
return this.request(`/apps/${appId}/keys`);
}
// Namespace (Channel Rules) methods
async listNamespaces(appId) {
return this.request(`/apps/${appId}/namespaces`);
}
// Queues methods
async listQueues(appId) {
return this.request(`/apps/${appId}/queues`);
}
// Rules (Integrations) methods
async listRules(appId) {
return this.request(`/apps/${appId}/rules`);
}
// Revoke a key
async revokeKey(appId, keyId) {
return this.request(`/apps/${appId}/keys/${keyId}`, "DELETE");
}
// Update an app
async updateApp(appId, appData) {
// Update app uses /apps/{appId} path
return this.request(`/apps/${appId}`, "PATCH", appData);
}
// Update an existing key
async updateKey(appId, keyId, keyData) {
return this.request(`/apps/${appId}/keys/${keyId}`, "PATCH", keyData);
}
async updateNamespace(appId, namespaceId, namespaceData) {
return this.request(`/apps/${appId}/namespaces/${namespaceId}`, "PATCH", namespaceData);
}
// Update a rule with typed RuleData interface
async updateRule(appId, ruleId, ruleData) {
return this.request(`/apps/${appId}/rules/${ruleId}`, "PATCH", ruleData);
}
// Upload Apple Push Notification Service P12 certificate for an app
async uploadApnsP12(appId, certificateData, options = {}) {
const data = {
p12Certificate: certificateData,
password: options.password,
useForSandbox: options.useForSandbox,
};
// App ID-specific operations don't need account ID in the path
return this.request(`/apps/${appId}/push/certificate`, "POST", data);
}
async request(path, method = "GET", body) {
const url = this.controlHost.includes("local")
? `http://${this.controlHost}/api/v1${path}`
: `https://${this.controlHost}/v1${path}`;
const options = {
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
"Ably-Agent": `ably-cli/${getCliVersion()}`,
},
method,
};
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const responseBody = await response.text();
// Attempt to parse JSON, otherwise use raw text
let responseData = responseBody;
try {
responseData = JSON.parse(responseBody);
}
catch {
/* Ignore parsing errors, keep as string */
}
const errorDetails = {
message: `API request failed with status ${response.status}: ${response.statusText}`,
response: responseData, // Assign unknown type
statusCode: response.status,
};
// Log the error for debugging purposes, but not during tests
if (this.logErrors) {
console.error("Control API Request Error:", {
message: errorDetails.message,
response: errorDetails.response || "No response body",
statusCode: errorDetails.statusCode,
});
}
// Throw a more user-friendly error, including the message from the response if available
let errorMessage = `API request failed (${response.status} ${response.statusText})`;
if (typeof responseData === "object" &&
responseData !== null &&
"message" in responseData &&
typeof responseData.message === "string") {
errorMessage += `: ${responseData.message}`;
}
else if (typeof responseData === "string" &&
responseData.length < 100) {
// Include short string responses directly
errorMessage += `: ${responseData}`;
}
throw new Error(errorMessage);
}
if (response.status === 204) {
return {};
}
return (await response.json());
}
}