UNPKG

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.

645 lines (644 loc) 34.9 kB
import inquirer from "inquirer"; import chalk from "chalk"; import { join } from "node:path"; import { Query } from "node-appwrite"; import { MessageFormatter } from "../../shared/messageFormatter.js"; import { ConfirmationDialogs } from "../../shared/confirmationDialogs.js"; import { SelectionDialogs } from "../../shared/selectionDialogs.js"; import { logger } from "../../shared/logging.js"; import { fetchAllDatabases } from "../../databases/methods.js"; import { listBuckets } from "../../storage/methods.js"; import { getFunction, downloadLatestFunctionDeployment } from "../../functions/methods.js"; import { wipeTableRows } from "../../collections/wipeOperations.js"; export const databaseCommands = { async syncDb(cli) { MessageFormatter.progress("Pushing local configuration to Appwrite...", { prefix: "Database" }); try { // Initialize controller await cli.controller.init(); // Get available and configured databases const availableDatabases = await fetchAllDatabases(cli.controller.database); const configuredDatabases = cli.controller.config?.databases || []; // Get local collections for selection const localCollections = cli.getLocalCollections(); // Push operations always use local configuration as source of truth // Select databases const selectedDatabaseIds = await SelectionDialogs.selectDatabases(availableDatabases, configuredDatabases, { showSelectAll: false, allowNewOnly: false, defaultSelected: [] }); if (selectedDatabaseIds.length === 0) { MessageFormatter.warning("No databases selected. Skipping database sync.", { prefix: "Database" }); return; } // Select tables/collections for each database using the existing method const tableSelectionsMap = new Map(); const availableTablesMap = new Map(); for (const databaseId of selectedDatabaseIds) { const database = availableDatabases.find(db => db.$id === databaseId); // Use the existing selectCollectionsAndTables method const selectedCollections = await cli.selectCollectionsAndTables(database, cli.controller.database, chalk.blue(`Select collections/tables to push to "${database.name}":`), true, // multiSelect true, // prefer local true // shouldFilterByDatabase ); // Map selected collections to table IDs const selectedTableIds = selectedCollections.map((c) => c.$id || c.id); // Store selections tableSelectionsMap.set(databaseId, selectedTableIds); availableTablesMap.set(databaseId, selectedCollections); if (selectedCollections.length === 0) { MessageFormatter.warning(`No collections selected for database "${database.name}". Skipping.`, { prefix: "Database" }); continue; } } // Ask if user wants to select buckets const { selectBuckets } = await inquirer.prompt([ { type: "confirm", name: "selectBuckets", message: "Do you want to select storage buckets to sync as well?", default: false, }, ]); let bucketSelections = []; if (selectBuckets) { // Get available and configured buckets try { const availableBucketsResponse = await listBuckets(cli.controller.storage); const availableBuckets = availableBucketsResponse.buckets || []; const configuredBuckets = cli.controller.config?.buckets || []; if (availableBuckets.length === 0) { MessageFormatter.warning("No storage buckets available in remote instance.", { prefix: "Database" }); } else { // Select buckets using SelectionDialogs const selectedBucketIds = await SelectionDialogs.selectBucketsForDatabases(selectedDatabaseIds, availableBuckets, configuredBuckets, { showSelectAll: false, groupByDatabase: true, defaultSelected: [] }); if (selectedBucketIds.length > 0) { // Create BucketSelection objects bucketSelections = SelectionDialogs.createBucketSelection(selectedBucketIds, availableBuckets, configuredBuckets, availableDatabases); MessageFormatter.info(`Selected ${bucketSelections.length} storage bucket(s)`, { prefix: "Database" }); } } } catch (error) { MessageFormatter.warning("Failed to fetch storage buckets. Continuing with databases only.", { prefix: "Database" }); logger.warn("Storage bucket fetch failed during syncDb", { error: error instanceof Error ? error.message : String(error) }); } } // Create DatabaseSelection objects const databaseSelections = SelectionDialogs.createDatabaseSelection(selectedDatabaseIds, availableDatabases, tableSelectionsMap, configuredDatabases, availableTablesMap); // Show confirmation summary const selectionSummary = SelectionDialogs.createSyncSelectionSummary(databaseSelections, bucketSelections); const confirmed = await SelectionDialogs.confirmSyncSelection(selectionSummary, 'push'); if (!confirmed) { MessageFormatter.info("Push operation cancelled by user", { prefix: "Database" }); return; } // Perform selective push using the controller MessageFormatter.progress("Starting selective push...", { prefix: "Database" }); await cli.controller.selectivePush(databaseSelections, bucketSelections); MessageFormatter.success("\n✅ All database configurations pushed successfully!", { prefix: "Database" }); // Then handle functions if requested const { syncFunctions } = await inquirer.prompt([ { type: "confirm", name: "syncFunctions", message: "Do you want to push local functions to remote?", default: false, }, ]); if (syncFunctions && cli.controller.config?.functions?.length) { const functions = await cli.selectFunctions(chalk.blue("Select local functions to push:"), true, true // prefer local ); for (const func of functions) { try { await cli.controller.deployFunction(func.name); MessageFormatter.success(`Function ${func.name} deployed successfully`, { prefix: "Functions" }); } catch (error) { MessageFormatter.error(`Failed to deploy function ${func.name}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Functions" }); } } } MessageFormatter.success("Local configuration push completed successfully!", { prefix: "Database" }); } catch (error) { MessageFormatter.error("Failed to push local configuration", error instanceof Error ? error : new Error(String(error)), { prefix: "Database" }); throw error; } }, async synchronizeConfigurations(cli) { MessageFormatter.progress("Synchronizing configurations...", { prefix: "Config" }); await cli.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(cli.controller.database); // First, prepare the combined database list for bucket configuration const localDatabases = cli.controller.config?.databases || []; const allDatabases = [ ...localDatabases, ...remoteDatabases.filter((rd) => !localDatabases.some((ld) => ld.name === rd.name)), ]; // Configure buckets FIRST to get user selections before writing config MessageFormatter.progress("Configuring storage buckets...", { prefix: "Buckets" }); const configWithBuckets = await cli.configureBuckets({ ...cli.controller.config, databases: allDatabases, }); // Update controller config with bucket selections cli.controller.config = configWithBuckets; // Now synchronize configurations with the updated config that includes bucket selections MessageFormatter.progress("Pulling collections and generating collection files...", { prefix: "Collections" }); await cli.controller.synchronizeConfigurations(remoteDatabases, configWithBuckets); } // 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 cli.controller.listAllFunctions(); const localFunctions = cli.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) { // Function exists in both local and remote const { preference } = await inquirer.prompt([ { type: "list", name: "preference", message: `Function "${func.name}" exists both locally and remotely. What would you like to do?`, choices: [ { 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") { await cli.controller.deployFunction(func.name); } else if (preference === "remote") { await downloadLatestFunctionDeployment(cli.controller.appwriteServer, func.$id, join(cli.controller.getAppwriteFolderPath(), "functions")); } else if (preference === "config") { // Update config with remote function details const remoteFunction = await getFunction(cli.controller.appwriteServer, func.$id); const newFunction = { $id: remoteFunction.$id, name: remoteFunction.name, runtime: remoteFunction.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 || [], installationId: remoteFunction.installationId, providerRepositoryId: remoteFunction.providerRepositoryId, providerBranch: remoteFunction.providerBranch, providerSilentMode: remoteFunction.providerSilentMode, providerRootDirectory: remoteFunction.providerRootDirectory, specification: remoteFunction.specification, }; const existingIndex = cli.controller.config.functions.findIndex((f) => f.$id === remoteFunction.$id); if (existingIndex >= 0) { cli.controller.config.functions[existingIndex] = newFunction; } else { cli.controller.config.functions.push(newFunction); } MessageFormatter.success(`Updated config for function: ${func.name}`, { prefix: "Functions" }); } } else if (hasLocal) { // Function exists only locally const { action } = await inquirer.prompt([ { type: "list", name: "action", message: `Function "${func.name}" exists only locally. What would you like to do?`, choices: [ { name: "Deploy to remote", value: "deploy" }, { name: "Skip this function", value: "skip" }, ], }, ]); if (action === "deploy") { await cli.controller.deployFunction(func.name); } } else if (hasRemote) { // Function exists only remotely 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(cli.controller.appwriteServer, func.$id, join(cli.controller.getAppwriteFolderPath(), "functions")); } else if (action === "config") { const remoteFunction = await getFunction(cli.controller.appwriteServer, func.$id); const newFunction = { $id: remoteFunction.$id, name: remoteFunction.name, runtime: remoteFunction.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 || [], installationId: remoteFunction.installationId, providerRepositoryId: remoteFunction.providerRepositoryId, providerBranch: remoteFunction.providerBranch, providerSilentMode: remoteFunction.providerSilentMode, providerRootDirectory: remoteFunction.providerRootDirectory, specification: remoteFunction.specification, }; cli.controller.config.functions = cli.controller.config.functions || []; cli.controller.config.functions.push(newFunction); MessageFormatter.success(`Added config for remote function: ${func.name}`, { prefix: "Functions" }); } } } } MessageFormatter.success("✨ Configurations synchronized successfully!", { prefix: "Config" }); }, async backupDatabase(cli) { if (!cli.controller.database || !cli.controller.storage) { throw new Error("Database or Storage is not initialized, is the config file correct & created?"); } try { // STEP 1: Select tracking database MessageFormatter.info("Step 1/5: Select tracking database", { prefix: "Backup" }); const trackingDb = await this.selectTrackingDatabase(cli); // STEP 2: Ensure backup tracking table exists MessageFormatter.info("Step 2/5: Initializing backup tracking", { prefix: "Backup" }); await this.ensureBackupTrackingTable(cli, trackingDb); // STEP 3: Select backup scope MessageFormatter.info("Step 3/5: Select backup scope", { prefix: "Backup" }); const scope = await this.selectBackupScope(cli); // STEP 4: Show confirmation MessageFormatter.info("Step 4/5: Confirm backup plan", { prefix: "Backup" }); const confirmed = await this.confirmBackupPlan(scope); if (!confirmed) { MessageFormatter.info("Backup cancelled by user", { prefix: "Backup" }); return; } // STEP 5: Execute unified backup MessageFormatter.info("Step 5/5: Executing backup", { prefix: "Backup" }); await this.executeUnifiedBackup(cli, trackingDb, scope); MessageFormatter.success("Backup operation completed successfully", { prefix: "Backup" }); } catch (error) { MessageFormatter.error("Backup operation failed", error instanceof Error ? error : new Error(String(error)), { prefix: "Backup" }); throw error; } }, // Helper method: Select tracking database async selectTrackingDatabase(cli) { const databases = await fetchAllDatabases(cli.controller.database); const { trackingDatabaseId } = await inquirer.prompt([ { type: "list", name: "trackingDatabaseId", message: "Select database to store backup metadata:", choices: databases.map(db => ({ name: `${db.name} (${db.$id})`, value: db.$id })) } ]); MessageFormatter.info(`Using ${trackingDatabaseId} for backup tracking`, { prefix: "Backup" }); return trackingDatabaseId; }, // Helper method: Ensure backup tracking table exists async ensureBackupTrackingTable(cli, trackingDatabaseId) { const { createCentralizedBackupTrackingTable } = await import("../../backups/tracking/centralizedTracking.js"); const adapter = cli.controller.adapter; await createCentralizedBackupTrackingTable(adapter, trackingDatabaseId); MessageFormatter.success("Backup tracking table ready", { prefix: "Backup" }); }, // Helper method: Select backup scope async selectBackupScope(cli) { const { scopeType } = await inquirer.prompt([ { type: "list", name: "scopeType", message: "What would you like to backup?", choices: [ { name: "Comprehensive (ALL databases + ALL buckets)", value: "comprehensive" }, { name: "Selective databases (choose specific databases)", value: "selective-databases" }, { name: "Selective collections (choose specific collections)", value: "selective-collections" } ] } ]); if (scopeType === "comprehensive") { return { type: "comprehensive" }; } if (scopeType === "selective-databases") { const databases = await fetchAllDatabases(cli.controller.database); const selectedDatabases = await cli.selectDatabases(databases, "Select databases to backup:"); const { includeBuckets } = await inquirer.prompt([ { type: "confirm", name: "includeBuckets", message: "Include storage buckets in backup?", default: false } ]); let selectedBuckets = []; if (includeBuckets) { const buckets = await listBuckets(cli.controller.storage); const { bucketIds } = await inquirer.prompt([ { type: "checkbox", name: "bucketIds", message: "Select buckets to backup:", choices: buckets.buckets.map((b) => ({ name: `${b.name} (${b.$id})`, value: b.$id })) } ]); selectedBuckets = bucketIds; } return { type: "selective-databases", databases: selectedDatabases, buckets: selectedBuckets }; } if (scopeType === "selective-collections") { const databases = await fetchAllDatabases(cli.controller.database); const selectedDatabase = await cli.selectDatabases(databases, "Select database containing collections:", false // single selection ); if (!selectedDatabase || selectedDatabase.length === 0) { throw new Error("No database selected"); } const db = selectedDatabase[0]; const collections = await cli.selectCollectionsAndTables(db, cli.controller.database, "Select collections to backup:", true, true, true); return { type: "selective-collections", databaseId: db.$id, databaseName: db.name, collections: collections }; } throw new Error("Invalid backup scope selected"); }, // Helper method: Confirm backup plan async confirmBackupPlan(scope) { let summary = "\n" + chalk.bold("Backup Plan Summary:") + "\n"; if (scope.type === "comprehensive") { summary += " • ALL databases\n"; summary += " • ALL storage buckets\n"; } else if (scope.type === "selective-databases") { summary += ` • ${scope.databases.length} selected databases\n`; if (scope.buckets.length > 0) { summary += ` • ${scope.buckets.length} selected buckets\n`; } } else if (scope.type === "selective-collections") { summary += ` • Database: ${scope.databaseName}\n`; summary += ` • ${scope.collections.length} selected collections\n`; } console.log(summary); const { confirmed } = await inquirer.prompt([ { type: "confirm", name: "confirmed", message: "Proceed with backup?", default: true } ]); return confirmed; }, // Helper method: Execute unified backup async executeUnifiedBackup(cli, trackingDatabaseId, scope) { if (scope.type === "comprehensive") { const { comprehensiveBackup } = await import("../../backups/operations/comprehensiveBackup.js"); await comprehensiveBackup(cli.controller.config, cli.controller.database, cli.controller.storage, cli.controller.adapter, { trackingDatabaseId, backupFormat: 'zip', parallelDownloads: 10, onProgress: (message) => { MessageFormatter.progress(message, { prefix: "Backup" }); } }); } else if (scope.type === "selective-databases") { // Backup each selected database for (const db of scope.databases) { MessageFormatter.progress(`Backing up database: ${db.name}`, { prefix: "Backup" }); await cli.controller.backupDatabase(db); } // Backup selected buckets if any for (const bucketId of scope.buckets) { MessageFormatter.progress(`Backing up bucket: ${bucketId}`, { prefix: "Backup" }); const { backupBucket } = await import("../../backups/operations/bucketBackup.js"); await backupBucket(cli.controller.storage, bucketId, "appwrite-backups", { parallelDownloads: 10 }); } } else if (scope.type === "selective-collections") { const { backupCollections } = await import("../../backups/operations/collectionBackup.js"); await backupCollections(cli.controller.config, cli.controller.database, cli.controller.storage, cli.controller.adapter, { trackingDatabaseId, databaseId: scope.databaseId, collectionIds: scope.collections.map((c) => c.$id || c.id), backupFormat: 'zip', onProgress: (message) => { MessageFormatter.progress(message, { prefix: "Backup" }); } }); } }, async wipeDatabase(cli) { if (!cli.controller.database || !cli.controller.storage) { throw new Error("Database or Storage is not initialized, is the config file correct & created?"); } const databases = await fetchAllDatabases(cli.controller.database); const storage = await listBuckets(cli.controller.storage); const selectedDatabases = await cli.selectDatabases(databases, "Select databases to wipe:"); const { selectedStorage } = await inquirer.prompt([ { type: "checkbox", name: "selectedStorage", message: "Select storage buckets to wipe:", choices: storage.buckets.map((s) => ({ name: s.name, value: s.$id })), }, ]); const { wipeUsers } = await inquirer.prompt([ { type: "confirm", name: "wipeUsers", message: "Do you want to wipe users as well?", default: false, }, ]); const databaseNames = selectedDatabases.map((db) => db.name); const confirmed = await ConfirmationDialogs.confirmDatabaseWipe(databaseNames, { includeStorage: selectedStorage.length > 0, includeUsers: wipeUsers }); if (confirmed) { MessageFormatter.info("Starting wipe operation...", { prefix: "Wipe" }); for (const db of selectedDatabases) { await cli.controller.wipeDatabase(db); } for (const bucketId of selectedStorage) { await cli.controller.wipeDocumentStorage(bucketId); } if (wipeUsers) { await cli.controller.wipeUsers(); } MessageFormatter.success("Wipe operation completed", { prefix: "Wipe" }); } else { MessageFormatter.info("Wipe operation cancelled", { prefix: "Wipe" }); } }, async wipeCollections(cli) { if (!cli.controller.database) { throw new Error("Database is not initialized, is the config file correct & created?"); } const databases = await fetchAllDatabases(cli.controller.database); const selectedDatabases = await cli.selectDatabases(databases, "Select the database(s) containing the collections to wipe:", true); for (const database of selectedDatabases) { const collections = await cli.selectCollectionsAndTables(database, cli.controller.database, `Select collections/tables to wipe from ${database.name}:`, true, undefined, true); const collectionNames = collections.map((c) => c.name); const confirmed = await ConfirmationDialogs.confirmCollectionWipe(database.name, collectionNames); if (confirmed) { MessageFormatter.info(`Wiping selected collections from ${database.name}...`, { prefix: "Wipe" }); for (const collection of collections) { await cli.controller.wipeCollection(database, collection); MessageFormatter.success(`Collection ${collection.name} wiped successfully`, { prefix: "Wipe" }); } } else { MessageFormatter.info(`Wipe operation cancelled for ${database.name}`, { prefix: "Wipe" }); } } MessageFormatter.success("Wipe collections operation completed", { prefix: "Wipe" }); }, async wipeTablesData(cli) { const controller = cli.controller; if (!controller?.adapter) { throw new Error("Database adapter is not initialized. TablesDB operations require adapter support."); } try { // Step 1: Select database (single selection for clearer UX) const databases = await fetchAllDatabases(controller.database); if (!databases || databases.length === 0) { MessageFormatter.warning("No databases found", { prefix: "Wipe" }); return; } const { selectedDatabase } = await inquirer.prompt([ { type: "list", name: "selectedDatabase", message: "Select database containing tables to wipe:", choices: databases.map((db) => ({ name: `${db.name} (${db.$id})`, value: db })) } ]); const database = selectedDatabase; // Step 2: Get available tables const adapter = controller.adapter; const tablesResponse = await adapter.listTables({ databaseId: database.$id, queries: [Query.limit(500)] }); const availableTables = tablesResponse.tables || []; if (availableTables.length === 0) { MessageFormatter.warning(`No tables found in database: ${database.name}`, { prefix: "Wipe" }); return; } // Step 3: Select tables using existing SelectionDialogs const selectedTableIds = await SelectionDialogs.selectTablesForDatabase(database.$id, database.name, availableTables, [], // No configured tables context needed for wipe { showSelectAll: true, allowNewOnly: false, defaultSelected: [] }); if (selectedTableIds.length === 0) { MessageFormatter.warning("No tables selected. Operation cancelled.", { prefix: "Wipe" }); return; } // Step 4: Show confirmation with table details const selectedTables = availableTables.filter((t) => selectedTableIds.includes(t.$id)); const tableNames = selectedTables.map((t) => t.name); console.log(chalk.yellow.bold("\n⚠️ WARNING: Table Row Wipe Operation")); console.log(chalk.yellow("This will delete ALL ROWS from the selected tables.")); console.log(chalk.yellow("The table structures will remain intact.\n")); console.log(chalk.cyan("Database:"), chalk.white(database.name)); console.log(chalk.cyan("Tables to wipe:")); tableNames.forEach((name) => console.log(chalk.white(` • ${name}`))); console.log(); const { confirmed } = await inquirer.prompt([ { type: "confirm", name: "confirmed", message: chalk.red.bold("Are you ABSOLUTELY SURE you want to wipe these table rows?"), default: false } ]); if (!confirmed) { MessageFormatter.info("Wipe operation cancelled by user", { prefix: "Wipe" }); return; } // Step 5: Execute wipe using existing wipeTableRows function MessageFormatter.progress("Starting table row wipe operation...", { prefix: "Wipe" }); for (const table of selectedTables) { try { MessageFormatter.info(`Wiping rows from table: ${table.name}`, { prefix: "Wipe" }); // Use existing wipeTableRows from wipeOperations.ts await wipeTableRows(adapter, database.$id, table.$id); MessageFormatter.success(`Successfully wiped rows from table: ${table.name}`, { prefix: "Wipe" }); } catch (error) { MessageFormatter.error(`Failed to wipe table ${table.name}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Wipe" }); } } MessageFormatter.success(`Wipe operation completed for ${selectedTables.length} table(s)`, { prefix: "Wipe" }); } catch (error) { MessageFormatter.error("Table wipe operation failed", error instanceof Error ? error : new Error(String(error)), { prefix: "Wipe" }); throw error; } } };