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.

850 lines (759 loc) 27 kB
import fs from "fs"; import path from "path"; import type { AppwriteConfig, Collection, CollectionCreate, AppwriteFunction } from "appwrite-utils"; import { ConfigDiscoveryService, ConfigLoaderService, ConfigMergeService, ConfigValidationService, SessionAuthService, type ConfigOverrides, type SessionAuthInfo, type AuthenticationStatus, type ValidationResult, } from "./services/index.js"; import { MessageFormatter } from "../shared/messageFormatter.js"; import { logger } from "../shared/logging.js"; import { detectAppwriteVersionCached, type ApiMode } from "../utils/versionDetection.js"; /** * Database type from AppwriteConfig */ type Database = { $id: string; name: string; bucket?: any; }; /** * Bucket type from AppwriteConfig */ type Bucket = { $id: string; name: string; $permissions?: any[]; [key: string]: any; }; /** * Options for loading configuration */ export interface ConfigLoadOptions { /** * Directory to start searching for config files * @default process.cwd() */ configDir?: string; /** * Force reload configuration even if cached * @default false */ forceReload?: boolean; /** * Validate configuration after loading * @default false */ validate?: boolean; /** * Use strict validation mode (warnings as errors) * @default false */ strictMode?: boolean; /** * Report validation results to console * @default false */ reportValidation?: boolean; /** * Override configuration values */ overrides?: ConfigOverrides; /** * Override session authentication (used for preserving session on reload) */ sessionOverride?: SessionAuthInfo; /** * Prefer loading from appwrite.config.json over config.yaml * @default false */ preferJson?: boolean; } /** * Filter function for collections (accepts both Collection and CollectionCreate) */ export type CollectionFilter = (collection: Collection | CollectionCreate) => boolean; /** * Centralized configuration manager with intelligent caching and session management. * * This singleton provides a unified interface for: * - Configuration discovery and loading (YAML, TypeScript, JSON) * - Session authentication with automatic caching * - Configuration validation and merging * - Session preservation across reloads * - File watching for configuration changes * * @example * ```typescript * const configManager = ConfigManager.getInstance(); * * // Load configuration (cached after first call) * const config = await configManager.loadConfig({ * validate: true, * reportValidation: true, * }); * * // Get cached config synchronously * const cachedConfig = configManager.getConfig(); * * // Reload with session preservation * const reloadedConfig = await configManager.reloadConfig(); * * // Watch for config changes * const unwatch = configManager.watchConfig(async (config) => { * console.log("Config changed:", config); * }); * ``` */ export class ConfigManager { // ────────────────────────────────────────────────── // SINGLETON PATTERN // ────────────────────────────────────────────────── private static instance: ConfigManager | null = null; /** * Get the ConfigManager singleton instance * * @returns The singleton ConfigManager instance * * @example * ```typescript * const configManager = ConfigManager.getInstance(); * ``` */ public static getInstance(): ConfigManager { if (!ConfigManager.instance) { ConfigManager.instance = new ConfigManager(); } return ConfigManager.instance; } /** * Reset the singleton instance (useful for testing) * * @example * ```typescript * ConfigManager.resetInstance(); * const newInstance = ConfigManager.getInstance(); * ``` */ public static resetInstance(): void { ConfigManager.instance = null; } // ────────────────────────────────────────────────── // STATE (Private) // ────────────────────────────────────────────────── private cachedConfig: AppwriteConfig | null = null; private cachedConfigPath: string | null = null; private cachedSession: SessionAuthInfo | null = null; private lastLoadTimestamp: number = 0; private isInitialized: boolean = false; // Service dependencies private discoveryService: ConfigDiscoveryService; private loaderService: ConfigLoaderService; private validationService: ConfigValidationService; private sessionService: SessionAuthService; private mergeService: ConfigMergeService; // ────────────────────────────────────────────────── // CONSTRUCTOR // ────────────────────────────────────────────────── /** * Private constructor to enforce singleton pattern. * Use ConfigManager.getInstance() instead. */ private constructor() { // Initialize all services this.discoveryService = new ConfigDiscoveryService(); this.loaderService = new ConfigLoaderService(); this.validationService = new ConfigValidationService(); this.sessionService = new SessionAuthService(); this.mergeService = new ConfigMergeService(); logger.debug("ConfigManager instance created", { prefix: "ConfigManager" }); } // ────────────────────────────────────────────────── // CORE CONFIGURATION METHODS // ────────────────────────────────────────────────── /** * Load configuration with intelligent caching and session management. * * This method orchestrates the entire configuration loading flow: * 1. Returns cached config if available (unless forceReload is true) * 2. Discovers config file using ConfigDiscoveryService * 3. Loads config from file using ConfigLoaderService * 4. Loads and merges session authentication using SessionAuthService * 5. Applies CLI/environment overrides using ConfigMergeService * 6. Validates configuration if requested * 7. Caches the result for future calls * * @param options - Configuration loading options * @returns The loaded and processed configuration * @throws Error if no configuration file is found * @throws Error if validation fails in strict mode * * @example * ```typescript * const config = await configManager.loadConfig({ * configDir: process.cwd(), * validate: true, * strictMode: true, * reportValidation: true, * overrides: { * appwriteEndpoint: "https://custom.endpoint.com/v1" * } * }); * ``` */ public async loadConfig(options: ConfigLoadOptions = {}): Promise<AppwriteConfig> { // 1. Return cache if available and not forcing reload if (this.cachedConfig && !options.forceReload) { logger.debug("Returning cached config", { prefix: "ConfigManager" }); return this.getCachedConfigWithOverrides(options.overrides); } logger.debug("Loading config from file", { prefix: "ConfigManager", options }); // 2. Discover config file const configPath = await this.discoveryService.findConfig( options.configDir || process.cwd(), options.preferJson || false ); if (!configPath) { const searchDir = options.configDir || process.cwd(); throw new Error( `No Appwrite configuration found in "${searchDir}".\n` + `Searched for: YAML (.appwrite/config.yaml), TypeScript (appwriteConfig.ts), or JSON (appwrite.json).\n` + `Suggestion: Create a configuration file using "npx appwrite-migrate --init" or refer to the documentation.` ); } logger.debug(`Config discovered at: ${configPath}`, { prefix: "ConfigManager" }); // 3. Load config from file let config = await this.loaderService.loadFromPath(configPath); // 4. Load session authentication const session = options.sessionOverride || (await this.sessionService.findSession(config.appwriteEndpoint, config.appwriteProject)); // 5. Merge session into config if (session) { logger.debug("Merging session authentication into config", { prefix: "ConfigManager" }); config = this.mergeService.mergeSession(config, session); this.cachedSession = session; } // 6. Apply CLI/env overrides if (options.overrides) { logger.debug("Applying configuration overrides", { prefix: "ConfigManager" }); config = this.mergeService.applyOverrides(config, options.overrides); } // 7. Validate if requested if (options.validate) { logger.debug("Validating configuration", { prefix: "ConfigManager", strictMode: options.strictMode }); const validation = options.strictMode ? this.validationService.validateStrict(config) : this.validationService.validate(config); if (options.reportValidation) { this.validationService.reportResults(validation, { verbose: false }); } if (options.strictMode && !validation.isValid) { throw new Error( `Configuration validation failed in strict mode.\n` + `Errors: ${validation.errors.length}, Warnings: ${validation.warnings.length}\n` + `Run with reportValidation: true to see details.` ); } } // 8. Run version detection and set apiMode if not explicitly configured if (!config.apiMode || config.apiMode === 'auto') { try { logger.debug('Running version detection for API mode detection', { prefix: "ConfigManager", endpoint: config.appwriteEndpoint }); const versionResult = await detectAppwriteVersionCached( config.appwriteEndpoint, config.appwriteProject, config.appwriteKey ); config.apiMode = versionResult.apiMode; logger.info(`API mode detected: ${config.apiMode}`, { prefix: "ConfigManager", method: versionResult.detectionMethod, confidence: versionResult.confidence, serverVersion: versionResult.serverVersion, }); } catch (error) { logger.warn('Version detection failed, defaulting to legacy mode', { prefix: "ConfigManager", error: error instanceof Error ? error.message : 'Unknown error', }); config.apiMode = 'legacy'; } } // 8. Cache the config this.cachedConfig = config; this.cachedConfigPath = configPath; this.lastLoadTimestamp = Date.now(); this.isInitialized = true; logger.debug("Config loaded and cached successfully", { prefix: "ConfigManager", path: configPath, hasSession: !!session, }); return config; } /** * Get the cached configuration synchronously. * * @returns The cached configuration * @throws Error if configuration has not been loaded yet * * @example * ```typescript * const config = configManager.getConfig(); * ``` */ public getConfig(): AppwriteConfig { if (!this.cachedConfig) { throw new Error( "Configuration not loaded. Call loadConfig() first.\n" + "Suggestion: await configManager.loadConfig();" ); } return this.cachedConfig; } /** * Get the path to the loaded configuration file. * * @returns The configuration file path, or null if not loaded * * @example * ```typescript * const configPath = configManager.getConfigPath(); * console.log(`Config loaded from: ${configPath}`); * ``` */ public getConfigPath(): string | null { return this.cachedConfigPath; } /** * Check if configuration has been loaded. * * @returns True if configuration is loaded and cached * * @example * ```typescript * if (!configManager.hasConfig()) { * await configManager.loadConfig(); * } * ``` */ public hasConfig(): boolean { return this.cachedConfig !== null; } /** * Check if the ConfigManager has been initialized. * * @returns True if initialized (config loaded at least once) * * @example * ```typescript * if (configManager.isConfigInitialized()) { * console.log("Config ready"); * } * ``` */ public isConfigInitialized(): boolean { return this.isInitialized; } /** * Invalidate the configuration cache. * Next call to loadConfig() will reload from disk. * * @example * ```typescript * configManager.invalidateCache(); * const freshConfig = await configManager.loadConfig(); * ``` */ public invalidateCache(): void { logger.debug("Invalidating config cache", { prefix: "ConfigManager" }); this.cachedConfig = null; this.cachedConfigPath = null; this.lastLoadTimestamp = 0; // Note: session cache is preserved by SessionAuthService } /** * Reload configuration from disk, preserving current session. * * This is a convenience method that combines invalidation and reloading * while automatically preserving the current session authentication. * * @param options - Configuration loading options (forceReload is automatically set) * @returns The reloaded configuration * * @example * ```typescript * // Reload config after manual file changes * const config = await configManager.reloadConfig({ * validate: true, * reportValidation: true * }); * ``` */ public async reloadConfig( options: Omit<ConfigLoadOptions, "forceReload"> = {} ): Promise<AppwriteConfig> { logger.debug("Reloading config with session preservation", { prefix: "ConfigManager" }); // Preserve current session during reload const currentSession = this.cachedSession; return this.loadConfig({ ...options, forceReload: true, sessionOverride: currentSession || undefined, }); } // ────────────────────────────────────────────────── // SESSION MANAGEMENT // ────────────────────────────────────────────────── /** * Get the current session information. * * @returns The cached session info, or null if no session * * @example * ```typescript * const session = configManager.getSession(); * if (session) { * console.log(`Authenticated as: ${session.email}`); * } * ``` */ public getSession(): SessionAuthInfo | null { return this.cachedSession; } /** * Check if a session is available. * * @returns True if a session is cached * * @example * ```typescript * if (configManager.hasSession()) { * console.log("Using session authentication"); * } else { * console.log("Using API key authentication"); * } * ``` */ public hasSession(): boolean { return this.cachedSession !== null; } /** * Refresh session from disk (bypasses session cache). * * This forces SessionAuthService to reload from ~/.appwrite/prefs.json * and update the cached session. * * @returns The refreshed session, or null if no session found * * @example * ```typescript * // After logging in via appwrite CLI * const session = await configManager.refreshSession(); * if (session) { * console.log("Session refreshed successfully"); * } * ``` */ public async refreshSession(): Promise<SessionAuthInfo | null> { if (!this.cachedConfig) { logger.debug("Cannot refresh session - no config loaded", { prefix: "ConfigManager" }); return null; } logger.debug("Refreshing session from disk", { prefix: "ConfigManager" }); // Invalidate session cache this.sessionService.invalidateCache(); // Reload session const session = await this.sessionService.findSession( this.cachedConfig.appwriteEndpoint, this.cachedConfig.appwriteProject ); if (session) { this.cachedSession = session; logger.debug("Session refreshed successfully", { prefix: "ConfigManager" }); } else { this.cachedSession = null; logger.debug("No session found after refresh", { prefix: "ConfigManager" }); } return session; } /** * Get authentication status for the current configuration. * * @returns Authentication status object with details about current auth method * @throws Error if configuration has not been loaded * * @example * ```typescript * const authStatus = await configManager.getAuthStatus(); * console.log(`Auth method: ${authStatus.authMethod}`); * console.log(`Has valid session: ${authStatus.hasValidSession}`); * ``` */ public async getAuthStatus(): Promise<AuthenticationStatus> { if (!this.cachedConfig) { throw new Error( "Configuration not loaded. Call loadConfig() first.\n" + "Suggestion: await configManager.loadConfig();" ); } return await this.sessionService.getAuthenticationStatus( this.cachedConfig.appwriteEndpoint, this.cachedConfig.appwriteProject, this.cachedConfig.appwriteKey, this.cachedSession ); } /** * Clear the cached session. * Next load will attempt to find a new session. * * @example * ```typescript * configManager.clearSession(); * const config = await configManager.reloadConfig(); * ``` */ public clearSession(): void { logger.debug("Clearing cached session", { prefix: "ConfigManager" }); this.cachedSession = null; } // ────────────────────────────────────────────────── // VALIDATION // ────────────────────────────────────────────────── /** * Validate the current configuration (warnings allowed). * * @param reportResults - Whether to report validation results to console * @returns Validation result with errors and warnings * @throws Error if configuration has not been loaded * * @example * ```typescript * const validation = configManager.validateConfig(true); * if (!validation.isValid) { * console.log(`Found ${validation.errors.length} errors`); * } * ``` */ public validateConfig(reportResults: boolean = false): ValidationResult { if (!this.cachedConfig) { throw new Error( "Configuration not loaded. Call loadConfig() first.\n" + "Suggestion: await configManager.loadConfig();" ); } const validation = this.validationService.validate(this.cachedConfig); if (reportResults) { this.validationService.reportResults(validation); } return validation; } /** * Validate the current configuration in strict mode (warnings as errors). * * @param reportResults - Whether to report validation results to console * @returns Validation result with errors and warnings (warnings treated as errors) * @throws Error if configuration has not been loaded * * @example * ```typescript * const validation = configManager.validateConfigStrict(true); * if (!validation.isValid) { * console.log("Config validation failed (strict mode)"); * } * ``` */ public validateConfigStrict(reportResults: boolean = false): ValidationResult { if (!this.cachedConfig) { throw new Error( "Configuration not loaded. Call loadConfig() first.\n" + "Suggestion: await configManager.loadConfig();" ); } const validation = this.validationService.validateStrict(this.cachedConfig); if (reportResults) { this.validationService.reportResults(validation); } return validation; } // ────────────────────────────────────────────────── // ADVANCED / CONVENIENCE // ────────────────────────────────────────────────── /** * Watch configuration file for changes and reload automatically. * * Returns a function to stop watching. * * @param callback - Function to call when config changes * @returns Function to stop watching * * @example * ```typescript * const unwatch = configManager.watchConfig(async (config) => { * console.log("Config changed, reloading..."); * // Handle config change * }); * * // Later, stop watching * unwatch(); * ``` */ public watchConfig(callback: (config: AppwriteConfig) => void | Promise<void>): () => void { if (!this.cachedConfigPath) { throw new Error( "Cannot watch config - no config file loaded.\n" + "Suggestion: Call loadConfig() before watchConfig()." ); } const configPath = this.cachedConfigPath; logger.debug(`Setting up config file watcher: ${configPath}`, { prefix: "ConfigManager" }); let isProcessing = false; const watcher = fs.watch(configPath, async (eventType) => { if (eventType === "change" && !isProcessing) { isProcessing = true; logger.debug("Config file changed, reloading...", { prefix: "ConfigManager" }); try { const newConfig = await this.reloadConfig(); await callback(newConfig); } catch (error) { MessageFormatter.error( "Failed to reload config after file change", error instanceof Error ? error : String(error), { prefix: "ConfigManager" } ); } finally { isProcessing = false; } } }); // Return cleanup function return () => { logger.debug("Stopping config file watcher", { prefix: "ConfigManager" }); watcher.close(); }; } /** * Merge overrides into the current configuration without persisting. * * This creates a new config object with overrides applied, without * affecting the cached configuration. * * @param overrides - Configuration overrides to apply * @returns New configuration with overrides applied * @throws Error if configuration has not been loaded * * @example * ```typescript * const customConfig = configManager.mergeOverrides({ * appwriteEndpoint: "https://test.endpoint.com/v1" * }); * ``` */ public mergeOverrides(overrides: ConfigOverrides): AppwriteConfig { if (!this.cachedConfig) { throw new Error( "Configuration not loaded. Call loadConfig() first.\n" + "Suggestion: await configManager.loadConfig();" ); } return this.mergeService.applyOverrides(this.cachedConfig, overrides); } /** * Get collections from the configuration, optionally filtered. * * @param filter - Optional filter function for collections * @returns Array of collections (or tables, depending on API mode) * * @example * ```typescript * // Get all collections * const allCollections = configManager.getCollections(); * * // Get enabled collections only * const enabledCollections = configManager.getCollections( * (c) => c.enabled !== false * ); * ``` */ public getCollections(filter?: CollectionFilter): (Collection | CollectionCreate)[] { const config = this.getConfig(); const collections = config.collections || config.tables || []; if (filter) { return collections.filter(filter); } return collections; } /** * Get databases from the configuration. * * @returns Array of databases * * @example * ```typescript * const databases = configManager.getDatabases(); * console.log(`Found ${databases.length} databases`); * ``` */ public getDatabases(): Database[] { const config = this.getConfig(); return config.databases || []; } /** * Get buckets from the configuration. * * @returns Array of buckets * * @example * ```typescript * const buckets = configManager.getBuckets(); * console.log(`Found ${buckets.length} buckets`); * ``` */ public getBuckets(): Bucket[] { const config = this.getConfig(); return config.buckets || []; } /** * Get functions from the configuration. * * @returns Array of functions * * @example * ```typescript * const functions = configManager.getFunctions(); * console.log(`Found ${functions.length} functions`); * ``` */ public getFunctions(): AppwriteFunction[] { const config = this.getConfig(); return config.functions || []; } // ────────────────────────────────────────────────── // PRIVATE HELPERS // ────────────────────────────────────────────────── /** * Get cached config with optional overrides applied. * Used internally to avoid reloading when only overrides change. */ private getCachedConfigWithOverrides(overrides?: ConfigOverrides): AppwriteConfig { if (!this.cachedConfig) { throw new Error("No cached config available"); } if (!overrides) { return this.cachedConfig; } logger.debug("Applying overrides to cached config", { prefix: "ConfigManager" }); return this.mergeService.applyOverrides(this.cachedConfig, overrides); } }