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.

473 lines (472 loc) 19.1 kB
import path from "path"; import fs from "fs"; import { CollectionCreateSchema } from "appwrite-utils"; import { register } from "tsx/esm/api"; import { pathToFileURL } from "node:url"; import yaml from "js-yaml"; import { z } from "zod"; import { MessageFormatter } from "../shared/messageFormatter.js"; import { shouldIgnoreDirectory } from "./directoryUtils.js"; import { resolveCollectionsDir, resolveTablesDir } from "./pathResolvers.js"; import { findYamlConfig } from "../config/yamlConfig.js"; /** * Recursively searches for TypeScript configuration files (appwriteConfig.ts) * @param dir The directory to start the search from * @param depth Current search depth for recursion limiting * @returns Path to the config file or null if not found */ export const findAppwriteConfigTS = (dir, depth = 0) => { // Limit search depth to prevent infinite recursion if (depth > 10) { return null; } if (shouldIgnoreDirectory(path.basename(dir))) { return null; } try { const entries = fs.readdirSync(dir, { withFileTypes: true }); // First check current directory for appwriteConfig.ts for (const entry of entries) { if (entry.isFile() && entry.name === "appwriteConfig.ts") { return path.join(dir, entry.name); } } // Then search subdirectories for (const entry of entries) { if (entry.isDirectory() && !shouldIgnoreDirectory(entry.name)) { const result = findAppwriteConfigTS(path.join(dir, entry.name), depth + 1); if (result) return result; } } } catch (error) { // Ignore directory access errors } return null; }; /** * Recursively searches for configuration files starting from the given directory. * Priority: 1) YAML configs in .appwrite directories, 2) appwriteConfig.ts files in subdirectories * @param dir The directory to start the search from * @returns The directory path where the config was found, suitable for passing to loadConfig() */ export const findAppwriteConfig = (dir) => { // First try to find YAML config (already searches recursively for .appwrite dirs) const yamlConfig = findYamlConfig(dir); if (yamlConfig) { // Return the directory containing the config file return path.dirname(yamlConfig); } // Fall back to TypeScript config search const tsConfigPath = findAppwriteConfigTS(dir); if (tsConfigPath) { return path.dirname(tsConfigPath); } return null; }; /** * Recursively searches for the functions directory * @param dir The directory to start searching from * @param depth Current search depth for recursion limiting * @returns Path to the functions directory or null if not found */ export const findFunctionsDir = (dir, depth = 0) => { // Limit search depth to prevent infinite recursion if (depth > 5) { return null; } if (shouldIgnoreDirectory(path.basename(dir))) { return null; } try { const files = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of files) { if (!entry.isDirectory() || shouldIgnoreDirectory(entry.name)) { continue; } if (entry.name === "functions") { return path.join(dir, entry.name); } const result = findFunctionsDir(path.join(dir, entry.name), depth + 1); if (result) return result; } } catch (error) { // Ignore directory access errors } return null; }; // YAML Collection Schema const YamlCollectionSchema = z.object({ name: z.string(), id: z.string().optional(), documentSecurity: z.boolean().default(false), enabled: z.boolean().default(true), permissions: z.array(z.object({ permission: z.string(), target: z.string() })).optional().default([]), attributes: z.array(z.object({ key: z.string(), type: z.string(), size: z.number().optional(), required: z.boolean().default(false), array: z.boolean().optional(), default: z.any().optional(), min: z.number().optional(), max: z.number().optional(), elements: z.array(z.string()).optional(), relatedCollection: z.string().optional(), relationType: z.string().optional(), twoWay: z.boolean().optional(), twoWayKey: z.string().optional(), onDelete: z.string().optional(), side: z.string().optional(), encrypt: z.boolean().optional(), format: z.string().optional() })).optional().default([]), indexes: z.array(z.object({ key: z.string(), type: z.string(), attributes: z.array(z.string()), orders: z.array(z.string()).optional() })).optional().default([]), importDefs: z.array(z.any()).optional().default([]) }); // YAML Table Schema - Supports table-specific terminology const YamlTableSchema = z.object({ name: z.string(), id: z.string().optional(), rowSecurity: z.boolean().default(false), // Tables use rowSecurity enabled: z.boolean().default(true), permissions: z.array(z.object({ permission: z.string(), target: z.string() })).optional().default([]), columns: z.array(// Tables use columns terminology z.object({ key: z.string(), type: z.string(), size: z.number().optional(), required: z.boolean().default(false), array: z.boolean().optional(), encrypt: z.boolean().optional(), // Tables support encrypt property default: z.any().optional(), min: z.number().optional(), max: z.number().optional(), elements: z.array(z.string()).optional(), relatedTable: z.string().optional(), // Tables use relatedTable relationType: z.string().optional(), twoWay: z.boolean().optional(), twoWayKey: z.string().optional(), onDelete: z.string().optional(), side: z.string().optional(), format: z.string().optional() })).optional().default([]), indexes: z.array(z.object({ key: z.string(), type: z.string(), columns: z.array(z.string()), // Tables use columns in indexes orders: z.array(z.string()).optional() })).optional().default([]), importDefs: z.array(z.any()).optional().default([]) }); /** * Loads a YAML collection file and converts it to CollectionCreate format * @param filePath Path to the YAML collection file * @returns CollectionCreate object or null if loading fails */ export const loadYamlCollection = (filePath) => { try { const fileContent = fs.readFileSync(filePath, "utf8"); const yamlData = yaml.load(fileContent); const parsedCollection = YamlCollectionSchema.parse(yamlData); // Convert YAML collection to CollectionCreate format const collectionInput = { name: parsedCollection.name, $id: parsedCollection.id || parsedCollection.name.toLowerCase().replace(/\s+/g, '_'), documentSecurity: parsedCollection.documentSecurity, enabled: parsedCollection.enabled, $permissions: parsedCollection.permissions.map(p => ({ permission: p.permission, target: p.target })), attributes: parsedCollection.attributes.map(attr => ({ key: attr.key, type: attr.type, size: attr.size, required: attr.required, array: attr.array, xdefault: attr.default, min: attr.min, max: attr.max, elements: attr.elements, relatedCollection: attr.relatedCollection, relationType: attr.relationType, twoWay: attr.twoWay, twoWayKey: attr.twoWayKey, onDelete: attr.onDelete, side: attr.side, encrypt: attr.encrypt, format: attr.format })), indexes: parsedCollection.indexes.map(idx => ({ key: idx.key, type: idx.type, attributes: idx.attributes, orders: idx.orders })), importDefs: parsedCollection.importDefs && Array.isArray(parsedCollection.importDefs) && parsedCollection.importDefs.length > 0 ? parsedCollection.importDefs : [] }; const collection = CollectionCreateSchema.parse(collectionInput); return collection; } catch (error) { MessageFormatter.error(`Error loading YAML collection from ${filePath}`, error, { prefix: "Config" }); return null; } }; /** * Loads a YAML table file and converts it to CollectionCreate format * @param filePath Path to the YAML table file * @returns CollectionCreate object or null if loading fails */ export const loadYamlTable = (filePath) => { try { const fileContent = fs.readFileSync(filePath, "utf8"); const yamlData = yaml.load(fileContent); // Use the new table-specific schema const parsedTable = YamlTableSchema.parse(yamlData); // Convert YAML table to CollectionCreate format (internal representation) const table = { name: parsedTable.name, $id: yamlData.tableId || parsedTable.id || parsedTable.name.toLowerCase().replace(/\s+/g, '_'), documentSecurity: parsedTable.rowSecurity, // Convert rowSecurity to documentSecurity enabled: parsedTable.enabled, $permissions: parsedTable.permissions.map(p => ({ permission: p.permission, target: p.target })), attributes: parsedTable.columns.map(col => ({ key: col.key, type: col.type, size: col.size, required: col.required, array: col.array, xdefault: col.default, min: col.min, max: col.max, elements: col.elements, relatedCollection: col.relatedTable, // Convert relatedTable to relatedCollection relationType: col.relationType, twoWay: col.twoWay, twoWayKey: col.twoWayKey, onDelete: col.onDelete, side: col.side, encrypt: col.encrypt, format: col.format })), indexes: parsedTable.indexes.map(idx => ({ key: idx.key, type: idx.type, attributes: idx.columns, // Convert columns to attributes orders: idx.orders })), importDefs: parsedTable.importDefs || [] }; return table; } catch (error) { MessageFormatter.error(`Error loading YAML table from ${filePath}`, error, { prefix: "Config" }); return null; } }; /** * Discovers and loads collections from a collections/ directory * @param collectionsDir Path to the collections directory * @returns Discovery result with loaded collections and metadata */ export const discoverCollections = async (collectionsDir) => { const collections = []; const loadedNames = new Set(); const conflicts = []; if (!fs.existsSync(collectionsDir)) { return { collections, loadedNames, conflicts }; } const unregister = register(); // Register tsx for collections try { const collectionFiles = fs.readdirSync(collectionsDir); MessageFormatter.success(`Loading from collections/ directory: ${collectionFiles.length} files found`, { prefix: "Config" }); for (const file of collectionFiles) { if (file === "index.ts") { continue; } const filePath = path.join(collectionsDir, file); let collection = null; // Handle YAML collections if (file.endsWith('.yaml') || file.endsWith('.yml')) { collection = loadYamlCollection(filePath); } // Handle TypeScript collections else if (file.endsWith('.ts')) { const fileUrl = pathToFileURL(filePath).href; const collectionModule = (await import(fileUrl)); const importedCollection = collectionModule.default?.default || collectionModule.default || collectionModule; if (importedCollection) { collection = importedCollection; // Ensure importDefs are properly loaded if (collectionModule.importDefs || collection.importDefs) { collection.importDefs = collectionModule.importDefs || collection.importDefs; } } } if (collection) { const collectionName = collection.name || collection.$id || file; loadedNames.add(collectionName); collections.push(collection); } } } finally { unregister(); // Unregister tsx when done } return { collections, loadedNames, conflicts }; }; /** * Discovers and loads tables from a tables/ directory * @param tablesDir Path to the tables directory * @param existingNames Set of already-loaded collection names to check for conflicts * @returns Discovery result with loaded tables and metadata */ export const discoverTables = async (tablesDir, existingNames = new Set()) => { const tables = []; const loadedNames = new Set(); const conflicts = []; if (!fs.existsSync(tablesDir)) { return { tables, loadedNames, conflicts }; } const unregister = register(); // Register tsx for tables try { const tableFiles = fs.readdirSync(tablesDir); MessageFormatter.success(`Loading from tables/ directory: ${tableFiles.length} files found`, { prefix: "Config" }); for (const file of tableFiles) { if (file === "index.ts") { continue; } const filePath = path.join(tablesDir, file); let table = null; // Handle YAML tables if (file.endsWith('.yaml') || file.endsWith('.yml')) { table = loadYamlTable(filePath); } // Handle TypeScript tables else if (file.endsWith('.ts')) { const fileUrl = pathToFileURL(filePath).href; const tableModule = (await import(fileUrl)); const importedTable = tableModule.default?.default || tableModule.default || tableModule; if (importedTable) { table = importedTable; // Ensure importDefs are properly loaded if (tableModule.importDefs || table.importDefs) { table.importDefs = tableModule.importDefs || table.importDefs; } } } if (table) { const tableName = table.name || table.tableId || table.$id || file; // Check for naming conflicts with existing collections if (existingNames.has(tableName)) { conflicts.push({ name: tableName, source1: "collections/", source2: "tables/" }); MessageFormatter.warning(`Skipping duplicate '${tableName}' from tables/ (collections/ takes priority)`, { prefix: "Config" }); } else { loadedNames.add(tableName); // Mark as coming from tables directory table._isFromTablesDir = true; tables.push(table); } } } } finally { unregister(); // Unregister tsx when done } return { tables, loadedNames, conflicts }; }; /** * Discovers and loads collections/tables from legacy single directory structure * @param configFileDir Directory containing the config file * @param dirName Directory name to search for ('collections' or 'tables') * @returns Array of discovered collections/tables */ export const discoverLegacyDirectory = async (configFileDir, dirName) => { const legacyDir = path.join(configFileDir, dirName); const items = []; if (!fs.existsSync(legacyDir)) { return items; } MessageFormatter.info(`Using legacy single directory: ${dirName}/`, { prefix: "Config" }); const unregister = register(); // Register tsx for legacy collections try { const collectionFiles = fs.readdirSync(legacyDir); for (const file of collectionFiles) { if (file === "index.ts") { continue; } const filePath = path.join(legacyDir, file); // Handle YAML collections if (file.endsWith('.yaml') || file.endsWith('.yml')) { const collection = loadYamlCollection(filePath); if (collection) { if (dirName === 'tables') { // Mark as coming from tables directory const table = { ...collection, _isFromTablesDir: true, tableId: collection.$id || collection.name.toLowerCase().replace(/\s+/g, '_') }; items.push(table); } else { items.push(collection); } } continue; } // Handle TypeScript collections if (file.endsWith('.ts')) { const fileUrl = pathToFileURL(filePath).href; const collectionModule = (await import(fileUrl)); const collection = collectionModule.default?.default || collectionModule.default || collectionModule; if (collection) { // Ensure importDefs are properly loaded if (collectionModule.importDefs || collection.importDefs) { collection.importDefs = collectionModule.importDefs || collection.importDefs; } if (dirName === 'tables') { // Mark as coming from tables directory const table = { ...collection, _isFromTablesDir: true, tableId: collection.$id || collection.name.toLowerCase().replace(/\s+/g, '_') }; items.push(table); } else { items.push(collection); } } } } } finally { unregister(); // Unregister tsx when done } return items; };