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.
1,498 lines (1,365 loc) โข 81.2 kB
text/typescript
import inquirer from "inquirer";
import { UtilsController } from "./utilsController.js";
import { createEmptyCollection, setupDirsFiles } from "./utils/setupFiles.js";
import { fetchAllDatabases } from "./databases/methods.js";
import { fetchAllCollections } from "./collections/methods.js";
import { listBuckets, createBucket } from "./storage/methods.js";
import {
Databases,
Storage,
Client,
type Models,
Compression,
Query,
Functions,
} from "node-appwrite";
import { getClient } from "./utils/getClientFromConfig.js";
import type { TransferOptions } from "./migrations/transfer.js";
import { ComprehensiveTransfer, type ComprehensiveTransferOptions } from "./migrations/comprehensiveTransfer.js";
import {
AppwriteFunctionSchema,
parseAttribute,
PermissionToAppwritePermission,
RuntimeSchema,
permissionSchema,
type AppwriteConfig,
type AppwriteFunction,
type ConfigDatabases,
type Runtime,
type Specification,
type FunctionScope,
} from "appwrite-utils";
import { ulid } from "ulidx";
import chalk from "chalk";
import { DateTime } from "luxon";
import {
createFunctionTemplate,
deleteFunction,
downloadLatestFunctionDeployment,
getFunction,
listFunctions,
listSpecifications,
} from "./functions/methods.js";
import { deployLocalFunction } from "./functions/deployments.js";
import { join } from "node:path";
import path from "path";
import fs from "node:fs";
import os from "node:os";
import { SchemaGenerator } from "./shared/schemaGenerator.js";
import { ConfirmationDialogs } from "./shared/confirmationDialogs.js";
import { MessageFormatter } from "./shared/messageFormatter.js";
import { migrateConfig } from "./utils/configMigration.js";
import { findAppwriteConfig } from "./utils/loadConfigs.js";
import { findYamlConfig, addFunctionToYamlConfig } from "./config/yamlConfig.js";
enum CHOICES {
MIGRATE_CONFIG = "๐ Migrate TypeScript config to YAML (.appwrite structure)",
CREATE_COLLECTION_CONFIG = "๐ Create collection config file",
CREATE_FUNCTION = "โก Create a new function, from scratch or using a template",
DEPLOY_FUNCTION = "๐ Deploy function(s)",
DELETE_FUNCTION = "๐๏ธ Delete function",
SETUP_DIRS_FILES = "๐ Setup directories and files",
SETUP_DIRS_FILES_WITH_EXAMPLE_DATA = "๐โจ Setup directories and files with example data",
SYNC_DB = "โฌ๏ธ Push local config to Appwrite",
SYNCHRONIZE_CONFIGURATIONS = "๐ Synchronize configurations - Pull from Appwrite and write to local config",
TRANSFER_DATA = "๐ฆ Transfer data",
COMPREHENSIVE_TRANSFER = "๐ Comprehensive transfer (users โ databases โ buckets โ functions)",
BACKUP_DATABASE = "๐พ Backup database",
WIPE_DATABASE = "๐งน Wipe database",
WIPE_COLLECTIONS = "๐งน Wipe collections",
GENERATE_SCHEMAS = "๐๏ธ Generate schemas",
GENERATE_CONSTANTS = "๐ Generate cross-language constants (TypeScript, Python, PHP, Dart, etc.)",
IMPORT_DATA = "๐ฅ Import data",
RELOAD_CONFIG = "๐ Reload configuration files",
UPDATE_FUNCTION_SPEC = "โ๏ธ Update function specifications",
EXIT = "๐ Exit",
}
export class InteractiveCLI {
private controller: UtilsController | undefined;
private isUsingTypeScriptConfig: boolean = false;
constructor(private currentDir: string) {}
async run(): Promise<void> {
MessageFormatter.banner(
"Appwrite Utils CLI",
"Welcome to Appwrite Utils CLI Tool by Zach Handley"
);
MessageFormatter.info(
"For more information, visit https://github.com/zachhandley/AppwriteUtils"
);
// Detect configuration type
try {
await this.detectConfigurationType();
} catch (error) {
// Continue if detection fails
this.isUsingTypeScriptConfig = false;
}
while (true) {
// Build choices array dynamically based on config type
const choices = this.buildChoicesList();
const { action } = await inquirer.prompt([
{
type: "list",
name: "action",
message: chalk.yellow("What would you like to do?"),
choices,
},
]);
switch (action) {
case CHOICES.MIGRATE_CONFIG:
await this.migrateTypeScriptConfig();
break;
case CHOICES.CREATE_COLLECTION_CONFIG:
await this.createCollectionConfig();
break;
case CHOICES.CREATE_FUNCTION:
await this.initControllerIfNeeded();
await this.createFunction();
break;
case CHOICES.DEPLOY_FUNCTION:
await this.initControllerIfNeeded();
await this.deployFunction();
break;
case CHOICES.DELETE_FUNCTION:
await this.initControllerIfNeeded();
await this.deleteFunction();
break;
case CHOICES.SETUP_DIRS_FILES:
await setupDirsFiles(false, this.currentDir);
break;
case CHOICES.SETUP_DIRS_FILES_WITH_EXAMPLE_DATA:
await setupDirsFiles(true, this.currentDir);
break;
case CHOICES.SYNCHRONIZE_CONFIGURATIONS:
await this.initControllerIfNeeded();
await this.synchronizeConfigurations();
break;
case CHOICES.SYNC_DB:
await this.initControllerIfNeeded();
await this.syncDb();
break;
case CHOICES.TRANSFER_DATA:
await this.initControllerIfNeeded();
await this.transferData();
break;
case CHOICES.COMPREHENSIVE_TRANSFER:
await this.comprehensiveTransfer();
break;
case CHOICES.BACKUP_DATABASE:
await this.initControllerIfNeeded();
await this.backupDatabase();
break;
case CHOICES.WIPE_DATABASE:
await this.initControllerIfNeeded();
await this.wipeDatabase();
break;
case CHOICES.WIPE_COLLECTIONS:
await this.initControllerIfNeeded();
await this.wipeCollections();
break;
case CHOICES.GENERATE_SCHEMAS:
await this.initControllerIfNeeded();
await this.generateSchemas();
break;
case CHOICES.GENERATE_CONSTANTS:
await this.initControllerIfNeeded();
await this.generateConstants();
break;
case CHOICES.IMPORT_DATA:
await this.initControllerIfNeeded();
await this.importData();
break;
case CHOICES.RELOAD_CONFIG:
await this.initControllerIfNeeded();
await this.reloadConfig();
break;
case CHOICES.UPDATE_FUNCTION_SPEC:
await this.initControllerIfNeeded();
await this.updateFunctionSpec();
break;
case CHOICES.EXIT:
MessageFormatter.success("Goodbye!");
process.exit(0);
}
}
}
private async initControllerIfNeeded(directConfig?: {
appwriteEndpoint: string;
appwriteProject: string;
appwriteKey: string;
}): Promise<void> {
if (!this.controller) {
this.controller = new UtilsController(this.currentDir, directConfig);
await this.controller.init();
}
}
private async selectDatabases(
databases: Models.Database[],
message: string,
multiSelect = true
): Promise<Models.Database[]> {
await this.initControllerIfNeeded();
const configDatabases = this.getLocalDatabases();
const allDatabases = [...databases, ...configDatabases]
.reduce((acc, db) => {
// Local config takes precedence - if a database with same name exists, use local version
const existingIndex = acc.findIndex((d) => d.name === db.name);
if (existingIndex >= 0) {
if (configDatabases.some((cdb) => cdb.name === db.name)) {
acc[existingIndex] = db; // Replace with local version
}
} else {
acc.push(db);
}
return acc;
}, [] as Models.Database[])
.filter((db) => {
const useMigrations = this.controller?.config?.useMigrations ?? true;
return useMigrations || db.name.toLowerCase() !== "migrations";
});
const hasLocalAndRemote =
allDatabases.some((db) =>
configDatabases.some((c) => c.name === db.name)
) &&
allDatabases.some(
(db) => !configDatabases.some((c) => c.name === db.name)
);
const choices = allDatabases
.sort((a, b) => a.name.localeCompare(b.name))
.map((db) => ({
name:
db.name +
(hasLocalAndRemote
? configDatabases.some((c) => c.name === db.name)
? " (Local)"
: " (Remote)"
: ""),
value: db,
}))
.filter((db) => {
const useMigrations = this.controller?.config?.useMigrations ?? true;
return useMigrations || db.name.toLowerCase() !== "migrations";
});
const { selectedDatabases } = await inquirer.prompt([
{
type: multiSelect ? "checkbox" : "list",
name: "selectedDatabases",
message: chalk.blue(message),
choices,
loop: true,
pageSize: 10,
},
]);
return selectedDatabases;
}
private async selectCollections(
database: Models.Database,
databasesClient: Databases,
message: string,
multiSelect = true,
preferLocal = false,
shouldFilterByDatabase = false
): Promise<Models.Collection[]> {
await this.initControllerIfNeeded();
const configCollections = this.getLocalCollections();
let remoteCollections: Models.Collection[] = [];
const dbExists = await databasesClient.list([
Query.equal("name", database.name),
]);
if (dbExists.total === 0) {
console.log(
chalk.red(
`Database "${database.name}" does not exist, using only local collection options`
)
);
shouldFilterByDatabase = false;
} else {
remoteCollections = await fetchAllCollections(
database.$id,
databasesClient
);
}
let allCollections = preferLocal
? remoteCollections.reduce(
(acc, remoteCollection) => {
if (!acc.some((c) => c.name === remoteCollection.name)) {
acc.push(remoteCollection);
}
return acc;
},
[...configCollections]
)
: [
...remoteCollections,
...configCollections.filter(
(c) => !remoteCollections.some((rc) => rc.name === c.name)
),
];
if (shouldFilterByDatabase) {
allCollections = allCollections.filter(
(c) => c.databaseId === database.$id
);
}
const hasLocalAndRemote =
allCollections.some((coll) =>
configCollections.some((c) => c.name === coll.name)
) &&
allCollections.some(
(coll) => !configCollections.some((c) => c.name === coll.name)
);
const choices = allCollections
.sort((a, b) => a.name.localeCompare(b.name))
.map((collection) => ({
name:
collection.name +
(hasLocalAndRemote
? configCollections.some((c) => c.name === collection.name)
? " (Local)"
: " (Remote)"
: ""),
value: collection,
}));
const { selectedCollections } = await inquirer.prompt([
{
type: multiSelect ? "checkbox" : "list",
name: "selectedCollections",
message: chalk.blue(message),
choices,
loop: true,
pageSize: 10,
},
]);
return selectedCollections;
}
private getTemplateDefaults(template: string) {
const defaults = {
"typescript-node": {
runtime: "node-21.0" as Runtime,
entrypoint: "src/index.ts",
commands: "npm install && npm run build",
specification: "s-0.5vcpu-512mb" as Specification,
},
"uv": {
runtime: "python-3.12" as Runtime,
entrypoint: "src/index.py",
commands: "uv sync && uv build",
specification: "s-0.5vcpu-512mb" as Specification,
},
"count-docs-in-collection": {
runtime: "node-21.0" as Runtime,
entrypoint: "src/main.ts",
commands: "npm install && npm run build",
specification: "s-1vcpu-512mb" as Specification,
},
};
return defaults[template as keyof typeof defaults] || {
runtime: "node-21.0" as Runtime,
entrypoint: "",
commands: "",
specification: "s-0.5vcpu-512mb" as Specification,
};
}
private async createFunction(): Promise<void> {
const { name } = await inquirer.prompt([
{
type: "input",
name: "name",
message: "Function name:",
validate: (input) => input.length > 0,
},
]);
const { template } = await inquirer.prompt([
{
type: "list",
name: "template",
message: "Select a template:",
choices: [
{ name: "TypeScript Node.js", value: "typescript-node" },
{ name: "Python with UV", value: "uv" },
{ name: "Count Documents in Collection", value: "count-docs-in-collection" },
{ name: "None (Empty Function)", value: "none" },
],
},
]);
// Get template defaults
const templateDefaults = this.getTemplateDefaults(template);
const { runtime } = await inquirer.prompt([
{
type: "list",
name: "runtime",
message: "Select runtime:",
choices: Object.values(RuntimeSchema.Values),
default: templateDefaults.runtime,
},
]);
const specifications = await listSpecifications(
this.controller!.appwriteServer!
);
const { specification } = await inquirer.prompt([
{
type: "list",
name: "specification",
message: "Select specification:",
choices: [
{ name: "None", value: undefined },
...specifications.specifications.map((s) => ({
name: s.slug,
value: s.slug,
})),
],
default: templateDefaults.specification,
},
]);
const functionConfig: AppwriteFunction = {
$id: ulid(),
name,
runtime,
events: [],
execute: ["any"],
enabled: true,
logging: true,
entrypoint: templateDefaults.entrypoint,
commands: templateDefaults.commands,
specification: specification || templateDefaults.specification,
scopes: [],
timeout: 15,
schedule: "",
installationId: "",
providerRepositoryId: "",
providerBranch: "",
providerSilentMode: false,
providerRootDirectory: "",
templateRepository: "",
templateOwner: "",
templateRootDirectory: "",
};
if (template !== "none") {
await createFunctionTemplate(
template as "typescript-node" | "uv" | "count-docs-in-collection",
name,
"./functions"
);
}
// Add to in-memory config
if (!this.controller!.config!.functions) {
this.controller!.config!.functions = [];
}
this.controller!.config!.functions.push(functionConfig);
// If using YAML config, also add to YAML file
const yamlConfigPath = findYamlConfig(this.currentDir);
if (yamlConfigPath) {
try {
await addFunctionToYamlConfig(yamlConfigPath, functionConfig);
} catch (error) {
MessageFormatter.warning(
`Function created but failed to update YAML config: ${error instanceof Error ? error.message : error}`,
{ prefix: "Functions" }
);
}
}
MessageFormatter.success("Function created successfully!", { prefix: "Functions" });
}
private async findFunctionInSubdirectories(
basePaths: string[],
functionName: string
): Promise<string | null> {
// Common locations to check first
const commonPaths = basePaths.flatMap((basePath) => [
join(basePath, "functions", functionName),
join(basePath, functionName),
join(basePath, functionName.toLowerCase()),
join(basePath, functionName.toLowerCase().replace(/\s+/g, "")),
]);
// Create different variations of the function name for comparison
const functionNameVariations = new Set([
functionName.toLowerCase(),
functionName.toLowerCase().replace(/\s+/g, ""),
functionName.toLowerCase().replace(/[^a-z0-9]/g, ""),
functionName.toLowerCase().replace(/[-_\s]+/g, ""),
]);
// Check common locations first
for (const path of commonPaths) {
try {
const stats = await fs.promises.stat(path);
if (stats.isDirectory()) {
console.log(
chalk.green(`Found function at common location: ${path}`)
);
return path;
}
} catch (error) {
// Path doesn't exist, continue to next
}
}
// If not found in common locations, do recursive search
console.log(
chalk.yellow(
"Function not found in common locations, searching subdirectories..."
)
);
const queue = [...basePaths];
const searched = new Set<string>();
while (queue.length > 0) {
const currentPath = queue.shift()!;
if (searched.has(currentPath)) continue;
searched.add(currentPath);
try {
const entries = await fs.promises.readdir(currentPath, {
withFileTypes: true,
});
for (const entry of entries) {
const fullPath = join(currentPath, entry.name);
// Skip node_modules and hidden directories
if (
entry.isDirectory() &&
!entry.name.startsWith(".") &&
entry.name !== "node_modules"
) {
const entryNameVariations = new Set([
entry.name.toLowerCase(),
entry.name.toLowerCase().replace(/\s+/g, ""),
entry.name.toLowerCase().replace(/[^a-z0-9]/g, ""),
entry.name.toLowerCase().replace(/[-_\s]+/g, ""),
]);
// Check if any variation of the entry name matches any variation of the function name
const hasMatch = [...functionNameVariations].some((fnVar) =>
[...entryNameVariations].includes(fnVar)
);
if (hasMatch) {
console.log(chalk.green(`Found function at: ${fullPath}`));
return fullPath;
}
queue.push(fullPath);
}
}
} catch (error) {
console.log(
chalk.yellow(`Error reading directory ${currentPath}:`, error)
);
}
}
return null;
}
private async deployFunction(): Promise<void> {
await this.initControllerIfNeeded();
if (!this.controller?.config) {
console.log(chalk.red("Failed to initialize controller or load config"));
return;
}
const functions = await this.selectFunctions(
"Select function(s) to deploy:",
true,
true
);
if (!functions?.length) {
console.log(chalk.red("No function selected"));
return;
}
for (const functionConfig of functions) {
if (!functionConfig) {
console.log(chalk.red("Invalid function configuration"));
return;
}
// Ensure functions array exists
if (!this.controller.config.functions) {
this.controller.config.functions = [];
}
const functionNameLower = functionConfig.name
.toLowerCase()
.replace(/\s+/g, "-");
// Debug logging
console.log(chalk.blue(`๐ Function deployment debug:`));
console.log(chalk.gray(` Function name: ${functionConfig.name}`));
console.log(chalk.gray(` Function ID: ${functionConfig.$id}`));
console.log(chalk.gray(` Config dirPath: ${functionConfig.dirPath || 'undefined'}`));
if (functionConfig.dirPath) {
const expandedPath = functionConfig.dirPath.startsWith('~/')
? functionConfig.dirPath.replace('~', os.homedir())
: functionConfig.dirPath;
console.log(chalk.gray(` Expanded dirPath: ${expandedPath}`));
}
console.log(chalk.gray(` Appwrite folder: ${this.controller.getAppwriteFolderPath()}`));
console.log(chalk.gray(` Current working dir: ${process.cwd()}`));
// Helper function to expand tilde in paths
const expandTildePath = (path: string): string => {
if (path.startsWith('~/')) {
return path.replace('~', os.homedir());
}
return path;
};
// Check locations in priority order:
const priorityLocations = [
// 1. Config dirPath if specified (with tilde expansion)
functionConfig.dirPath ? expandTildePath(functionConfig.dirPath) : undefined,
// 2. Appwrite config folder/functions/name
join(
this.controller.getAppwriteFolderPath()!,
"functions",
functionNameLower
),
// 3. Current working directory/functions/name
join(process.cwd(), "functions", functionNameLower),
// 4. Current working directory/name
join(process.cwd(), functionNameLower),
].filter((val): val is string => val !== undefined); // Remove undefined entries (in case dirPath is undefined)
console.log(chalk.blue(`๐ Priority locations to check:`));
priorityLocations.forEach((loc, i) => {
console.log(chalk.gray(` ${i + 1}. ${loc}`));
});
let functionPath: string | null = null;
// Check each priority location
for (const location of priorityLocations) {
console.log(chalk.gray(` Checking: ${location} - ${fs.existsSync(location) ? 'EXISTS' : 'NOT FOUND'}`));
if (fs.existsSync(location)) {
console.log(chalk.green(`โ
Found function at: ${location}`));
functionPath = location;
break;
}
}
// If not found in priority locations, do a broader search
if (!functionPath) {
console.log(
chalk.yellow(
`Function not found in primary locations, searching subdirectories...`
)
);
// Search in both appwrite config directory and current working directory
functionPath = await this.findFunctionInSubdirectories(
[this.controller.getAppwriteFolderPath()!, process.cwd()],
functionNameLower
);
}
if (!functionPath) {
const { shouldDownload } = await inquirer.prompt([
{
type: "confirm",
name: "shouldDownload",
message:
"Function not found locally. Would you like to download the latest deployment?",
default: false,
},
]);
if (shouldDownload) {
try {
console.log(chalk.blue("Downloading latest deployment..."));
const { path: downloadedPath, function: remoteFunction } =
await downloadLatestFunctionDeployment(
this.controller.appwriteServer!,
functionConfig.$id,
join(this.controller.getAppwriteFolderPath()!, "functions")
);
console.log(
chalk.green(`โจ Function downloaded to ${downloadedPath}`)
);
functionPath = downloadedPath;
functionConfig.dirPath = downloadedPath;
const existingIndex = this.controller.config.functions.findIndex(
(f) => f?.$id === remoteFunction.$id
);
if (existingIndex >= 0) {
this.controller.config.functions[existingIndex].dirPath =
downloadedPath;
}
await this.controller.reloadConfig();
} catch (error) {
console.error(
chalk.red("Failed to download function deployment:"),
error
);
return;
}
} else {
console.log(
chalk.red(
`Function ${functionConfig.name} not found locally. Cannot deploy.`
)
);
return;
}
}
if (!this.controller.appwriteServer) {
console.log(chalk.red("Appwrite server not initialized"));
return;
}
try {
await deployLocalFunction(
this.controller.appwriteServer,
functionConfig.name,
{
...functionConfig,
dirPath: functionPath,
},
functionPath
);
MessageFormatter.success("Function deployed successfully!", { prefix: "Functions" });
} catch (error) {
console.error(chalk.red("Failed to deploy function:"), error);
}
}
}
private async deleteFunction(): Promise<void> {
const functions = await this.selectFunctions(
"Select functions to delete:",
true,
false
);
if (!functions.length) {
console.log(chalk.red("No functions selected"));
return;
}
for (const func of functions) {
try {
await deleteFunction(this.controller!.appwriteServer!, func.$id);
console.log(
chalk.green(`โจ Function ${func.name} deleted successfully!`)
);
} catch (error) {
console.error(
chalk.red(`Failed to delete function ${func.name}:`),
error
);
}
}
}
private async selectFunctions(
message: string,
multiple: boolean = true,
includeRemote: boolean = false
): Promise<AppwriteFunction[]> {
const remoteFunctions = includeRemote
? await listFunctions(this.controller!.appwriteServer!, [
Query.limit(1000),
])
: { functions: [] };
const localFunctions = this.getLocalFunctions();
// Combine functions, preferring local ones
const allFunctions = [
...localFunctions,
...remoteFunctions.functions.filter(
(rf) => !localFunctions.some((lf) => lf.name === rf.name)
),
];
const { selectedFunctions } = await inquirer.prompt([
{
type: multiple ? "checkbox" : "list",
name: "selectedFunctions",
message,
choices: allFunctions.map((f) => ({
name: `${f.name} (${f.$id})${
localFunctions.some((lf) => lf.name === f.name)
? " (Local)"
: " (Remote)"
}`,
value: f,
})),
loop: true,
},
]);
return multiple ? selectedFunctions : [selectedFunctions];
}
private getLocalFunctions(): AppwriteFunction[] {
const configFunctions = this.controller!.config?.functions || [];
return configFunctions.map((f) => ({
$id: f.$id || ulid(),
$createdAt: DateTime.now().toISO(),
$updatedAt: DateTime.now().toISO(),
name: f.name,
runtime: f.runtime,
execute: f.execute || ["any"],
events: f.events || [],
schedule: f.schedule || "",
timeout: f.timeout || 15,
ignore: f.ignore,
enabled: f.enabled !== false,
logging: f.logging !== false,
entrypoint: f.entrypoint || "src/index.ts",
commands: f.commands || "npm install",
scopes: f.scopes || [], // Add scopes
path: f.dirPath || `functions/${f.name}`,
dirPath: f.dirPath, // Preserve original dirPath
installationId: f.installationId || "",
providerRepositoryId: f.providerRepositoryId || "",
providerBranch: f.providerBranch || "",
providerSilentMode: f.providerSilentMode || false,
providerRootDirectory: f.providerRootDirectory || "",
...(f.specification ? { specification: f.specification } : {}),
...(f.predeployCommands
? { predeployCommands: f.predeployCommands }
: {}),
...(f.deployDir ? { deployDir: f.deployDir } : {}),
}));
}
private async selectBuckets(
buckets: Models.Bucket[],
message: string,
multiSelect = true
): Promise<Models.Bucket[]> {
const choices = buckets.map((bucket) => ({
name: bucket.name,
value: bucket,
}));
const { selectedBuckets } = await inquirer.prompt([
{
type: multiSelect ? "checkbox" : "list",
name: "selectedBuckets",
message: chalk.blue(message),
choices,
loop: false,
pageSize: 10,
},
]);
return selectedBuckets;
}
private async createCollectionConfig(): Promise<void> {
const { collectionName } = await inquirer.prompt([
{
type: "input",
name: "collectionName",
message: chalk.blue("Enter the name of the collection:"),
validate: (input) =>
input.trim() !== "" || "Collection name cannot be empty.",
},
]);
console.log(
chalk.green(`Creating collection config file for '${collectionName}'...`)
);
createEmptyCollection(collectionName);
}
private async configureBuckets(
config: AppwriteConfig,
databases?: ConfigDatabases
): Promise<AppwriteConfig> {
const { storage } = this.controller!;
if (!storage) {
throw new Error(
"Storage is not initialized. Is the config file correct and created?"
);
}
const allBuckets = await listBuckets(storage);
// If there are no buckets, ask to create one for each database
if (allBuckets.total === 0) {
const databasesToUse = databases ?? config.databases;
for (const database of databasesToUse) {
// If database has bucket config in local config, use that
const localDatabase = this.controller!.config?.databases.find(
(db) => db.name === database.name
);
if (localDatabase?.bucket) {
database.bucket = localDatabase.bucket;
continue;
}
const { wantCreateBucket } = await inquirer.prompt([
{
type: "confirm",
name: "wantCreateBucket",
message: chalk.blue(
`There are no buckets. Do you want to create a bucket for the database "${database.name}"?`
),
default: true,
},
]);
if (wantCreateBucket) {
const createdBucket = await this.createNewBucket(
storage,
database.name
);
database.bucket = {
...createdBucket,
compression: createdBucket.compression as Compression,
};
}
}
return config;
}
// Configure global buckets
let globalBuckets: Models.Bucket[] = [];
if (allBuckets.total > 0) {
globalBuckets = await this.selectBuckets(
allBuckets.buckets,
"Select global buckets (buckets that are not associated with any specific database):",
true
);
config.buckets = globalBuckets.map((bucket) => ({
$id: bucket.$id,
name: bucket.name,
enabled: bucket.enabled,
maximumFileSize: bucket.maximumFileSize,
allowedFileExtensions: bucket.allowedFileExtensions,
compression: bucket.compression as Compression,
encryption: bucket.encryption,
antivirus: bucket.antivirus,
}));
} else {
config.buckets = [];
}
// Configure database-specific buckets
for (const database of config.databases) {
const { assignBucket } = await inquirer.prompt([
{
type: "confirm",
name: "assignBucket",
message: `Do you want to assign or create a bucket for the database "${database.name}"?`,
default: false,
},
]);
if (assignBucket) {
const { action } = await inquirer.prompt([
{
type: "list",
name: "action",
message: `Choose an action for the database "${database.name}":`,
choices: [
{ name: "Assign existing bucket", value: "assign" },
{ name: "Create new bucket", value: "create" },
],
},
]);
if (action === "assign") {
const selectedBuckets = await this.selectBuckets(
allBuckets.buckets.filter(
(b) => !globalBuckets.some((gb) => gb.$id === b.$id)
),
`Select a bucket for the database "${database.name}":`,
false // multiSelect = false
);
if (selectedBuckets.length > 0) {
const selectedBucket = selectedBuckets[0];
database.bucket = {
$id: selectedBucket.$id,
name: selectedBucket.name,
enabled: selectedBucket.enabled,
maximumFileSize: selectedBucket.maximumFileSize,
allowedFileExtensions: selectedBucket.allowedFileExtensions,
compression: selectedBucket.compression as Compression,
encryption: selectedBucket.encryption,
antivirus: selectedBucket.antivirus,
permissions: selectedBucket.$permissions.map((p) =>
permissionSchema.parse(p)
),
};
}
} else if (action === "create") {
const createdBucket = await this.createNewBucket(
storage,
database.name
);
database.bucket = {
...createdBucket,
compression: createdBucket.compression as Compression,
};
}
}
}
return config;
}
private async createNewBucket(
storage: Storage,
databaseName: string
): Promise<Models.Bucket> {
const {
bucketName,
bucketEnabled,
bucketMaximumFileSize,
bucketAllowedFileExtensions,
bucketFileSecurity,
bucketCompression,
bucketCompressionType,
bucketEncryption,
bucketAntivirus,
bucketId,
} = await inquirer.prompt([
{
type: "input",
name: "bucketName",
message: `Enter the name of the bucket for database "${databaseName}":`,
default: `${databaseName}-bucket`,
},
{
type: "confirm",
name: "bucketEnabled",
message: "Is the bucket enabled?",
default: true,
},
{
type: "confirm",
name: "bucketFileSecurity",
message: "Do you want to enable file security for the bucket?",
default: false,
},
{
type: "number",
name: "bucketMaximumFileSize",
message: "Enter the maximum file size for the bucket (MB):",
default: 1000000,
},
{
type: "input",
name: "bucketAllowedFileExtensions",
message:
"Enter the allowed file extensions for the bucket (comma separated):",
default: "",
},
{
type: "confirm",
name: "bucketCompression",
message: "Do you want to enable compression for the bucket?",
default: false,
},
{
type: "list",
name: "bucketCompressionType",
message: "Select the compression type for the bucket:",
choices: Object.values(Compression),
default: Compression.None,
when: (answers) => answers.bucketCompression,
},
{
type: "confirm",
name: "bucketEncryption",
message: "Do you want to enable encryption for the bucket?",
default: false,
},
{
type: "confirm",
name: "bucketAntivirus",
message: "Do you want to enable antivirus for the bucket?",
default: false,
},
{
type: "input",
name: "bucketId",
message: "Enter the ID of the bucket (or empty for auto-generation):",
},
]);
return await createBucket(
storage,
{
name: bucketName,
$permissions: [],
enabled: bucketEnabled,
fileSecurity: bucketFileSecurity,
maximumFileSize: bucketMaximumFileSize * 1024 * 1024,
allowedFileExtensions:
bucketAllowedFileExtensions.length > 0
? bucketAllowedFileExtensions?.split(",")
: [],
compression: bucketCompressionType as Compression,
encryption: bucketEncryption,
antivirus: bucketAntivirus,
},
bucketId.length > 0 ? bucketId : ulid()
);
}
private async syncDb(): Promise<void> {
console.log(chalk.blue("Pushing local configuration to Appwrite..."));
const databases = await this.selectDatabases(
this.getLocalDatabases(),
chalk.blue("Select local databases to push:"),
true
);
if (!databases.length) {
console.log(
chalk.yellow("No databases selected. Skipping database sync.")
);
return;
}
const collections = await this.selectCollections(
databases[0],
this.controller!.database!,
chalk.blue("Select local collections to push:"),
true,
true // prefer local
);
const { syncFunctions } = await inquirer.prompt([
{
type: "confirm",
name: "syncFunctions",
message: "Do you want to push local functions to remote?",
default: false,
},
]);
try {
// First sync databases and collections
await this.controller!.syncDb(databases, collections);
console.log(chalk.green("Database and collections pushed successfully"));
// Then handle functions if requested
if (syncFunctions && this.controller!.config?.functions?.length) {
const functions = await this.selectFunctions(
chalk.blue("Select local functions to push:"),
true,
true // prefer local
);
for (const func of functions) {
try {
await this.controller!.deployFunction(func.name);
console.log(
chalk.green(`Function ${func.name} deployed successfully`)
);
} catch (error) {
console.error(
chalk.red(`Failed to deploy function ${func.name}:`),
error
);
}
}
}
console.log(
chalk.green("Local configuration push completed successfully!")
);
} catch (error) {
console.error(chalk.red("Failed to push local configuration:"), error);
throw error;
}
}
private async synchronizeConfigurations(): Promise<void> {
console.log(chalk.blue("Synchronizing configurations..."));
await this.controller!.init();
// Sync databases, collections, and buckets
const { syncDatabases } = await inquirer.prompt([
{
type: "confirm",
name: "syncDatabases",
message: "Do you want to synchronize databases, collections, and their buckets?",
default: true,
},
]);
if (syncDatabases) {
const remoteDatabases = await fetchAllDatabases(
this.controller!.database!
);
// Use the controller's synchronizeConfigurations method which handles collections properly
console.log(chalk.blue("Pulling collections and generating collection files..."));
await this.controller!.synchronizeConfigurations(remoteDatabases);
// Also configure buckets for any new databases
const localDatabases = this.controller!.config?.databases || [];
const updatedConfig = await this.configureBuckets({
...this.controller!.config!,
databases: [
...localDatabases,
...remoteDatabases.filter(
(rd) => !localDatabases.some((ld) => ld.name === rd.name)
),
],
});
this.controller!.config = updatedConfig;
}
// Then sync functions
const { syncFunctions } = await inquirer.prompt([
{
type: "confirm",
name: "syncFunctions",
message: "Do you want to synchronize functions?",
default: true,
},
]);
if (syncFunctions) {
const remoteFunctions = await this.controller!.listAllFunctions();
const localFunctions = this.controller!.config?.functions || [];
const allFunctions = [
...remoteFunctions,
...localFunctions.filter(
(f) => !remoteFunctions.some((rf) => rf.$id === f.$id)
),
];
for (const func of allFunctions) {
const hasLocal = localFunctions.some((lf) => lf.$id === func.$id);
const hasRemote = remoteFunctions.some((rf) => rf.$id === func.$id);
if (hasLocal && hasRemote) {
// First try to find the function locally
let functionPath = join(
this.controller!.getAppwriteFolderPath()!,
"functions",
func.name
);
if (!fs.existsSync(functionPath)) {
console.log(
chalk.yellow(
`Function not found in primary location, searching subdirectories...`
)
);
const foundPath = await this.findFunctionInSubdirectories(
[this.controller!.getAppwriteFolderPath()!, process.cwd()],
func.name
);
if (foundPath) {
console.log(chalk.green(`Found function at: ${foundPath}`));
functionPath = foundPath;
}
}
const { preference } = await inquirer.prompt([
{
type: "list",
name: "preference",
message: `Function "${func.name}" ${
functionPath ? "found at " + functionPath : "not found locally"
}. What would you like to do?`,
choices: [
...(functionPath
? [
{
name: "Keep local version (deploy to remote)",
value: "local",
},
]
: []),
{ name: "Use remote version (download)", value: "remote" },
{ name: "Update config only", value: "config" },
{ name: "Skip this function", value: "skip" },
],
},
]);
if (preference === "local" && functionPath) {
await this.controller!.deployFunction(func.name);
} else if (preference === "remote") {
await downloadLatestFunctionDeployment(
this.controller!.appwriteServer!,
func.$id,
join(this.controller!.getAppwriteFolderPath()!, "functions")
);
} else if (preference === "config") {
const remoteFunction = await getFunction(
this.controller!.appwriteServer!,
func.$id
);
const newFunction = {
$id: remoteFunction.$id,
name: remoteFunction.name,
runtime: remoteFunction.runtime as Runtime,
execute: remoteFunction.execute || [],
events: remoteFunction.events || [],
schedule: remoteFunction.schedule || "",
timeout: remoteFunction.timeout || 15,
enabled: remoteFunction.enabled !== false,
logging: remoteFunction.logging !== false,
entrypoint: remoteFunction.entrypoint || "src/index.ts",
commands: remoteFunction.commands || "npm install",
scopes: (remoteFunction.scopes || []) as FunctionScope[],
installationId: remoteFunction.installationId,
providerRepositoryId: remoteFunction.providerRepositoryId,
providerBranch: remoteFunction.providerBranch,
providerSilentMode: remoteFunction.providerSilentMode,
providerRootDirectory: remoteFunction.providerRootDirectory,
specification: remoteFunction.specification as Specification,
};
const existingIndex = this.controller!.config!.functions!.findIndex(
(f) => f.$id === remoteFunction.$id
);
if (existingIndex >= 0) {
this.controller!.config!.functions![existingIndex] = newFunction;
} else {
this.controller!.config!.functions!.push(newFunction);
}
console.log(
chalk.green(`Updated config for function: ${func.name}`)
);
}
} else if (hasLocal) {
// Similar check for local-only functions
let functionPath = join(
this.controller!.getAppwriteFolderPath()!,
"functions",
func.name
);
if (!fs.existsSync(functionPath)) {
const foundPath = await this.findFunctionInSubdirectories(
[this.controller!.getAppwriteFolderPath()!, process.cwd()],
func.name
);
if (foundPath) {
functionPath = foundPath;
}
}
const { action } = await inquirer.prompt([
{
type: "list",
name: "action",
message: `Function "${func.name}" ${
functionPath ? "found at " + functionPath : "not found locally"
}. What would you like to do?`,
choices: [
...(functionPath
? [
{
name: "Deploy to remote",
value: "deploy",
},
]
: []),
{ name: "Skip this function", value: "skip" },
],
},
]);
if (action === "deploy" && functionPath) {
await this.controller!.deployFunction(func.name);
}
} else if (hasRemote) {
const { action } = await inquirer.prompt([
{
type: "list",
name: "action",
message: `Function "${func.name}" exists only remotely. What would you like to do?`,
choices: [
{ name: "Update config only", value: "config" },
{ name: "Download locally", value: "download" },
{ name: "Skip this function", value: "skip" },
],
},
]);
if (action === "download") {
await downloadLatestFunctionDeployment(
this.controller!.appwriteServer!,
func.$id,
join(this.controller!.getAppwriteFolderPath()!, "functions")
);
} else if (action === "config") {
const remoteFunction = await getFunction(
this.controller!.appwriteServer!,
func.$id
);
const newFunction = {
$id: remoteFunction.$id,
name: remoteFunction.name,
runtime: remoteFunction.runtime as Runtime,
execute: remoteFunction.execute || [],
events: remoteFunction.events || [],
schedule: remoteFunction.schedule || "",
timeout: remoteFunction.timeout || 15,
enabled: remoteFunction.enabled !== false,
logging: remoteFunction.logging !== false,
entrypoint: remoteFunction.entrypoint || "src/index.ts",
commands: remoteFunction.commands || "npm install",
scopes: (remoteFunction.scopes || []) as FunctionScope[],
installationId: remoteFunction.installationId,
providerRepositoryId: remoteFunction.providerRepositoryId,
providerBranch: remoteFunction.providerBranch,
providerSilentMode: remoteFunction.providerSilentMode,
providerRootDirectory: remoteFunction.providerRootDirectory,
specification: remoteFunction.specification as Specification,
};
this.controller!.config!.functions =
this.controller!.config!.functions || [];
this.controller!.config!.functions.push(newFunction);
console.log(
chalk.green(`Added config for remote function: ${func.name}`)
);
}
}
}
// Schema generation and collection file writing is handled by controller.synchronizeConfigurations()
}
console.log(chalk.green("โจ Configurations synchronized successfully!"));
}
private async backupDatabase(): Promise<void> {
if (!this.controller!.database) {
throw new Error(
"Database is not initialized, is the config file correct & created?"
);
}
const databases = await fetchAllDatabases(this.controller!.database);
const selectedDatabases = await this.selectDatabases(
databases,
"Select databases to backup:"
);
for (const db of selectedDatabases) {
console.log(chalk.yellow(`Backing up database: ${db.name}`));
await this.controller!.backupDatabase(db);
}
MessageFormatter.success("Database b