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.
750 lines (662 loc) ⢠24.3 kB
text/typescript
import inquirer from "inquirer";
import chalk from "chalk";
import type { Models } from "node-appwrite";
import { MessageFormatter } from "./messageFormatter.js";
import { logger } from "./logging.js";
/**
* Interface for sync selection summary
*/
export interface SyncSelectionSummary {
databases: DatabaseSelection[];
buckets: BucketSelection[];
totalDatabases: number;
totalTables: number;
totalBuckets: number;
newItems: {
databases: number;
tables: number;
buckets: number;
};
existingItems: {
databases: number;
tables: number;
buckets: number;
};
}
/**
* Database selection with associated tables
*/
export interface DatabaseSelection {
databaseId: string;
databaseName: string;
tableIds: string[];
tableNames: string[];
isNew: boolean;
}
/**
* Bucket selection with associated database
*/
export interface BucketSelection {
bucketId: string;
bucketName: string;
databaseId?: string;
databaseName?: string;
isNew: boolean;
}
/**
* Options for database selection
*/
export interface DatabaseSelectionOptions {
showSelectAll?: boolean;
allowNewOnly?: boolean;
defaultSelected?: string[];
}
/**
* Options for table selection
*/
export interface TableSelectionOptions {
showSelectAll?: boolean;
allowNewOnly?: boolean;
defaultSelected?: string[];
showDatabaseContext?: boolean;
}
/**
* Options for bucket selection
*/
export interface BucketSelectionOptions {
showSelectAll?: boolean;
allowNewOnly?: boolean;
defaultSelected?: string[];
groupByDatabase?: boolean;
}
/**
* Response from existing config prompt
*/
export interface ExistingConfigResponse {
syncExisting: boolean;
modifyConfiguration: boolean;
}
/**
* Comprehensive selection dialog system for enhanced sync flow
*
* This class provides interactive dialogs for selecting databases, tables/collections,
* and storage buckets during sync operations. It supports both new and existing
* configurations with visual indicators and comprehensive confirmation flows.
*
* @example
* ```typescript
* import { SelectionDialogs } from './shared/selectionDialogs.js';
* import type { Models } from 'node-appwrite';
*
* // Example usage in a sync command
* const availableDatabases: Models.Database[] = await getAvailableDatabases();
* const configuredDatabases = config.databases || [];
*
* // Prompt about existing configuration
* const { syncExisting, modifyConfiguration } = await SelectionDialogs.promptForExistingConfig(configuredDatabases);
*
* if (modifyConfiguration) {
* // Select databases
* const selectedDatabaseIds = await SelectionDialogs.selectDatabases(
* availableDatabases,
* configuredDatabases,
* { showSelectAll: true, allowNewOnly: !syncExisting }
* );
*
* // For each database, select tables
* const tableSelectionsMap = new Map<string, string[]>();
* const availableTablesMap = new Map<string, any[]>();
*
* for (const databaseId of selectedDatabaseIds) {
* const database = availableDatabases.find(db => db.$id === databaseId)!;
* const availableTables = await getTablesForDatabase(databaseId);
* const configuredTables = getConfiguredTablesForDatabase(databaseId);
*
* availableTablesMap.set(databaseId, availableTables);
*
* const selectedTableIds = await SelectionDialogs.selectTablesForDatabase(
* databaseId,
* database.name,
* availableTables,
* configuredTables,
* { showSelectAll: true, allowNewOnly: !syncExisting }
* );
*
* tableSelectionsMap.set(databaseId, selectedTableIds);
* }
*
* // Select buckets
* const availableBuckets = await getAvailableBuckets();
* const configuredBuckets = config.buckets || [];
* const selectedBucketIds = await SelectionDialogs.selectBucketsForDatabases(
* selectedDatabaseIds,
* availableBuckets,
* configuredBuckets,
* { showSelectAll: true, groupByDatabase: true }
* );
*
* // Create selection objects
* const databaseSelections = SelectionDialogs.createDatabaseSelection(
* selectedDatabaseIds,
* availableDatabases,
* tableSelectionsMap,
* configuredDatabases,
* availableTablesMap
* );
*
* const bucketSelections = SelectionDialogs.createBucketSelection(
* selectedBucketIds,
* availableBuckets,
* configuredBuckets,
* availableDatabases
* );
*
* // Show final confirmation
* const selectionSummary = SelectionDialogs.createSyncSelectionSummary(
* databaseSelections,
* bucketSelections
* );
*
* const confirmed = await SelectionDialogs.confirmSyncSelection(selectionSummary);
*
* if (confirmed) {
* // Proceed with sync operation
* await performSync(databaseSelections, bucketSelections);
* }
* }
* ```
*/
export class SelectionDialogs {
/**
* Prompts user about existing configuration
*/
static async promptForExistingConfig(configuredItems: any[]): Promise<ExistingConfigResponse> {
if (configuredItems.length === 0) {
return { syncExisting: false, modifyConfiguration: true };
}
MessageFormatter.section("Existing Configuration Found");
MessageFormatter.info(`Found ${configuredItems.length} configured items.`, { skipLogging: true });
const { syncExisting } = await inquirer.prompt([{
type: 'confirm',
name: 'syncExisting',
message: 'Sync existing configured items?',
default: true
}]);
if (!syncExisting) {
return { syncExisting: false, modifyConfiguration: true };
}
const { modifyConfiguration } = await inquirer.prompt([{
type: 'confirm',
name: 'modifyConfiguration',
message: 'Add/remove items from configuration?',
default: false
}]);
return { syncExisting, modifyConfiguration };
}
/**
* Shows database selection dialog with indicators for configured vs new databases
*/
static async selectDatabases(
availableDatabases: Models.Database[],
configuredDatabases: any[],
options: DatabaseSelectionOptions = {}
): Promise<string[]> {
const {
showSelectAll = true,
allowNewOnly = false,
defaultSelected = []
} = options;
MessageFormatter.section("Database Selection");
const configuredIds = new Set(configuredDatabases.map(db => db.$id || db.id));
let choices: any[] = [];
if (showSelectAll && availableDatabases.length > 1) {
choices.push({
name: chalk.green.bold('š Select All Databases'),
value: '__SELECT_ALL__',
short: 'All databases'
});
}
availableDatabases.forEach(database => {
const isConfigured = configuredIds.has(database.$id);
const status = isConfigured ? chalk.green('ā
') : chalk.blue('ā');
const name = `${status} ${database.name} (${database.$id})`;
if (allowNewOnly && isConfigured) {
return; // Skip configured databases if only allowing new ones
}
choices.push({
name,
value: database.$id,
short: database.name,
// Do not preselect anything unless explicitly provided
checked: defaultSelected.includes(database.$id)
});
});
if (choices.length === 0) {
MessageFormatter.warning("No databases available for selection.", { skipLogging: true });
return [];
}
const { selectedDatabaseIds } = await inquirer.prompt([{
type: 'checkbox',
name: 'selectedDatabaseIds',
message: 'Select databases to sync:',
choices,
validate: (input: string[]) => {
if (input.length === 0) {
return chalk.red('Please select at least one database.');
}
if (input.includes('__SELECT_ALL__') && input.length > 1) {
return chalk.red('Cannot select "Select All" with individual databases.');
}
return true;
}
}]);
// Handle select all
if (selectedDatabaseIds.includes('__SELECT_ALL__')) {
const allIds = availableDatabases.map(db => db.$id);
if (allowNewOnly) {
return allIds.filter(id => !configuredIds.has(id));
}
return allIds;
}
return selectedDatabaseIds;
}
/**
* Shows table/collection selection dialog for a specific database
*/
static async selectTablesForDatabase(
databaseId: string,
databaseName: string,
availableTables: any[],
configuredTables: any[],
options: TableSelectionOptions = {}
): Promise<string[]> {
const {
showSelectAll = true,
allowNewOnly = false,
defaultSelected = [],
showDatabaseContext = true
} = options;
if (showDatabaseContext) {
MessageFormatter.section(`Table Selection for ${databaseName}`);
}
const configuredIds = new Set(configuredTables.map(table => table.$id || table.id));
let choices: any[] = [];
if (showSelectAll && availableTables.length > 1) {
choices.push({
name: chalk.green.bold('š Select All Tables'),
value: '__SELECT_ALL__',
short: 'All tables'
});
}
availableTables.forEach(table => {
const isConfigured = configuredIds.has(table.$id);
const status = isConfigured ? chalk.green('ā
') : chalk.blue('ā');
const name = `${status} ${table.name} (${table.$id})`;
if (allowNewOnly && isConfigured) {
return; // Skip configured tables if only allowing new ones
}
choices.push({
name,
value: table.$id,
short: table.name,
// Do not preselect anything unless explicitly provided
checked: defaultSelected.includes(table.$id)
});
});
if (choices.length === 0) {
MessageFormatter.warning(`No tables available for database: ${databaseName}`, { skipLogging: true });
return [];
}
const { selectedTableIds } = await inquirer.prompt([{
type: 'checkbox',
name: 'selectedTableIds',
message: `Select tables to sync for ${databaseName}:`,
choices,
validate: (input: string[]) => {
if (input.length === 0) {
return chalk.red('Please select at least one table.');
}
if (input.includes('__SELECT_ALL__') && input.length > 1) {
return chalk.red('Cannot select "Select All" with individual tables.');
}
return true;
}
}]);
// Handle select all
if (selectedTableIds.includes('__SELECT_ALL__')) {
const allIds = availableTables.map(table => table.$id);
if (allowNewOnly) {
return allIds.filter(id => !configuredIds.has(id));
}
return allIds;
}
return selectedTableIds;
}
/**
* Shows bucket selection dialog for selected databases
*/
static async selectBucketsForDatabases(
selectedDatabaseIds: string[],
availableBuckets: any[],
configuredBuckets: any[],
options: BucketSelectionOptions = {}
): Promise<string[]> {
const {
showSelectAll = true,
allowNewOnly = false,
defaultSelected = [],
groupByDatabase = true
} = options;
MessageFormatter.section("Storage Bucket Selection");
const configuredIds = new Set(configuredBuckets.map(bucket => bucket.$id || bucket.id));
// Filter buckets that are associated with selected databases
const relevantBuckets = availableBuckets.filter(bucket => {
if (selectedDatabaseIds.length === 0) return true; // If no databases selected, show all buckets
return selectedDatabaseIds.includes(bucket.databaseId) || !bucket.databaseId;
});
if (relevantBuckets.length === 0) {
MessageFormatter.warning("No storage buckets available for selected databases.", { skipLogging: true });
return [];
}
let choices: any[] = [];
if (showSelectAll && relevantBuckets.length > 1) {
choices.push({
name: chalk.green.bold('š Select All Buckets'),
value: '__SELECT_ALL__',
short: 'All buckets'
});
}
if (groupByDatabase) {
// Group buckets by database
const bucketsByDatabase = new Map<string, any[]>();
relevantBuckets.forEach(bucket => {
const dbId = bucket.databaseId || 'ungrouped';
if (!bucketsByDatabase.has(dbId)) {
bucketsByDatabase.set(dbId, []);
}
bucketsByDatabase.get(dbId)!.push(bucket);
});
// Add buckets grouped by database
selectedDatabaseIds.forEach(dbId => {
const buckets = bucketsByDatabase.get(dbId) || [];
if (buckets.length > 0) {
choices.push(new inquirer.Separator(chalk.cyan(`š Database: ${dbId}`)));
buckets.forEach(bucket => {
const isConfigured = configuredIds.has(bucket.$id);
const status = isConfigured ? chalk.green('ā
') : chalk.blue('ā');
const name = `${status} ${bucket.name} (${bucket.$id})`;
if (allowNewOnly && isConfigured) {
return; // Skip configured buckets if only allowing new ones
}
choices.push({
name: ` ${name}`,
value: bucket.$id,
short: bucket.name,
// Do not preselect anything unless explicitly provided
checked: defaultSelected.includes(bucket.$id)
});
});
}
});
// Add ungrouped buckets
const ungroupedBuckets = bucketsByDatabase.get('ungrouped') || [];
if (ungroupedBuckets.length > 0) {
choices.push(new inquirer.Separator(chalk.cyan('š General Storage')));
ungroupedBuckets.forEach(bucket => {
const isConfigured = configuredIds.has(bucket.$id);
const status = isConfigured ? chalk.green('ā
') : chalk.blue('ā');
const name = `${status} ${bucket.name} (${bucket.$id})`;
if (allowNewOnly && isConfigured) {
return; // Skip configured buckets if only allowing new ones
}
choices.push({
name: ` ${name}`,
value: bucket.$id,
short: bucket.name,
checked: defaultSelected.includes(bucket.$id) || (!allowNewOnly && isConfigured)
});
});
}
} else {
// Flat list of buckets
relevantBuckets.forEach(bucket => {
const isConfigured = configuredIds.has(bucket.$id);
const status = isConfigured ? chalk.green('ā
') : chalk.blue('ā');
const dbContext = bucket.databaseId ? ` [${bucket.databaseId}]` : '';
const name = `${status} ${bucket.name} (${bucket.$id})${dbContext}`;
if (allowNewOnly && isConfigured) {
return; // Skip configured buckets if only allowing new ones
}
choices.push({
name,
value: bucket.$id,
short: bucket.name,
// Do not preselect anything unless explicitly provided
checked: defaultSelected.includes(bucket.$id)
});
});
}
const { selectedBucketIds } = await inquirer.prompt([{
type: 'checkbox',
name: 'selectedBucketIds',
message: 'Select storage buckets to sync:',
choices,
validate: (input: string[]) => {
if (input.length === 0) {
return chalk.yellow('No storage buckets selected. Continue with databases only?') || true;
}
if (input.includes('__SELECT_ALL__') && input.length > 1) {
return chalk.red('Cannot select "Select All" with individual buckets.');
}
return true;
}
}]);
// Handle select all
if (selectedBucketIds && selectedBucketIds.includes('__SELECT_ALL__')) {
const allIds = relevantBuckets.map(bucket => bucket.$id);
if (allowNewOnly) {
return allIds.filter(id => !configuredIds.has(id));
}
return allIds;
}
return selectedBucketIds || [];
}
/**
* Shows final confirmation dialog with sync selection summary
*/
static async confirmSyncSelection(
selectionSummary: SyncSelectionSummary,
operationType: 'push' | 'pull' | 'sync' = 'sync'
): Promise<boolean> {
const labels = {
push: {
banner: "Push Selection Summary",
subtitle: "Review selections before pushing to Appwrite",
confirm: "Proceed with push operation?",
success: "Push operation confirmed.",
cancel: "Push operation cancelled."
},
pull: {
banner: "Pull Selection Summary",
subtitle: "Review selections before pulling from Appwrite",
confirm: "Proceed with pull operation?",
success: "Pull operation confirmed.",
cancel: "Pull operation cancelled."
},
sync: {
banner: "Sync Selection Summary",
subtitle: "Review your selections before proceeding",
confirm: "Proceed with sync operation?",
success: "Sync operation confirmed.",
cancel: "Sync operation cancelled."
}
};
const label = labels[operationType];
MessageFormatter.banner(label.banner, label.subtitle);
// Database summary
console.log(chalk.bold.cyan("\nš Databases:"));
console.log(` Total: ${selectionSummary.totalDatabases}`);
console.log(` ${chalk.green('ā
Configured')}: ${selectionSummary.existingItems.databases}`);
console.log(` ${chalk.blue('ā New')}: ${selectionSummary.newItems.databases}`);
if (selectionSummary.databases.length > 0) {
console.log(chalk.gray("\n Selected databases:"));
selectionSummary.databases.forEach(db => {
const status = db.isNew ? chalk.blue('ā') : chalk.green('ā
');
console.log(` ${status} ${db.databaseName} (${db.tableNames.length} tables)`);
});
}
// Table summary
console.log(chalk.bold.cyan("\nš Tables/Collections:"));
console.log(` Total: ${selectionSummary.totalTables}`);
console.log(` ${chalk.green('ā
Configured')}: ${selectionSummary.existingItems.tables}`);
console.log(` ${chalk.blue('ā New')}: ${selectionSummary.newItems.tables}`);
// Bucket summary
console.log(chalk.bold.cyan("\nšŖ£ Storage Buckets:"));
console.log(` Total: ${selectionSummary.totalBuckets}`);
console.log(` ${chalk.green('ā
Configured')}: ${selectionSummary.existingItems.buckets}`);
console.log(` ${chalk.blue('ā New')}: ${selectionSummary.newItems.buckets}`);
if (selectionSummary.buckets.length > 0) {
console.log(chalk.gray("\n Selected buckets:"));
selectionSummary.buckets.forEach(bucket => {
const status = bucket.isNew ? chalk.blue('ā') : chalk.green('ā
');
const dbContext = bucket.databaseName ? ` [${bucket.databaseName}]` : '';
console.log(` ${status} ${bucket.bucketName}${dbContext}`);
});
}
console.log(); // Add spacing
const { confirmed } = await inquirer.prompt([{
type: 'confirm',
name: 'confirmed',
message: chalk.green.bold(label.confirm),
default: true
}]);
if (confirmed) {
MessageFormatter.success(label.success, { skipLogging: true });
logger.info(`${operationType} selection confirmed`, {
databases: selectionSummary.totalDatabases,
tables: selectionSummary.totalTables,
buckets: selectionSummary.totalBuckets
});
} else {
MessageFormatter.warning(label.cancel, { skipLogging: true });
logger.info(`${operationType} selection cancelled by user`);
}
return confirmed;
}
/**
* Creates a sync selection summary from selected items
*/
static createSyncSelectionSummary(
databaseSelections: DatabaseSelection[],
bucketSelections: BucketSelection[]
): SyncSelectionSummary {
const totalDatabases = databaseSelections.length;
const totalTables = databaseSelections.reduce((sum, db) => sum + db.tableIds.length, 0);
const totalBuckets = bucketSelections.length;
const newDatabases = databaseSelections.filter(db => db.isNew).length;
const newTables = databaseSelections.reduce((sum, db) =>
sum + db.tableIds.length, 0); // TODO: Track which tables are new
const newBuckets = bucketSelections.filter(bucket => bucket.isNew).length;
const existingDatabases = totalDatabases - newDatabases;
const existingTables = totalTables - newTables;
const existingBuckets = totalBuckets - newBuckets;
return {
databases: databaseSelections,
buckets: bucketSelections,
totalDatabases,
totalTables,
totalBuckets,
newItems: {
databases: newDatabases,
tables: newTables,
buckets: newBuckets
},
existingItems: {
databases: existingDatabases,
tables: existingTables,
buckets: existingBuckets
}
};
}
/**
* Helper method to create database selection objects
*/
static createDatabaseSelection(
selectedDatabaseIds: string[],
availableDatabases: Models.Database[],
tableSelectionsMap: Map<string, string[]>,
configuredDatabases: any[],
availableTablesMap: Map<string, any[]> = new Map()
): DatabaseSelection[] {
const configuredIds = new Set(configuredDatabases.map(db => db.$id || db.id));
return selectedDatabaseIds.map(databaseId => {
const database = availableDatabases.find(db => db.$id === databaseId);
if (!database) {
throw new Error(`Database with ID ${databaseId} not found in available databases`);
}
const tableIds = tableSelectionsMap.get(databaseId) || [];
const tables = availableTablesMap.get(databaseId) || [];
const tableNames: string[] = tables.map(table => table.name || table.$id || `Table-${table.$id}`);
return {
databaseId,
databaseName: database.name,
tableIds,
tableNames,
isNew: !configuredIds.has(databaseId)
};
});
}
/**
* Helper method to create bucket selection objects
*/
static createBucketSelection(
selectedBucketIds: string[],
availableBuckets: any[],
configuredBuckets: any[],
availableDatabases: Models.Database[]
): BucketSelection[] {
const configuredIds = new Set(configuredBuckets.map(bucket => bucket.$id || bucket.id));
return selectedBucketIds.map(bucketId => {
const bucket = availableBuckets.find(b => b.$id === bucketId);
if (!bucket) {
throw new Error(`Bucket with ID ${bucketId} not found in available buckets`);
}
const database = bucket.databaseId ?
availableDatabases.find(db => db.$id === bucket.databaseId) : undefined;
return {
bucketId,
bucketName: bucket.name,
databaseId: bucket.databaseId,
databaseName: database?.name,
isNew: !configuredIds.has(bucketId)
};
});
}
/**
* Shows a progress message during selection operations
*/
static showProgress(message: string): void {
MessageFormatter.progress(message, { skipLogging: true });
}
/**
* Shows an error message and handles graceful cancellation
*/
static showError(message: string, error?: Error): void {
MessageFormatter.error(message, error, { skipLogging: true });
logger.error(`Selection dialog error: ${message}`, { error: error?.message });
}
/**
* Shows a warning message
*/
static showWarning(message: string): void {
MessageFormatter.warning(message, { skipLogging: true });
logger.warn(`Selection dialog warning: ${message}`);
}
/**
* Shows a success message
*/
static showSuccess(message: string): void {
MessageFormatter.success(message, { skipLogging: true });
logger.info(`Selection dialog success: ${message}`);
}
}