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.
385 lines (384 loc) • 19.9 kB
JavaScript
import inquirer from "inquirer";
import { Databases, Storage } from "node-appwrite";
import { MessageFormatter } from "../../shared/messageFormatter.js";
import { fetchAllDatabases } from "../../databases/methods.js";
import { listBuckets } from "../../storage/methods.js";
import { getClient } from "../../utils/getClientFromConfig.js";
import { ComprehensiveTransfer } from "../../migrations/comprehensiveTransfer.js";
export const transferCommands = {
async transferData(cli) {
if (!cli.controller.database) {
throw new Error("Database is not initialized, is the config file correct & created?");
}
const { isRemote } = await inquirer.prompt([
{
type: "confirm",
name: "isRemote",
message: "Is this a remote transfer?",
default: false,
},
]);
let sourceClient = cli.controller.database;
let targetClient;
let sourceDatabases;
let targetDatabases;
let remoteOptions;
if (isRemote) {
remoteOptions = await inquirer.prompt([
{
type: "input",
name: "transferEndpoint",
message: "Enter the remote endpoint:",
},
{
type: "input",
name: "transferProject",
message: "Enter the remote project ID:",
},
{
type: "input",
name: "transferKey",
message: "Enter the remote API key:",
},
]);
const remoteClient = getClient(remoteOptions.transferEndpoint, remoteOptions.transferProject, remoteOptions.transferKey);
targetClient = new Databases(remoteClient);
sourceDatabases = await fetchAllDatabases(sourceClient);
targetDatabases = await fetchAllDatabases(targetClient);
}
else {
targetClient = sourceClient;
const allDatabases = await fetchAllDatabases(sourceClient);
sourceDatabases = targetDatabases = allDatabases;
}
const fromDbs = await cli.selectDatabases(sourceDatabases, "Select the source database:", false);
const fromDb = fromDbs[0];
if (!fromDb) {
throw new Error("No source database selected");
}
const availableDbs = targetDatabases.filter((db) => db.$id !== fromDb.$id);
const targetDbs = await cli.selectDatabases(availableDbs, "Select the target database:", false);
const targetDb = targetDbs[0];
if (!targetDb) {
throw new Error("No target database selected");
}
const selectedCollections = await cli.selectCollectionsAndTables(fromDb, sourceClient, "Select collections/tables to transfer:", true, false // don't prefer local for transfers
);
const { transferStorage } = await inquirer.prompt([
{
type: "confirm",
name: "transferStorage",
message: "Do you want to transfer storage as well?",
default: false,
},
]);
let sourceBucket, targetBucket;
if (transferStorage) {
const sourceStorage = new Storage(cli.controller.appwriteServer);
const targetStorage = isRemote
? new Storage(getClient(remoteOptions.transferEndpoint, remoteOptions.transferProject, remoteOptions.transferKey))
: sourceStorage;
const sourceBuckets = await listBuckets(sourceStorage);
const targetBuckets = isRemote
? await listBuckets(targetStorage)
: sourceBuckets;
const sourceBucketPicked = await cli.selectBuckets(sourceBuckets.buckets, "Select the source bucket:", false);
const targetBucketPicked = await cli.selectBuckets(targetBuckets.buckets, "Select the target bucket:", false);
sourceBucket = sourceBucketPicked[0];
targetBucket = targetBucketPicked[0];
}
let transferOptions = {
fromDb,
targetDb,
isRemote,
collections: selectedCollections.length > 0
? selectedCollections.map((c) => c.$id)
: undefined,
sourceBucket,
targetBucket,
};
if (isRemote && remoteOptions) {
transferOptions = {
...transferOptions,
...remoteOptions,
};
}
MessageFormatter.progress("Transferring data...", { prefix: "Transfer" });
await cli.controller.transferData(transferOptions);
MessageFormatter.success("Data transfer completed", { prefix: "Transfer" });
},
async comprehensiveTransfer(cli) {
MessageFormatter.info("Starting comprehensive transfer configuration...", { prefix: "Transfer" });
try {
// Initialize controller to optionally load config if available (supports both YAML and TypeScript configs)
await cli.initControllerIfNeeded();
// Extract session for potential transfer operations
const sessionConfig = cli.extractSessionFromController();
// Check if user has an appwrite config for easier setup
const hasAppwriteConfig = cli.controller?.config?.appwriteEndpoint &&
cli.controller?.config?.appwriteProject &&
cli.controller?.config?.appwriteKey;
let sourceConfig;
let targetConfig;
if (hasAppwriteConfig) {
// Offer to use existing config for source
const { useConfigForSource } = await inquirer.prompt([
{
type: "confirm",
name: "useConfigForSource",
message: "Use your current appwriteConfig as the source?",
default: true,
},
]);
if (useConfigForSource) {
sourceConfig = {
sourceEndpoint: cli.controller.config.appwriteEndpoint,
sourceProject: cli.controller.config.appwriteProject,
sourceKey: cli.controller.config.appwriteKey,
};
// Preserve session for source if available
if (sessionConfig?.sessionCookie) {
sourceConfig.sessionCookie = sessionConfig.sessionCookie;
sourceConfig.sessionMetadata = sessionConfig.sessionMetadata;
}
MessageFormatter.info(`Using config source: ${sourceConfig.sourceEndpoint}`, { prefix: "Transfer" });
}
else {
// Get source configuration manually
sourceConfig = await inquirer.prompt([
{
type: "input",
name: "sourceEndpoint",
message: "Enter the source Appwrite endpoint:",
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
},
{
type: "input",
name: "sourceProject",
message: "Enter the source project ID:",
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
},
{
type: "password",
name: "sourceKey",
message: "Enter the source API key:",
validate: (input) => input.trim() !== "" || "API key cannot be empty",
},
]);
}
// Offer to use existing config for target
const { useConfigForTarget } = await inquirer.prompt([
{
type: "confirm",
name: "useConfigForTarget",
message: "Use your current appwriteConfig as the target?",
default: false,
},
]);
if (useConfigForTarget) {
targetConfig = {
targetEndpoint: cli.controller.config.appwriteEndpoint,
targetProject: cli.controller.config.appwriteProject,
targetKey: cli.controller.config.appwriteKey,
};
// Preserve session for target if available
if (sessionConfig?.sessionCookie) {
targetConfig.sessionCookie = sessionConfig.sessionCookie;
targetConfig.sessionMetadata = sessionConfig.sessionMetadata;
}
MessageFormatter.info(`Using config target: ${targetConfig.targetEndpoint}`, { prefix: "Transfer" });
}
else {
// Get target configuration manually
targetConfig = await inquirer.prompt([
{
type: "input",
name: "targetEndpoint",
message: "Enter the target Appwrite endpoint:",
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
},
{
type: "input",
name: "targetProject",
message: "Enter the target project ID:",
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
},
{
type: "password",
name: "targetKey",
message: "Enter the target API key:",
validate: (input) => input.trim() !== "" || "API key cannot be empty",
},
]);
}
}
else {
// No appwrite config found, get both configurations manually
MessageFormatter.info("No appwriteConfig found, please enter source and target configurations manually", { prefix: "Transfer" });
// Get source configuration
sourceConfig = await inquirer.prompt([
{
type: "input",
name: "sourceEndpoint",
message: "Enter the source Appwrite endpoint:",
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
},
{
type: "input",
name: "sourceProject",
message: "Enter the source project ID:",
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
},
{
type: "password",
name: "sourceKey",
message: "Enter the source API key:",
validate: (input) => input.trim() !== "" || "API key cannot be empty",
},
]);
// Get target configuration
targetConfig = await inquirer.prompt([
{
type: "input",
name: "targetEndpoint",
message: "Enter the target Appwrite endpoint:",
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
},
{
type: "input",
name: "targetProject",
message: "Enter the target project ID:",
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
},
{
type: "password",
name: "targetKey",
message: "Enter the target API key:",
validate: (input) => input.trim() !== "" || "API key cannot be empty",
},
]);
}
// Get transfer options
const transferOptions = await inquirer.prompt([
{
type: "checkbox",
name: "transferTypes",
message: "Select what to transfer:",
choices: [
{ name: "👥 Users", value: "users", checked: true },
{ name: "👥 Teams", value: "teams", checked: true },
{ name: "🗄️ Databases", value: "databases", checked: true },
{ name: "📦 Storage Buckets", value: "buckets", checked: true },
{ name: "⚡ Functions", value: "functions", checked: true },
],
validate: (input) => input.length > 0 || "Select at least one transfer type",
},
{
type: "list",
name: "concurrencyLimit",
message: "Select concurrency limit:",
choices: [
{ name: "5 (Conservative) - Users: 2, Files: 1", value: 5 },
{ name: "10 (Balanced) - Users: 5, Files: 2", value: 10 },
{ name: "15 - Users: 7, Files: 3", value: 15 },
{ name: "20 - Users: 10, Files: 5", value: 20 },
{ name: "25 - Users: 12, Files: 6", value: 25 },
{ name: "30 - Users: 15, Files: 7", value: 30 },
{ name: "35 - Users: 17, Files: 8", value: 35 },
{ name: "40 - Users: 20, Files: 10", value: 40 },
{ name: "45 - Users: 22, Files: 11", value: 45 },
{ name: "50 - Users: 25, Files: 12", value: 50 },
{ name: "55 - Users: 27, Files: 13", value: 55 },
{ name: "60 - Users: 30, Files: 15", value: 60 },
{ name: "65 - Users: 32, Files: 16", value: 65 },
{ name: "70 - Users: 35, Files: 17", value: 70 },
{ name: "75 - Users: 37, Files: 18", value: 75 },
{ name: "80 - Users: 40, Files: 20", value: 80 },
{ name: "85 - Users: 42, Files: 21", value: 85 },
{ name: "90 - Users: 45, Files: 22", value: 90 },
{ name: "95 - Users: 47, Files: 23", value: 95 },
{ name: "100 (Aggressive) - Users: 50, Files: 25", value: 100 },
],
default: 10,
},
{
type: "confirm",
name: "dryRun",
message: "Run in dry-run mode (no actual changes)?",
default: false,
},
]);
// Confirmation
const { confirmed } = await inquirer.prompt([
{
type: "confirm",
name: "confirmed",
message: `Are you sure you want to ${transferOptions.dryRun ? "dry-run" : "perform"} comprehensive transfer from ${sourceConfig.sourceEndpoint} to ${targetConfig.targetEndpoint}?`,
default: false,
},
]);
if (!confirmed) {
MessageFormatter.info("Transfer cancelled by user", { prefix: "Transfer" });
return;
}
// Password preservation information
if (transferOptions.transferTypes.includes("users") && !transferOptions.dryRun) {
MessageFormatter.info("User Password Transfer Information:", { prefix: "Transfer" });
MessageFormatter.info("✅ Users with hashed passwords (Argon2, Bcrypt, Scrypt, MD5, SHA, PHPass) will preserve their passwords", { prefix: "Transfer" });
MessageFormatter.info("⚠️ Users without hash information will receive temporary passwords and need to reset", { prefix: "Transfer" });
MessageFormatter.info("🔒 All user data (preferences, labels, verification status) will be preserved", { prefix: "Transfer" });
const { continueWithUsers } = await inquirer.prompt([
{
type: "confirm",
name: "continueWithUsers",
message: "Continue with user transfer?",
default: true,
},
]);
if (!continueWithUsers) {
// Remove users from transfer types
transferOptions.transferTypes = transferOptions.transferTypes.filter((type) => type !== "users");
if (transferOptions.transferTypes.length === 0) {
MessageFormatter.info("No transfer types selected, cancelling", { prefix: "Transfer" });
return;
}
}
}
// Execute comprehensive transfer
const comprehensiveTransferOptions = {
sourceEndpoint: sourceConfig.sourceEndpoint,
sourceProject: sourceConfig.sourceProject,
sourceKey: sourceConfig.sourceKey,
targetEndpoint: targetConfig.targetEndpoint,
targetProject: targetConfig.targetProject,
targetKey: targetConfig.targetKey,
transferUsers: transferOptions.transferTypes.includes("users"),
transferTeams: transferOptions.transferTypes.includes("teams"),
transferDatabases: transferOptions.transferTypes.includes("databases"),
transferBuckets: transferOptions.transferTypes.includes("buckets"),
transferFunctions: transferOptions.transferTypes.includes("functions"),
concurrencyLimit: transferOptions.concurrencyLimit,
dryRun: transferOptions.dryRun,
};
const transfer = new ComprehensiveTransfer(comprehensiveTransferOptions);
const results = await transfer.execute();
// Display results
if (transferOptions.dryRun) {
MessageFormatter.success("Dry run completed successfully!", { prefix: "Transfer" });
}
else {
MessageFormatter.success("Comprehensive transfer completed!", { prefix: "Transfer" });
if (transferOptions.transferTypes.includes("users") && results.users.transferred > 0) {
MessageFormatter.info("Users with preserved password hashes can log in with their original passwords", { prefix: "Transfer" });
MessageFormatter.info("Users with temporary passwords will need to reset their passwords", { prefix: "Transfer" });
}
if (transferOptions.transferTypes.includes("teams") && results.teams.transferred > 0) {
MessageFormatter.info("Team memberships have been transferred and may require user acceptance of invitations", { prefix: "Transfer" });
}
}
}
catch (error) {
MessageFormatter.error("Comprehensive transfer failed", error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
}
}
};