UNPKG

caprover-api-js

Version:

An unofficial TypeScript-based, promise-driven Node.js library for interacting with the CapRover API.

405 lines (404 loc) 18.8 kB
// src/index.ts import axios, { AxiosError } from 'axios'; import * as yaml from 'js-yaml'; import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; // Helper function to pause execution, equivalent to time.sleep() const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const PUBLIC_ONE_CLICK_APP_PATH = "https://raw.githubusercontent.com/caprover/one-click-apps/master/public/v4/apps/"; export class CaproverAPI { /** * The constructor is private to enforce async initialization via `CaproverAPI.create()`. */ constructor(options) { this.rootDomain = ''; const { dashboardUrl, password, protocol = 'https://', schemaVersion = 2, captainNamespace = 'captain', } = options; this.password = password; this.captainNamespace = captainNamespace; this.schemaVersion = schemaVersion; const cleanUrl = dashboardUrl.split("/#")[0].replace(/\/$/, ""); this.baseUrl = cleanUrl.startsWith('http') ? cleanUrl : protocol + cleanUrl; this.axios = axios.create({ baseURL: this.baseUrl, headers: { 'accept': 'application/json, text/plain, */*', 'x-namespace': this.captainNamespace, 'content-type': 'application/json;charset=UTF-8', }, }); } /** * Creates and initializes a new CaproverAPI instance. * This is the correct way to instantiate the class due to async login requirements. * @param options - Configuration for the API client. * @returns A promise that resolves to a fully authenticated CaproverAPI instance. */ static async create(options) { const api = new CaproverAPI(options); await api._login(); const systemInfo = await api.getSystemInfo(); api.rootDomain = systemInfo.data.rootDomain; return api; } /** * A retry helper to wrap around API calls, replacing the Python decorator. * @param asyncFunc The async function to execute. * @param times The number of times to retry. * @param delay The delay in ms between retries. */ async _retry(asyncFunc, times = 3, delay = 1000) { let attempt = 0; while (attempt < times) { try { return await asyncFunc(); } catch (error) { attempt++; const isNetworkError = error instanceof AxiosError && error.code !== 'ECONNABORTED'; if (isNetworkError && attempt < times) { console.error(`Attempt ${attempt} failed. Retrying in ${delay}ms...`, error.message); await sleep(delay); } else { throw error; } } } throw new Error('Retry mechanism failed.'); } /** * Checks the response from the CapRover API for errors. * Throws an exception if the status is not 'OK'. * @param response - The API response object. */ _checkErrors(response) { const { status, description } = response; if (status !== CaproverAPI.Status.STATUS_OK && status !== CaproverAPI.Status.STATUS_OK_PARTIALLY) { console.error(description); throw new Error(description); } console.log(description); return response; } async _login() { console.log("Attempting to login to CapRover dashboard..."); const data = { password: this.password }; const response = await this.axios.post(CaproverAPI.LOGIN_PATH, data); const checkedResponse = this._checkErrors(response.data); const token = checkedResponse.data.token; this.axios.defaults.headers.common['x-captain-auth'] = token; } async getSystemInfo() { return this._retry(async () => { const response = await this.axios.get(CaproverAPI.SYSTEM_INFO_PATH); return this._checkErrors(response.data); }); } async listApps() { return this._retry(async () => { const response = await this.axios.get(CaproverAPI.APP_LIST_PATH); return this._checkErrors(response.data); }); } async getApp(appName) { const appList = await this.listApps(); return appList.data.appDefinitions.find((app) => app.appName === appName) || null; } async createApp(appName, hasPersistentData, wait = true) { console.log(`Creating new app: ${appName}`); const data = { appName, hasPersistentData }; const response = await this._retry(() => this.axios.post(CaproverAPI.APP_REGISTER_PATH, data)); this._checkErrors(response.data); if (wait) { await this._waitUntilAppReady(appName); } return response.data; } // Add these inside your CaproverAPI class in src/index.ts async addDomain(appName, customDomain) { console.log(`${appName} | Adding custom domain: ${customDomain}`); const data = { appName, customDomain }; const response = await this._retry(() => this.axios.post(CaproverAPI.ADD_CUSTOM_DOMAIN_PATH, data)); return this._checkErrors(response.data); } async enableSsl(appName, customDomain) { let path; let data; if (customDomain) { console.log(`${appName} | Enabling SSL for custom domain: ${customDomain}`); path = CaproverAPI.ENABLE_CUSTOM_DOMAIN_SSL_PATH; data = { appName, customDomain }; } else { console.log(`${appName} | Enabling SSL for default CapRover domain`); path = CaproverAPI.ENABLE_BASE_DOMAIN_SSL_PATH; data = { appName }; } const response = await this._retry(() => this.axios.post(path, data)); return this._checkErrors(response.data); } async updateApp(appName, updates) { console.log(`${appName} | Updating app info...`); const currentAppInfo = await this.getApp(appName); if (!currentAppInfo) { throw new Error(`App '${appName}' not found.`); } // Deep merge environment variables if (updates.envVars) { const currentEnvVars = currentAppInfo.envVars.reduce((acc, v) => ({ ...acc, [v.key]: v.value }), {}); const newEnvVars = updates.envVars.reduce((acc, v) => ({ ...acc, [v.key]: v.value }), {}); const merged = { ...currentEnvVars, ...newEnvVars }; updates.envVars = Object.entries(merged).map(([key, value]) => ({ key, value })); } // Merge and send the full object const data = { ...currentAppInfo, ...updates }; const response = await this._retry(() => this.axios.post(CaproverAPI.UPDATE_APP_PATH, data)); return this._checkErrors(response.data); } /** * Triggers a new build for an app configured to deploy from Git. * This is the programmatic equivalent of clicking the "Save & Update" button. * @param appName The name of the app to trigger the build for. */ async triggerBuild(appName) { console.log(`Triggering build process for: ${appName}`); // First, we must get the app's details to find its unique push webhook token. const app = await this.getApp(appName); if (!app) { throw new Error(`Cannot trigger build: App "${appName}" not found.`); } const pushWebhookToken = app.appPushWebhook?.pushWebhookToken; if (!pushWebhookToken) { throw new Error(`Cannot trigger build: App "${appName}" does not have a Git repository configured or push webhook enabled.`); } // Prepare the request. The API expects the token and namespace as URL query parameters. const params = { namespace: this.captainNamespace, token: pushWebhookToken, }; // The POST body is empty. const data = {}; const response = await this._retry(() => this.axios.post(CaproverAPI.TRIGGER_BUILD_PATH, data, { params })); return this._checkErrors(response.data); } async deployApp(appName, options) { let definition = { schemaVersion: this.schemaVersion }; if (options.imageName) { definition.imageName = options.imageName; } else if (options.dockerfileLines) { definition.dockerfileLines = options.dockerfileLines; } else { throw new Error('Either imageName or dockerfileLines must be provided.'); } const data = { captainDefinitionContent: JSON.stringify(definition), gitHash: "" }; const response = await this._retry(() => this.axios.post(`${CaproverAPI.APP_DATA_PATH}/${appName}`, data)); this._checkErrors(response.data); await this._waitUntilAppReady(appName); await sleep(500); // Small delay to ensure build status is updated await this._ensureAppBuildSuccess(appName); return response.data; } async deleteApp(appName, deleteVolumes = false) { let data; if (deleteVolumes) { console.log(`Deleting app ${appName} and its volumes...`); const app = await this.getApp(appName); if (!app) throw new Error(`App ${appName} not found.`); data = { appName, volumes: app.volumes.map((v) => v.volumeName) }; } else { console.log(`Deleting app ${appName}`); data = { appName }; } const response = await this._retry(() => this.axios.post(CaproverAPI.APP_DELETE_PATH, data)); return this._checkErrors(response.data); } /** * Deploys a one-click app from a public or private repository. */ async deployOneClickApp(oneClickAppName, appName, appVariables, oneClickRepository = PUBLIC_ONE_CLICK_APP_PATH) { console.log(`Starting one-click deployment for ${oneClickAppName} as ${appName}`); // 1. Download app definition const rawAppDefinition = await this._downloadOneClickAppDefn(oneClickRepository, oneClickAppName); // 2. Resolve variables const resolvedAppData = this._resolveAppVariables(rawAppDefinition, appName, appVariables); const appData = yaml.load(resolvedAppData); const services = appData.services; const serviceNames = Object.keys(services); const deployed = new Set(); // 3. Deploy services respecting `depends_on` let servicesToProcess = new Set(serviceNames); while (deployed.size < serviceNames.length) { let deployedInThisPass = 0; for (const serviceName of servicesToProcess) { const serviceData = services[serviceName]; const dependencies = serviceData.depends_on || []; // Check if all dependencies are met const canDeploy = dependencies.every((dep) => deployed.has(dep)); if (canDeploy) { console.log(`Deploying service: ${serviceName}`); const hasPersistentData = !!serviceData.volumes; await this.createApp(serviceName, hasPersistentData, true); const environment_variables = serviceData.environment || {}; const caprover_extras = serviceData.caproverExtra || {}; // Prepare update payload const updates = { instanceCount: 1, environment_variables: Object.entries(environment_variables).map(([key, value]) => ({ key, value })), notExposeAsWebApp: caprover_extras.notExposeAsWebApp === 'true', containerHttpPort: caprover_extras.containerHttpPort || 80, }; if (serviceData.volumes) { updates.volumes = serviceData.volumes.map((v) => { const [volumeName, containerPath] = v.split(':'); return volumeName.startsWith('/') ? { hostPath: volumeName, containerPath } : { volumeName, containerPath }; }); } await this.updateApp(serviceName, updates); await this.deployApp(serviceName, { imageName: serviceData.image, dockerfileLines: caprover_extras.dockerfileLines }); deployed.add(serviceName); servicesToProcess.delete(serviceName); // Remove from processing queue deployedInThisPass++; } } if (deployedInThisPass === 0 && servicesToProcess.size > 0) { throw new Error(`Circular dependency or missing dependency detected. Cannot deploy: ${[...servicesToProcess].join(', ')}`); } } return this._checkErrors({ status: CaproverAPI.Status.STATUS_OK, description: `Deployed all services in >>${oneClickAppName}<<`, data: { success: true } }); } async createBackup(fileName) { if (!fileName) { const dateStr = new Date().toISOString().replace(/:/g, '-').slice(0, 19); fileName = `${this.captainNamespace}-bck-${dateStr}.tar`; } console.log(`Creating backup file: ${fileName}`); const createResponse = await this._retry(() => this.axios.post(CaproverAPI.CREATE_BACKUP_PATH, { postDownloadFileName: fileName })); const { downloadToken } = this._checkErrors(createResponse.data).data; console.log('Downloading backup...'); const downloadResponse = await this._retry(() => this.axios.get(CaproverAPI.DOWNLOAD_BACKUP_PATH, { params: { namespace: this.captainNamespace, downloadToken }, responseType: 'stream' })); const absolutePath = path.resolve(fileName); await fs.writeFile(absolutePath, downloadResponse.data); console.log(`Backup saved to ${absolutePath}`); return absolutePath; } // "Private" helper methods (conventionally prefixed with _) async getAppInfo(appName) { return this._retry(async () => { const response = await this.axios.get(`${CaproverAPI.APP_DATA_PATH}/${appName}`); return this._checkErrors(response.data); }); } async _waitUntilAppReady(appName) { const timeout = 60; // 60 seconds for (let i = 0; i < timeout; i++) { await sleep(1000); const appInfo = await this.getAppInfo(appName); if (!appInfo.data?.isAppBuilding) { console.log("App building finished..."); return appInfo; } } throw new Error("App building timeout reached"); } async _ensureAppBuildSuccess(appName) { const appInfo = await this.getAppInfo(appName); if (appInfo.data?.isBuildFailed) { throw new Error(`App build failed for ${appName}. Check the CapRover logs.`); } return appInfo; } async _downloadOneClickAppDefn(repositoryPath, oneClickAppName) { const url = `${repositoryPath}${oneClickAppName}.yml`; console.log(`Downloading one-click app definition from ${url}`); const response = await axios.get(url); return response.data; } _resolveAppVariables(rawAppDefinition, capAppName, appVariables) { let rawAppData = rawAppDefinition; rawAppData = rawAppData.replace(/\$\$cap_gen_random_hex\((\d+)\)/g, (_, lengthStr) => { const length = parseInt(lengthStr, 10); return crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length); }); // FIX: Define the type of allVariables to allow any string key. const allVariables = { ...appVariables, '$$cap_appname': capAppName, '$$cap_root_domain': this.rootDomain, }; const appDefn = yaml.load(rawAppData); const requiredVars = appDefn?.caproverOneClickApp?.variables || []; for (const reqVar of requiredVars) { // FIX: These checks are now valid due to the Record<string, ...> type. if (allVariables[reqVar.id] === undefined || allVariables[reqVar.id] === null) { if (reqVar.defaultValue !== undefined) { allVariables[reqVar.id] = reqVar.defaultValue; } else { throw new Error(`Missing required variable: ${reqVar.label} (${reqVar.id}). Description: ${reqVar.description}`); } } } for (const [id, value] of Object.entries(allVariables)) { rawAppData = rawAppData.replace(new RegExp(id.replace('$$', '\\$\\$'), 'g'), String(value)); } return rawAppData; } } // A collection of status codes, similar to the Python inner class CaproverAPI.Status = { STATUS_ERROR_GENERIC: 1000, STATUS_OK: 100, STATUS_OK_DEPLOY_STARTED: 101, STATUS_OK_PARTIALLY: 102, STATUS_ERROR_CAPTAIN_NOT_INITIALIZED: 1001, STATUS_ERROR_USER_NOT_INITIALIZED: 1101, STATUS_ERROR_NOT_AUTHORIZED: 1102, STATUS_ERROR_ALREADY_EXIST: 1103, STATUS_ERROR_BAD_NAME: 1104, STATUS_WRONG_PASSWORD: 1105, STATUS_AUTH_TOKEN_INVALID: 1106, VERIFICATION_FAILED: 1107, ILLEGAL_OPERATION: 1108, BUILD_ERROR: 1109, ILLEGAL_PARAMETER: 1110, NOT_FOUND: 1111, AUTHENTICATION_FAILED: 1112, STATUS_PASSWORD_BACK_OFF: 1113, }; // API Path constants CaproverAPI.LOGIN_PATH = '/api/v2/login'; CaproverAPI.SYSTEM_INFO_PATH = "/api/v2/user/system/info"; CaproverAPI.APP_LIST_PATH = "/api/v2/user/apps/appDefinitions"; CaproverAPI.APP_REGISTER_PATH = '/api/v2/user/apps/appDefinitions/register'; CaproverAPI.APP_DELETE_PATH = '/api/v2/user/apps/appDefinitions/delete'; CaproverAPI.ADD_CUSTOM_DOMAIN_PATH = '/api/v2/user/apps/appDefinitions/customdomain'; CaproverAPI.UPDATE_APP_PATH = '/api/v2/user/apps/appDefinitions/update'; CaproverAPI.ENABLE_BASE_DOMAIN_SSL_PATH = '/api/v2/user/apps/appDefinitions/enablebasedomainssl'; CaproverAPI.ENABLE_CUSTOM_DOMAIN_SSL_PATH = '/api/v2/user/apps/appDefinitions/enablecustomdomainssl'; CaproverAPI.APP_DATA_PATH = '/api/v2/user/apps/appData'; CaproverAPI.CREATE_BACKUP_PATH = '/api/v2/user/system/createbackup'; CaproverAPI.DOWNLOAD_BACKUP_PATH = '/api/v2/downloads/'; CaproverAPI.TRIGGER_BUILD_PATH = '/api/v2/user/apps/webhooks/triggerbuild';