appwrite-utils-cli
Version:
Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.
537 lines (462 loc) • 17.2 kB
text/typescript
import { Client, Functions, Runtime, type Models } from "node-appwrite";
import { type AppwriteFunction, EventTypeSchema } from "appwrite-utils";
import { join, relative, resolve, basename } from "node:path";
import fs from "node:fs";
import chalk from "chalk";
import pLimit from "p-limit";
import { tryAwaitWithRetry } from "../utils/helperFunctions.js";
import { MessageFormatter } from "./messageFormatter.js";
/**
* Validates and filters events array for Appwrite functions
* - Filters out empty/invalid strings
* - Validates against EventTypeSchema
* - Limits to 100 items maximum (Appwrite limit)
* - Returns empty array if input is invalid
*/
const validateEvents = (events?: string[]): string[] => {
if (!events || !Array.isArray(events)) return [];
return events
.filter(event => {
if (!event || typeof event !== 'string' || event.trim().length === 0) {
return false;
}
// Validate against EventTypeSchema
const result = EventTypeSchema.safeParse(event);
if (!result.success) {
MessageFormatter.warning(`Invalid event type "${event}" will be filtered out`, { prefix: "Functions" });
return false;
}
return true;
})
.slice(0, 100);
};
// Concurrency limits
const functionLimit = pLimit(5); // Moderate limit for function operations
const queryLimit = pLimit(25); // Higher limit for read operations
export interface FunctionSearchOptions {
searchPaths?: string[];
caseSensitive?: boolean;
allowFuzzyMatch?: boolean;
verbose?: boolean;
}
export interface FunctionDeploymentOptions {
activate?: boolean;
entrypoint?: string;
commands?: string;
ignored?: string[];
verbose?: boolean;
forceRedeploy?: boolean;
}
export class FunctionManager {
private client: Client;
private functions: Functions;
constructor(client: Client) {
this.client = client;
this.functions = new Functions(client);
}
/**
* Improved function directory detection with multiple strategies
*/
public async findFunctionDirectory(
functionName: string,
options: FunctionSearchOptions = {}
): Promise<string | null> {
const {
searchPaths = [process.cwd()],
caseSensitive = false,
allowFuzzyMatch = true,
verbose = false
} = options;
if (verbose) {
MessageFormatter.info(`Searching for function: ${functionName}`, { prefix: "Functions" });
}
// Normalize function name for comparison
const normalizedName = caseSensitive ? functionName : functionName.toLowerCase();
const nameVariations = this.generateNameVariations(normalizedName);
// Strategy 1: Check standard locations first
const standardPaths = this.getStandardFunctionPaths(searchPaths, functionName);
for (const path of standardPaths) {
if (await this.isValidFunctionDirectory(path)) {
if (verbose) {
MessageFormatter.success(`Found function at standard location: ${path}`, { prefix: "Functions" });
}
return path;
}
}
// Strategy 2: Recursive directory search with fuzzy matching
if (allowFuzzyMatch) {
for (const searchPath of searchPaths) {
const foundPath = await this.recursiveDirectorySearch(
searchPath,
nameVariations,
{ caseSensitive, verbose }
);
if (foundPath) {
if (verbose) {
MessageFormatter.success(`Found function via fuzzy search: ${foundPath}`, { prefix: "Functions" });
}
return foundPath;
}
}
}
if (verbose) {
MessageFormatter.warning(`Function directory not found: ${functionName}`, { prefix: "Functions" });
}
return null;
}
private generateNameVariations(name: string): Set<string> {
const variations = new Set<string>([
name,
name.toLowerCase(),
name.replace(/\s+/g, "-"),
name.replace(/\s+/g, "_"),
name.replace(/[^a-z0-9]/gi, ""),
name.replace(/[-_\s]+/g, ""),
name.replace(/[-_\s]+/g, "-").toLowerCase(),
name.replace(/[-_\s]+/g, "_").toLowerCase(),
]);
return variations;
}
private getStandardFunctionPaths(searchPaths: string[], functionName: string): string[] {
const normalizedName = functionName.toLowerCase().replace(/\s+/g, "-");
const paths: string[] = [];
for (const basePath of searchPaths) {
paths.push(
join(basePath, "functions", functionName),
join(basePath, "functions", normalizedName),
join(basePath, "src", "functions", functionName),
join(basePath, "src", "functions", normalizedName),
join(basePath, functionName),
join(basePath, normalizedName),
join(basePath, "appwrite", "functions", functionName),
join(basePath, "appwrite", "functions", normalizedName)
);
}
return paths;
}
private async recursiveDirectorySearch(
searchPath: string,
nameVariations: Set<string>,
options: { caseSensitive: boolean; verbose: boolean }
): Promise<string | null> {
const { caseSensitive, verbose } = options;
try {
const stats = await fs.promises.stat(searchPath);
if (!stats.isDirectory()) return null;
const entries = await fs.promises.readdir(searchPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip common directories that won't contain functions
if (this.shouldSkipDirectory(entry.name)) continue;
const entryPath = join(searchPath, entry.name);
// Check if this directory matches our function name
const entryNameToCheck = caseSensitive ? entry.name : entry.name.toLowerCase();
if (nameVariations.has(entryNameToCheck)) {
if (await this.isValidFunctionDirectory(entryPath)) {
return entryPath;
}
}
// Recursively search subdirectories
const result = await this.recursiveDirectorySearch(entryPath, nameVariations, options);
if (result) return result;
}
} catch (error) {
if (verbose) {
MessageFormatter.debug(`Skipping inaccessible directory: ${searchPath}`, undefined, { prefix: "Functions" });
}
}
return null;
}
private shouldSkipDirectory(dirName: string): boolean {
const skipDirs = new Set([
'node_modules',
'.git',
'.vscode',
'.idea',
'dist',
'build',
'coverage',
'.nyc_output',
'tmp',
'temp',
'.cache',
'__pycache__',
'.pytest_cache',
'venv',
'.venv',
'env',
'.env'
]);
return skipDirs.has(dirName) || dirName.startsWith('.');
}
private async isValidFunctionDirectory(path: string): Promise<boolean> {
try {
const stats = await fs.promises.stat(path);
if (!stats.isDirectory()) return false;
const files = await fs.promises.readdir(path);
// Check for common function indicators
const hasPackageJson = files.includes('package.json');
const hasMainFile = files.some(file =>
file.match(/^(index|main|app)\.(js|ts|py)$/i) ||
file === 'src' ||
file === 'lib'
);
const hasRequirements = files.includes('requirements.txt');
const hasPyProject = files.includes('pyproject.toml');
return hasPackageJson || hasMainFile || hasRequirements || hasPyProject;
} catch {
return false;
}
}
/**
* Enhanced function deployment with better error handling and validation
*/
public async deployFunction(
functionConfig: AppwriteFunction,
functionPath: string,
options: FunctionDeploymentOptions = {}
): Promise<Models.Deployment> {
const {
activate = true,
entrypoint = functionConfig.entrypoint || "index.js",
commands = functionConfig.commands || "npm install",
ignored = ["node_modules", ".git", ".vscode", ".DS_Store", "__pycache__", ".venv"],
verbose = false,
forceRedeploy = false
} = options;
return await functionLimit(async () => {
if (verbose) {
MessageFormatter.processing(`Deploying function: ${functionConfig.name}`, { prefix: "Functions" });
MessageFormatter.debug(`Path: ${functionPath}`, undefined, { prefix: "Functions" });
MessageFormatter.debug(`Entrypoint: ${entrypoint}`, undefined, { prefix: "Functions" });
}
// Validate function directory
if (!await this.isValidFunctionDirectory(functionPath)) {
throw new Error(`Invalid function directory: ${functionPath}`);
}
// Ensure function exists
let functionExists = false;
try {
await this.getFunction(functionConfig.$id);
functionExists = true;
} catch (error) {
if (verbose) {
MessageFormatter.info(`Function ${functionConfig.$id} does not exist, creating...`, { prefix: "Functions" });
}
}
// Create function if it doesn't exist
if (!functionExists) {
await this.createFunction(functionConfig, { verbose });
} else if (forceRedeploy) {
await this.updateFunction(functionConfig, { verbose });
}
// Execute pre-deploy commands if specified
if (functionConfig.predeployCommands?.length) {
await this.executePredeployCommands(functionConfig.predeployCommands, functionPath, { verbose });
}
// Deploy the function
const deployment = await this.createDeployment(
functionConfig.$id,
functionPath,
{ activate, entrypoint, commands, ignored, verbose }
);
if (verbose) {
MessageFormatter.success(`Function ${functionConfig.name} deployed successfully`, { prefix: "Functions" });
}
return deployment;
});
}
private async createFunction(
functionConfig: AppwriteFunction,
options: { verbose?: boolean } = {}
): Promise<Models.Function> {
const { verbose = false } = options;
if (verbose) {
MessageFormatter.processing(`Creating function: ${functionConfig.name}`, { prefix: "Functions" });
}
return await tryAwaitWithRetry(async () => {
return await this.functions.create(
functionConfig.$id,
functionConfig.name,
functionConfig.runtime as Runtime,
functionConfig.execute || [],
functionConfig.events || [],
functionConfig.schedule || "",
functionConfig.timeout || 15,
functionConfig.enabled !== false,
functionConfig.logging !== false,
functionConfig.entrypoint,
functionConfig.commands,
functionConfig.scopes || [],
functionConfig.installationId,
functionConfig.providerRepositoryId,
functionConfig.providerBranch,
functionConfig.providerSilentMode,
functionConfig.providerRootDirectory
);
});
}
private async updateFunction(
functionConfig: AppwriteFunction,
options: { verbose?: boolean } = {}
): Promise<Models.Function> {
const { verbose = false } = options;
if (verbose) {
MessageFormatter.processing(`Updating function: ${functionConfig.name}`, { prefix: "Functions" });
}
return await tryAwaitWithRetry(async () => {
return await this.functions.update(
functionConfig.$id,
functionConfig.name,
functionConfig.runtime as Runtime,
functionConfig.execute || [],
functionConfig.events || [],
functionConfig.schedule || "",
functionConfig.timeout || 15,
functionConfig.enabled !== false,
functionConfig.logging !== false,
functionConfig.entrypoint,
functionConfig.commands,
functionConfig.scopes || [],
functionConfig.installationId,
functionConfig.providerRepositoryId,
functionConfig.providerBranch,
functionConfig.providerSilentMode,
functionConfig.providerRootDirectory,
functionConfig.specification
);
});
}
private async executePredeployCommands(
commands: string[],
workingDir: string,
options: { verbose?: boolean } = {}
): Promise<void> {
const { verbose = false } = options;
const { execSync } = await import("child_process");
const { platform } = await import("node:os");
if (verbose) {
MessageFormatter.processing("Executing pre-deploy commands...", { prefix: "Functions" });
}
const isWindows = platform() === "win32";
for (const command of commands) {
if (verbose) {
MessageFormatter.debug(`$ ${command}`, undefined, { prefix: "Functions" });
}
try {
execSync(command, {
cwd: workingDir,
stdio: verbose ? "inherit" : "pipe",
shell: isWindows ? "cmd.exe" : "/bin/sh",
windowsHide: true,
});
} catch (error) {
MessageFormatter.error(`Failed to execute command: ${command}`, error as Error, { prefix: "Functions" });
throw error;
}
}
if (verbose) {
MessageFormatter.success("Pre-deploy commands completed", { prefix: "Functions" });
}
}
private async createDeployment(
functionId: string,
codePath: string,
options: FunctionDeploymentOptions & { verbose?: boolean } = {}
): Promise<Models.Deployment> {
const { activate = true, entrypoint = "index.js", commands = "npm install", ignored = [], verbose = false } = options;
const { InputFile } = await import("node-appwrite/file");
const { create: createTarball } = await import("tar");
const tarPath = join(process.cwd(), `function-${functionId}-${Date.now()}.tar.gz`);
try {
if (verbose) {
MessageFormatter.processing("Creating deployment archive...", { prefix: "Functions" });
}
// Create tarball
await createTarball(
{
gzip: true,
file: tarPath,
cwd: codePath,
filter: (path) => {
const relativePath = relative(codePath, join(codePath, path)).toLowerCase();
const shouldIgnore = ignored.some(pattern =>
relativePath.startsWith(pattern.toLowerCase()) ||
relativePath.includes(`/${pattern.toLowerCase()}`) ||
relativePath.includes(`\\${pattern.toLowerCase()}`)
);
if (shouldIgnore && verbose) {
MessageFormatter.debug(`Ignoring: ${path}`, undefined, { prefix: "Functions" });
}
return !shouldIgnore;
},
},
["."]
);
// Read and upload
const fileBuffer = await fs.promises.readFile(tarPath);
const fileObject = InputFile.fromBuffer(
new Uint8Array(fileBuffer),
`function-${functionId}.tar.gz`
);
if (verbose) {
MessageFormatter.processing("Uploading deployment...", { prefix: "Functions" });
}
const deployment = await tryAwaitWithRetry(async () => {
return await this.functions.createDeployment(
functionId,
fileObject,
activate,
entrypoint,
commands
);
});
return deployment;
} finally {
// Clean up tarball
try {
await fs.promises.unlink(tarPath);
} catch {
// Ignore cleanup errors
}
}
}
public async getFunction(functionId: string): Promise<Models.Function> {
return await queryLimit(() =>
tryAwaitWithRetry(async () => await this.functions.get(functionId))
);
}
public async listFunctions(): Promise<Models.FunctionList> {
return await queryLimit(() =>
tryAwaitWithRetry(async () => await this.functions.list())
);
}
public async deleteFunction(functionId: string): Promise<void> {
await functionLimit(() =>
tryAwaitWithRetry(async () => await this.functions.delete(functionId))
);
}
/**
* Validate function configuration
*/
public validateFunctionConfig(functionConfig: AppwriteFunction): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!functionConfig.$id) {
errors.push("Function ID is required");
}
if (!functionConfig.name) {
errors.push("Function name is required");
}
if (!functionConfig.runtime) {
errors.push("Function runtime is required");
}
if (!functionConfig.entrypoint) {
errors.push("Function entrypoint is required");
}
// Validate timeout
if (functionConfig.timeout && (functionConfig.timeout < 1 || functionConfig.timeout > 900)) {
errors.push("Function timeout must be between 1 and 900 seconds");
}
return { valid: errors.length === 0, errors };
}
}