diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a30d38..a2d779ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Change Log -## 13.0.0-rc.2 +## 13.0.0-rc.3 +- Push, Pull and Schema classes are now exported as part of the package - Fixes a lot of typescript errors throughout the codebase ## 13.0.0-rc.2 diff --git a/README.md b/README.md index 608fb2ca..1ccd2686 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Once the installation is complete, you can verify the install using ```sh $ appwrite -v -13.0.0-rc.2 +13.0.0-rc.3 ``` ### Install using prebuilt binaries @@ -69,7 +69,7 @@ Once the installation completes, you can verify your install using ``` $ appwrite -v -13.0.0-rc.2 +13.0.0-rc.3 ``` ## Getting Started diff --git a/bun.lock b/bun.lock index 57bb97a5..82d5a534 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "name": "appwrite-cli", "dependencies": { "@appwrite.io/console": "^2.1.0", - "@types/bun": "^1.3.5", "chalk": "4.1.2", "chokidar": "^3.6.0", "cli-progress": "^3.12.0", @@ -21,8 +20,10 @@ "tail": "^2.2.6", "tar": "^6.1.11", "undici": "^5.28.2", + "zod": "^4.3.5", }, "devDependencies": { + "@types/bun": "^1.3.5", "@types/cli-progress": "^3.11.5", "@types/inquirer": "^8.2.10", "@types/json-bigint": "^1.0.4", @@ -531,6 +532,8 @@ "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@isaacs/fs-minipass/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "@yao-pkg/pkg/tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], diff --git a/cli.ts b/cli.ts new file mode 100644 index 00000000..108957b8 --- /dev/null +++ b/cli.ts @@ -0,0 +1,168 @@ +#! /usr/bin/env node + +/** Required to set max width of the help commands */ +const oldWidth = process.stdout.columns; +process.stdout.columns = 100; +/** ---------------------------------------------- */ + +import { program } from "commander"; +import chalk from "chalk"; +import packageJson from "./package.json" with { type: "json" }; +const { version } = packageJson; +import { commandDescriptions, cliConfig } from "./lib/parser.js"; +import { client } from "./lib/commands/generic.js"; +import { getLatestVersion, compareVersions } from "./lib/utils.js"; +import inquirer from "inquirer"; +import { + login, + logout, + whoami, + migrate, + register, +} from "./lib/commands/generic.js"; +import { init } from "./lib/commands/init.js"; +import { types } from "./lib/commands/types.js"; +import { pull } from "./lib/commands/pull.js"; +import { run } from "./lib/commands/run.js"; +import { push, deploy } from "./lib/commands/push.js"; +import { update } from "./lib/commands/update.js"; +import { account } from "./lib/commands/services/account.js"; +import { console } from "./lib/commands/services/console.js"; +import { databases } from "./lib/commands/services/databases.js"; +import { functions } from "./lib/commands/services/functions.js"; +import { graphql } from "./lib/commands/services/graphql.js"; +import { health } from "./lib/commands/services/health.js"; +import { locale } from "./lib/commands/services/locale.js"; +import { messaging } from "./lib/commands/services/messaging.js"; +import { migrations } from "./lib/commands/services/migrations.js"; +import { project } from "./lib/commands/services/project.js"; +import { projects } from "./lib/commands/services/projects.js"; +import { proxy } from "./lib/commands/services/proxy.js"; +import { sites } from "./lib/commands/services/sites.js"; +import { storage } from "./lib/commands/services/storage.js"; +import { tablesdb } from "./lib/commands/services/tablesdb.js"; +import { teams } from "./lib/commands/services/teams.js"; +import { tokens } from "./lib/commands/services/tokens.js"; +import { users } from "./lib/commands/services/users.js"; +import { vcs } from "./lib/commands/services/vcs.js"; +import searchList from "inquirer-search-list"; + +inquirer.registerPrompt("search-list", searchList); + +/** + * Check for updates and show version information + */ +async function checkVersion(): Promise { + process.stdout.write(chalk.bold(`appwrite version ${version}`) + "\n"); + + try { + const latestVersion = await getLatestVersion(); + const comparison = compareVersions(version, latestVersion); + + if (comparison > 0) { + // Current version is older than latest + process.stdout.write( + chalk.yellow( + `\nāš ļø A newer version is available: ${chalk.bold(latestVersion)}`, + ) + "\n", + ); + process.stdout.write( + chalk.cyan( + `šŸ’” Run '${chalk.bold("appwrite update")}' to update to the latest version.`, + ) + "\n", + ); + } else if (comparison === 0) { + process.stdout.write( + chalk.green("\nāœ… You are running the latest version!") + "\n", + ); + } else { + // Current version is newer than latest (pre-release/dev) + process.stdout.write( + chalk.blue( + "\nšŸš€ You are running a pre-release or development version.", + ) + "\n", + ); + } + } catch (error) { + // Silently fail version check, just show current version + process.stdout.write(chalk.gray("\n(Unable to check for updates)") + "\n"); + } +} + +// Intercept version flag before Commander.js processes it +if (process.argv.includes("-v") || process.argv.includes("--version")) { + (async () => { + await checkVersion(); + process.exit(0); + })(); +} else { + program + .description(commandDescriptions["main"]) + .configureHelp({ + helpWidth: process.stdout.columns || 80, + sortSubcommands: true, + }) + .helpOption("-h, --help", "Display help for command") + .version(version, "-v, --version", "Output the version number") + .option("-V, --verbose", "Show complete error log") + .option("-j, --json", "Output in JSON format") + .hook("preAction", migrate) + .option("-f,--force", "Flag to confirm all warnings") + .option("-a,--all", "Flag to push all resources") + .option("--id [id...]", "Flag to pass a list of ids for a given action") + .option("--report", "Enable reporting in case of CLI errors") + .on("option:json", () => { + cliConfig.json = true; + }) + .on("option:verbose", () => { + cliConfig.verbose = true; + }) + .on("option:report", function () { + cliConfig.report = true; + cliConfig.reportData = { data: this }; + }) + .on("option:force", () => { + cliConfig.force = true; + }) + .on("option:all", () => { + cliConfig.all = true; + }) + .on("option:id", function () { + cliConfig.ids = (this as any).opts().id; + }) + .showSuggestionAfterError() + .addCommand(whoami) + .addCommand(register) + .addCommand(login) + .addCommand(init) + .addCommand(pull) + .addCommand(push) + .addCommand(types) + .addCommand(deploy) + .addCommand(run) + .addCommand(update) + .addCommand(logout) + .addCommand(account) + .addCommand(console) + .addCommand(databases) + .addCommand(functions) + .addCommand(graphql) + .addCommand(health) + .addCommand(locale) + .addCommand(messaging) + .addCommand(migrations) + .addCommand(project) + .addCommand(projects) + .addCommand(proxy) + .addCommand(sites) + .addCommand(storage) + .addCommand(tablesdb) + .addCommand(teams) + .addCommand(tokens) + .addCommand(users) + .addCommand(vcs) + .addCommand(client) + .parse(process.argv); + + process.stdout.columns = oldWidth; +} diff --git a/index.ts b/index.ts index 108957b8..1a9ecce5 100644 --- a/index.ts +++ b/index.ts @@ -1,168 +1,28 @@ -#! /usr/bin/env node - -/** Required to set max width of the help commands */ -const oldWidth = process.stdout.columns; -process.stdout.columns = 100; -/** ---------------------------------------------- */ - -import { program } from "commander"; -import chalk from "chalk"; -import packageJson from "./package.json" with { type: "json" }; -const { version } = packageJson; -import { commandDescriptions, cliConfig } from "./lib/parser.js"; -import { client } from "./lib/commands/generic.js"; -import { getLatestVersion, compareVersions } from "./lib/utils.js"; -import inquirer from "inquirer"; -import { - login, - logout, - whoami, - migrate, - register, -} from "./lib/commands/generic.js"; -import { init } from "./lib/commands/init.js"; -import { types } from "./lib/commands/types.js"; -import { pull } from "./lib/commands/pull.js"; -import { run } from "./lib/commands/run.js"; -import { push, deploy } from "./lib/commands/push.js"; -import { update } from "./lib/commands/update.js"; -import { account } from "./lib/commands/services/account.js"; -import { console } from "./lib/commands/services/console.js"; -import { databases } from "./lib/commands/services/databases.js"; -import { functions } from "./lib/commands/services/functions.js"; -import { graphql } from "./lib/commands/services/graphql.js"; -import { health } from "./lib/commands/services/health.js"; -import { locale } from "./lib/commands/services/locale.js"; -import { messaging } from "./lib/commands/services/messaging.js"; -import { migrations } from "./lib/commands/services/migrations.js"; -import { project } from "./lib/commands/services/project.js"; -import { projects } from "./lib/commands/services/projects.js"; -import { proxy } from "./lib/commands/services/proxy.js"; -import { sites } from "./lib/commands/services/sites.js"; -import { storage } from "./lib/commands/services/storage.js"; -import { tablesdb } from "./lib/commands/services/tablesdb.js"; -import { teams } from "./lib/commands/services/teams.js"; -import { tokens } from "./lib/commands/services/tokens.js"; -import { users } from "./lib/commands/services/users.js"; -import { vcs } from "./lib/commands/services/vcs.js"; -import searchList from "inquirer-search-list"; - -inquirer.registerPrompt("search-list", searchList); - /** - * Check for updates and show version information + * Library exports for programmatic use of the Appwrite CLI + * + * For CLI usage, run the 'appwrite' command directly. */ -async function checkVersion(): Promise { - process.stdout.write(chalk.bold(`appwrite version ${version}`) + "\n"); - - try { - const latestVersion = await getLatestVersion(); - const comparison = compareVersions(version, latestVersion); - - if (comparison > 0) { - // Current version is older than latest - process.stdout.write( - chalk.yellow( - `\nāš ļø A newer version is available: ${chalk.bold(latestVersion)}`, - ) + "\n", - ); - process.stdout.write( - chalk.cyan( - `šŸ’” Run '${chalk.bold("appwrite update")}' to update to the latest version.`, - ) + "\n", - ); - } else if (comparison === 0) { - process.stdout.write( - chalk.green("\nāœ… You are running the latest version!") + "\n", - ); - } else { - // Current version is newer than latest (pre-release/dev) - process.stdout.write( - chalk.blue( - "\nšŸš€ You are running a pre-release or development version.", - ) + "\n", - ); - } - } catch (error) { - // Silently fail version check, just show current version - process.stdout.write(chalk.gray("\n(Unable to check for updates)") + "\n"); - } -} - -// Intercept version flag before Commander.js processes it -if (process.argv.includes("-v") || process.argv.includes("--version")) { - (async () => { - await checkVersion(); - process.exit(0); - })(); -} else { - program - .description(commandDescriptions["main"]) - .configureHelp({ - helpWidth: process.stdout.columns || 80, - sortSubcommands: true, - }) - .helpOption("-h, --help", "Display help for command") - .version(version, "-v, --version", "Output the version number") - .option("-V, --verbose", "Show complete error log") - .option("-j, --json", "Output in JSON format") - .hook("preAction", migrate) - .option("-f,--force", "Flag to confirm all warnings") - .option("-a,--all", "Flag to push all resources") - .option("--id [id...]", "Flag to pass a list of ids for a given action") - .option("--report", "Enable reporting in case of CLI errors") - .on("option:json", () => { - cliConfig.json = true; - }) - .on("option:verbose", () => { - cliConfig.verbose = true; - }) - .on("option:report", function () { - cliConfig.report = true; - cliConfig.reportData = { data: this }; - }) - .on("option:force", () => { - cliConfig.force = true; - }) - .on("option:all", () => { - cliConfig.all = true; - }) - .on("option:id", function () { - cliConfig.ids = (this as any).opts().id; - }) - .showSuggestionAfterError() - .addCommand(whoami) - .addCommand(register) - .addCommand(login) - .addCommand(init) - .addCommand(pull) - .addCommand(push) - .addCommand(types) - .addCommand(deploy) - .addCommand(run) - .addCommand(update) - .addCommand(logout) - .addCommand(account) - .addCommand(console) - .addCommand(databases) - .addCommand(functions) - .addCommand(graphql) - .addCommand(health) - .addCommand(locale) - .addCommand(messaging) - .addCommand(migrations) - .addCommand(project) - .addCommand(projects) - .addCommand(proxy) - .addCommand(sites) - .addCommand(storage) - .addCommand(tablesdb) - .addCommand(teams) - .addCommand(tokens) - .addCommand(users) - .addCommand(vcs) - .addCommand(client) - .parse(process.argv); - process.stdout.columns = oldWidth; -} +import { Push } from "./lib/commands/push.js"; +import { Pull } from "./lib/commands/pull.js"; +import { Schema } from "./lib/commands/schema.js"; + +export { Schema, Push, Pull }; +export type { + ConfigType, + SettingsType, + FunctionType, + SiteType, + DatabaseType, + CollectionType, + TableType, + TopicType, + TeamType, + MessageType, + BucketType, + AttributeType, + IndexType, + ColumnType, + TableIndexType, +} from "./lib/commands/config.js"; diff --git a/install.ps1 b/install.ps1 index 86ca00bb..4d6b07fd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -13,8 +13,8 @@ # You can use "View source" of this page to see the full script. # REPO -$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.2/appwrite-cli-win-x64.exe" -$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.2/appwrite-cli-win-arm64.exe" +$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.3/appwrite-cli-win-x64.exe" +$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.3/appwrite-cli-win-arm64.exe" $APPWRITE_BINARY_NAME = "appwrite.exe" diff --git a/install.sh b/install.sh index faa9c248..2d0b089c 100644 --- a/install.sh +++ b/install.sh @@ -96,7 +96,7 @@ printSuccess() { downloadBinary() { echo "[2/4] Downloading executable for $OS ($ARCH) ..." - GITHUB_LATEST_VERSION="13.0.0-rc.2" + GITHUB_LATEST_VERSION="13.0.0-rc.3" GITHUB_FILE="appwrite-cli-${OS}-${ARCH}" GITHUB_URL="https://github.com/$GITHUB_REPOSITORY_NAME/releases/download/$GITHUB_LATEST_VERSION/$GITHUB_FILE" diff --git a/lib/client.ts b/lib/client.ts index 566b6aeb..5f043e14 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -26,8 +26,8 @@ class Client { "x-sdk-name": "Command Line", "x-sdk-platform": "console", "x-sdk-language": "cli", - "x-sdk-version": "13.0.0-rc.2", - "user-agent": `AppwriteCLI/13.0.0-rc.2 (${os.type()} ${os.version()}; ${os.arch()})`, + "x-sdk-version": "13.0.0-rc.3", + "user-agent": `AppwriteCLI/13.0.0-rc.3 (${os.type()} ${os.version()}; ${os.arch()})`, "X-Appwrite-Response-Format": "1.8.1", }; } diff --git a/lib/commands/config.ts b/lib/commands/config.ts new file mode 100644 index 00000000..6e3b3a21 --- /dev/null +++ b/lib/commands/config.ts @@ -0,0 +1,493 @@ +import { z } from "zod"; + +// ============================================================================ +// Internal Helpers (not exported) +// ============================================================================ + +const INT64_MIN = BigInt("-9223372036854775808"); +const INT64_MAX = BigInt("9223372036854775807"); + +const int64Schema = z.preprocess( + (val) => { + if (typeof val === "bigint") { + return val; + } + + if (typeof val === "object" && val !== null) { + if (typeof val.valueOf === "function") { + try { + const valueOfResult = val.valueOf(); + const bigIntVal = BigInt(valueOfResult as string | number | bigint); + return bigIntVal; + } catch (e) { + return undefined; + } + } + + const num = Number(val); + return !isNaN(num) ? BigInt(Math.trunc(num)) : undefined; + } + + if (typeof val === "string") { + try { + return BigInt(val); + } catch (e) { + return undefined; + } + } + + if (typeof val === "number") { + return BigInt(Math.trunc(val)); + } + + return val; + }, + z + .bigint() + .nullable() + .optional() + .superRefine((val, ctx) => { + if (val === undefined || val === null) return; + + if (val < INT64_MIN || val > INT64_MAX) { + ctx.addIssue({ + code: "custom", + message: `Value must be between ${INT64_MIN} and ${INT64_MAX} (64-bit signed integer range)`, + }); + } + }), +); + +const MockNumberSchema = z + .object({ + phone: z.string(), + otp: z.string(), + }) + .strict(); + +// ============================================================================ +// Config Schema +// ============================================================================ + +const ConfigSchema = z + .object({ + projectId: z.string(), + projectName: z.string().optional(), + endpoint: z.string().optional(), + settings: z.lazy(() => SettingsSchema).optional(), + functions: z.array(z.lazy(() => FunctionSchema)).optional(), + sites: z.array(z.lazy(() => SiteSchema)).optional(), + databases: z.array(z.lazy(() => DatabaseSchema)).optional(), + collections: z.array(z.lazy(() => CollectionSchema)).optional(), + tables: z.array(z.lazy(() => TablesDBSchema)).optional(), + topics: z.array(z.lazy(() => TopicSchema)).optional(), + teams: z.array(z.lazy(() => TeamSchema)).optional(), + buckets: z.array(z.lazy(() => BucketSchema)).optional(), + messages: z.array(z.lazy(() => MessageSchema)).optional(), + }) + .strict(); + +// ============================================================================ +// Project Settings +// ============================================================================ + +const SettingsSchema = z + .object({ + services: z + .object({ + account: z.boolean().optional(), + avatars: z.boolean().optional(), + databases: z.boolean().optional(), + locale: z.boolean().optional(), + health: z.boolean().optional(), + storage: z.boolean().optional(), + teams: z.boolean().optional(), + users: z.boolean().optional(), + sites: z.boolean().optional(), + functions: z.boolean().optional(), + graphql: z.boolean().optional(), + messaging: z.boolean().optional(), + }) + .strict() + .optional(), + auth: z + .object({ + methods: z + .object({ + jwt: z.boolean().optional(), + phone: z.boolean().optional(), + invites: z.boolean().optional(), + anonymous: z.boolean().optional(), + "email-otp": z.boolean().optional(), + "magic-url": z.boolean().optional(), + "email-password": z.boolean().optional(), + }) + .strict() + .optional(), + security: z + .object({ + duration: z.number().optional(), + limit: z.number().optional(), + sessionsLimit: z.number().optional(), + passwordHistory: z.number().optional(), + passwordDictionary: z.boolean().optional(), + personalDataCheck: z.boolean().optional(), + sessionAlerts: z.boolean().optional(), + mockNumbers: z.array(MockNumberSchema).optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + }) + .strict(); + +// ============================================================================ +// Functions and Sites +// ============================================================================ + +const SiteSchema = z + .object({ + path: z.string().optional(), + $id: z.string(), + name: z.string(), + enabled: z.boolean().optional(), + logging: z.boolean().optional(), + timeout: z.number().optional(), + framework: z.string().optional(), + buildRuntime: z.string().optional(), + adapter: z.string().optional(), + installCommand: z.string().optional(), + buildCommand: z.string().optional(), + outputDirectory: z.string().optional(), + fallbackFile: z.string().optional(), + specification: z.string().optional(), + vars: z.record(z.string(), z.string()).optional(), + ignore: z.string().optional(), + }) + .strict(); + +const FunctionSchema = z + .object({ + path: z.string().optional(), + $id: z.string(), + execute: z.array(z.string()).optional(), + name: z.string(), + enabled: z.boolean().optional(), + logging: z.boolean().optional(), + runtime: z.string(), + specification: z.string().optional(), + scopes: z.array(z.string()).optional(), + events: z.array(z.string()).optional(), + schedule: z.string().optional(), + timeout: z.number().optional(), + entrypoint: z.string().optional(), + commands: z.string().optional(), + vars: z.record(z.string(), z.string()).optional(), + ignore: z.string().optional(), + }) + .strict(); + +// ============================================================================ +// Databases +// ============================================================================ + +const DatabaseSchema = z + .object({ + $id: z.string(), + name: z.string(), + enabled: z.boolean().optional(), + }) + .strict(); + +// ============================================================================ +// Collections (legacy) +// ============================================================================ + +const AttributeSchemaBase = z + .object({ + key: z.string(), + type: z.enum([ + "string", + "integer", + "double", + "boolean", + "datetime", + "relationship", + "linestring", + "point", + "polygon", + ]), + required: z.boolean().optional(), + array: z.boolean().optional(), + size: z.number().optional(), + default: z.any().optional(), + min: int64Schema, + max: int64Schema, + format: z + .union([ + z.enum(["email", "enum", "url", "ip", "datetime"]), + z.literal(""), + ]) + .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(), + attributes: z.array(z.string()).optional(), + orders: z.array(z.string()).optional(), + encrypt: z.boolean().optional(), + }) + .strict(); + +const AttributeSchema = AttributeSchemaBase.refine( + (data) => { + if (data.required === true && data.default !== null) { + return false; + } + return true; + }, + { + message: "When 'required' is true, 'default' must be null", + path: ["default"], + }, +); + +const IndexSchema = z + .object({ + key: z.string(), + type: z.string(), + status: z.string().optional(), + attributes: z.array(z.string()), + orders: z.array(z.string()).optional(), + }) + .strict(); + +const CollectionSchema = z + .object({ + $id: z.string(), + $permissions: z.array(z.string()).optional(), + databaseId: z.string(), + name: z.string(), + enabled: z.boolean().optional(), + documentSecurity: z.boolean().default(true), + attributes: z.array(AttributeSchema).optional(), + indexes: z.array(IndexSchema).optional(), + }) + .strict() + .superRefine((data, ctx) => { + if (data.attributes && data.attributes.length > 0) { + const seenKeys = new Set(); + + data.attributes.forEach((attr, index) => { + if (seenKeys.has(attr.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Attribute with the key '${attr.key}' already exists. Attribute keys must be unique, try again with a different key.`, + path: ["attributes", index, "key"], + }); + } else { + seenKeys.add(attr.key); + } + }); + } + + if (data.indexes && data.indexes.length > 0) { + const seenKeys = new Set(); + + data.indexes.forEach((index, indexPos) => { + if (seenKeys.has(index.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, + path: ["indexes", indexPos, "key"], + }); + } else { + seenKeys.add(index.key); + } + }); + } + }); + +// ============================================================================ +// Tables +// ============================================================================ + +const ColumnSchema = AttributeSchema; + +const IndexTableSchema = z + .object({ + key: z.string(), + type: z.string(), + status: z.string().optional(), + columns: z.array(z.string()), + orders: z.array(z.string()).optional(), + }) + .strict(); + +const TablesDBSchema = z + .object({ + $id: z.string(), + $permissions: z.array(z.string()).optional(), + databaseId: z.string(), + name: z.string(), + enabled: z.boolean().optional(), + rowSecurity: z.boolean().default(true), + columns: z.array(ColumnSchema).optional(), + indexes: z.array(IndexTableSchema).optional(), + }) + .strict() + .superRefine((data, ctx) => { + if (data.columns && data.columns.length > 0) { + const seenKeys = new Set(); + + data.columns.forEach((col, index) => { + if (seenKeys.has(col.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Column with the key '${col.key}' already exists. Column keys must be unique, try again with a different key.`, + path: ["columns", index, "key"], + }); + } else { + seenKeys.add(col.key); + } + }); + } + + if (data.indexes && data.indexes.length > 0) { + const seenKeys = new Set(); + + data.indexes.forEach((index, indexPos) => { + if (seenKeys.has(index.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, + path: ["indexes", indexPos, "key"], + }); + } else { + seenKeys.add(index.key); + } + }); + } + }); + +// ============================================================================ +// Topics +// ============================================================================ + +const TopicSchema = z + .object({ + $id: z.string(), + name: z.string(), + subscribe: z.array(z.string()).optional(), + }) + .strict(); + +// ============================================================================ +// Teams +// ============================================================================ + +const TeamSchema = z + .object({ + $id: z.string(), + name: z.string(), + }) + .strict(); + +// ============================================================================ +// Messages +// ============================================================================ + +const MessageSchema = z + .object({ + $id: z.string(), + name: z.string(), + emailTotal: z.number().optional(), + smsTotal: z.number().optional(), + pushTotal: z.number().optional(), + subscribe: z.array(z.string()).optional(), + }) + .strict(); + +// ============================================================================ +// Buckets +// ============================================================================ + +const BucketSchema = z + .object({ + $id: z.string(), + $permissions: z.array(z.string()).optional(), + fileSecurity: z.boolean().optional(), + name: z.string(), + enabled: z.boolean().optional(), + maximumFileSize: z.number().optional(), + allowedFileExtensions: z.array(z.string()).optional(), + compression: z.string().optional(), + encryption: z.boolean().optional(), + antivirus: z.boolean().optional(), + }) + .strict(); + +// ============================================================================ +// Type Exports (inferred from Zod schemas - single source of truth) +// ============================================================================ + +export type ConfigType = z.infer; +export type SettingsType = z.infer; +export type SiteType = z.infer; +export type FunctionType = z.infer; +export type DatabaseType = z.infer; +export type CollectionType = z.infer; +export type AttributeType = z.infer; +export type IndexType = z.infer; +export type TableType = z.infer; +export type ColumnType = z.infer; +export type TableIndexType = z.infer; +export type TopicType = z.infer; +export type TeamType = z.infer; +export type MessageType = z.infer; +export type BucketType = z.infer; + +// ============================================================================ +// Schema Exports +// ============================================================================ + +export { + ConfigSchema, + + /** Project Settings */ + SettingsSchema, + + /** Functions and Sites */ + SiteSchema, + FunctionSchema, + + /** Databases */ + DatabaseSchema, + + /** Collections (legacy) */ + CollectionSchema, + AttributeSchema, + IndexSchema, + + /** Tables */ + TablesDBSchema, + ColumnSchema, + IndexTableSchema, + + /** Topics */ + TopicSchema, + + /** Teams */ + TeamSchema, + + /** Messages */ + MessageSchema, + + /** Buckets */ + BucketSchema, +}; diff --git a/lib/commands/db.ts b/lib/commands/db.ts new file mode 100644 index 00000000..1149bee4 --- /dev/null +++ b/lib/commands/db.ts @@ -0,0 +1,324 @@ +import { ConfigType, AttributeSchema } from "./config.js"; +import * as fs from "fs"; +import * as path from "path"; +import { z } from "zod"; + +export interface GenerateOptions { + strict?: boolean; +} + +export interface GenerateResult { + dbContent: string; + typesContent: string; +} + +export class Db { + private getType( + attribute: z.infer, + collections: NonNullable, + ): string { + let type = ""; + + switch (attribute.type) { + case "string": + case "datetime": + type = "string"; + if (attribute.format === "enum") { + type = this.toPascalCase(attribute.key); + } + break; + case "integer": + type = "number"; + break; + case "double": + type = "number"; + break; + case "boolean": + type = "boolean"; + break; + case "relationship": + const relatedCollection = collections.find( + (c) => c.$id === attribute.relatedCollection, + ); + if (!relatedCollection) { + throw new Error( + `Related collection with ID '${attribute.relatedCollection}' not found.`, + ); + } + type = this.toPascalCase(relatedCollection.name); + if ( + (attribute.relationType === "oneToMany" && + attribute.side === "parent") || + (attribute.relationType === "manyToOne" && + attribute.side === "child") || + attribute.relationType === "manyToMany" + ) { + type = `${type}[]`; + } + break; + default: + throw new Error(`Unknown attribute type: ${attribute.type}`); + } + + if (attribute.array) { + type += "[]"; + } + + if (!attribute.required && attribute.default === null) { + type += " | null"; + } + + return type; + } + + private toPascalCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) + .replace(/^(.)/, (char) => char.toUpperCase()); + } + + private toCamelCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) + .replace(/^(.)/, (char) => char.toLowerCase()); + } + + private toUpperSnakeCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[-\s]+/g, "_") + .toUpperCase(); + } + + private generateCollectionType( + collection: NonNullable[number], + collections: NonNullable, + options: GenerateOptions = {}, + ): string { + if (!collection.attributes) { + return ""; + } + + const { strict = false } = options; + const typeName = this.toPascalCase(collection.name); + const attributes = collection.attributes + .map((attr: z.infer) => { + const key = strict ? this.toCamelCase(attr.key) : attr.key; + return ` ${key}: ${this.getType(attr, collections)};`; + }) + .join("\n"); + + return `export type ${typeName} = Models.Row & {\n${attributes}\n}`; + } + + private generateEnums( + collections: NonNullable, + ): string { + const enumTypes: string[] = []; + + for (const collection of collections) { + if (!collection.attributes) continue; + + for (const attribute of collection.attributes) { + if (attribute.format === "enum" && attribute.elements) { + const enumName = this.toPascalCase(attribute.key); + const enumValues = attribute.elements + .map((element, index) => { + const key = this.toUpperSnakeCase(element); + const isLast = index === attribute.elements!.length - 1; + return ` ${key} = "${element}"${isLast ? "" : ","}`; + }) + .join("\n"); + + enumTypes.push(`export enum ${enumName} {\n${enumValues}\n}`); + } + } + } + + return enumTypes.join("\n\n"); + } + + private generateTypesFile( + config: ConfigType, + options: GenerateOptions = {}, + ): string { + if (!config.collections || config.collections.length === 0) { + return "// No collections found in configuration\n"; + } + + const appwriteDep = this.getAppwriteDependency(); + const enums = this.generateEnums(config.collections); + const types = config.collections + .map((collection) => + this.generateCollectionType(collection, config.collections!, options), + ) + .join("\n\n"); + + const parts = [`import { type Models } from '${appwriteDep}';`, ""]; + + if (enums) { + parts.push(enums); + parts.push(""); + } + + parts.push(types); + parts.push(""); + + return parts.join("\n"); + } + + private getAppwriteDependency(): string { + const cwd = process.cwd(); + + if (fs.existsSync(path.resolve(cwd, "package.json"))) { + try { + const packageJsonRaw = fs.readFileSync( + path.resolve(cwd, "package.json"), + "utf-8", + ); + const packageJson = JSON.parse(packageJsonRaw); + return packageJson.dependencies?.["appwrite"] + ? "appwrite" + : "node-appwrite"; + } catch { + // Fallback if package.json is invalid + } + } + + if (fs.existsSync(path.resolve(cwd, "deno.json"))) { + return "https://deno.land/x/appwrite/mod.ts"; + } + + return "appwrite"; + } + + private generateDbFile( + config: ConfigType, + options: GenerateOptions = {}, + ): string { + const { strict = false } = options; + const typesFileName = "appwrite.types.ts"; + + if (!config.collections || config.collections.length === 0) { + return "// No collections found in configuration\n"; + } + + const typeNames = config.collections.map((c) => this.toPascalCase(c.name)); + const importPath = typesFileName + .replace(/\.d\.ts$/, "") + .replace(/\.ts$/, ""); + const appwriteDep = this.getAppwriteDependency(); + + const collectionsCode = config.collections + .map((collection) => { + const collectionName = strict + ? this.toCamelCase(collection.name) + : collection.name; + const typeName = this.toPascalCase(collection.name); + + return ` ${collectionName}: { + create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: string[] }) => + tablesDB.createRow<${typeName}>({ + databaseId: process.env.APPWRITE_DB_ID!, + tableId: '${collection.$id}', + rowId: options?.rowId ?? ID.unique(), + data, + permissions: [ + Permission.write(Role.user(data.createdBy)), + Permission.read(Role.user(data.createdBy)), + Permission.update(Role.user(data.createdBy)), + Permission.delete(Role.user(data.createdBy)) + ] + }), + get: (id: string) => + tablesDB.getRow<${typeName}>({ + databaseId: process.env.APPWRITE_DB_ID!, + tableId: '${collection.$id}', + rowId: id, + }), + update: (id: string, data: Partial>, options?: { permissions?: string[] }) => + tablesDB.updateRow<${typeName}>({ + databaseId: process.env.APPWRITE_DB_ID!, + tableId: '${collection.$id}', + rowId: id, + data, + ...(options?.permissions ? { permissions: options.permissions } : {}), + }), + delete: (id: string) => + tablesDB.deleteRow({ + databaseId: process.env.APPWRITE_DB_ID!, + tableId: '${collection.$id}', + rowId: id, + }), + list: (queries?: string[]) => + tablesDB.listRows<${typeName}>({ + databaseId: process.env.APPWRITE_DB_ID!, + tableId: '${collection.$id}', + queries, + }), + }`; + }) + .join(",\n"); + + return `import { Client, TablesDB, ID, type Models, Permission, Role } from '${appwriteDep}'; +import type { ${typeNames.join(", ")} } from './${importPath}'; + +const client = new Client() + .setEndpoint(process.env.APPWRITE_ENDPOINT!) + .setProject(process.env.APPWRITE_PROJECT_ID!) + .setKey(process.env.APPWRITE_API_KEY!); + +const tablesDB = new TablesDB(client); + + +export const db = { +${collectionsCode} +}; +`; + } + + /** + * Generates TypeScript code for Appwrite database collections and types based on the provided configuration. + * + * This method returns generated content as strings: + * 1. A types string containing TypeScript interfaces for each collection. + * 2. A database client string with helper methods for CRUD operations on each collection. + * + * @param config - The Appwrite project configuration, including collections and project details. + * @param options - Optional settings for code generation: + * - strict: Whether to use strict naming conventions for collections (default: false). + * @returns A Promise that resolves with an object containing dbContent and typesContent strings. + * @throws If the configuration is missing a projectId or contains no collections. + */ + public async generate( + config: ConfigType, + options: GenerateOptions = {}, + ): Promise { + const { strict = false } = options; + + if (!config.projectId) { + throw new Error("Project ID is required in configuration"); + } + + if (!config.collections || config.collections.length === 0) { + console.log( + "No collections found in configuration. Skipping database generation.", + ); + return { + dbContent: "// No collections found in configuration\n", + typesContent: "// No collections found in configuration\n", + }; + } + + // Generate types content + const typesContent = this.generateTypesFile(config, { strict }); + + // Generate database client content + const dbContent = this.generateDbFile(config, { strict }); + + return { + dbContent, + typesContent, + }; + } +} diff --git a/lib/commands/errors.ts b/lib/commands/errors.ts new file mode 100644 index 00000000..44610c29 --- /dev/null +++ b/lib/commands/errors.ts @@ -0,0 +1,93 @@ +/** + * Error thrown when destructive changes are detected during push operations + * and the force flag is not enabled. + */ +export class DestructiveChangeError extends Error { + constructor( + message: string, + private metadata: { + changes: Array<{ + type: string; + resource: string; + field: string; + oldValue?: any; + newValue?: any; + }>; + affectedResources: number; + }, + ) { + super(message); + this.name = "DestructiveChangeError"; + Error.captureStackTrace(this, DestructiveChangeError); + } + + /** + * Get detailed metadata about the destructive changes + */ + public getMetadata() { + return this.metadata; + } +} + +/** + * Error thrown when configuration validation fails + */ +export class ConfigValidationError extends Error { + constructor( + message: string, + private validationErrors: Array<{ + path: string; + message: string; + }>, + ) { + super(message); + this.name = "ConfigValidationError"; + Error.captureStackTrace(this, ConfigValidationError); + } + + /** + * Get detailed validation errors + */ + public getValidationErrors() { + return this.validationErrors; + } +} + +/** + * Error thrown when a requested resource is not found + */ +export class ResourceNotFoundError extends Error { + constructor( + message: string, + public resourceType: string, + public resourceId: string, + ) { + super(message); + this.name = "ResourceNotFoundError"; + Error.captureStackTrace(this, ResourceNotFoundError); + } +} + +/** + * Error thrown when authentication fails or is missing + */ +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + Error.captureStackTrace(this, AuthenticationError); + } +} + +/** + * Error thrown when project is not initialized + */ +export class ProjectNotInitializedError extends Error { + constructor( + message: string = "Project configuration not found. Project must be initialized first.", + ) { + super(message); + this.name = "ProjectNotInitializedError"; + Error.captureStackTrace(this, ProjectNotInitializedError); + } +} diff --git a/lib/commands/pull.ts b/lib/commands/pull.ts index ccf60cec..b01bf9ea 100644 --- a/lib/commands/pull.ts +++ b/lib/commands/pull.ts @@ -1,22 +1,25 @@ import fs from "fs"; import chalk from "chalk"; -import tar from "tar"; import { Command } from "commander"; import inquirer from "inquirer"; import { - getMessagingService, - getTeamsService, - getProjectsService, - getFunctionsService, - getSitesService, - getDatabasesService, - getTablesDBService, - getStorageService, -} from "../services.js"; + Databases, + Functions, + Messaging, + Projects, + Sites, + Storage, + TablesDB, + Teams, + Client, + Query, + Models, +} from "@appwrite.io/console"; +import { getFunctionsService, getSitesService } from "../services.js"; +import { sdkForProject, sdkForConsole } from "../sdks.js"; import { localConfig } from "../config.js"; import { paginate } from "../paginate.js"; import { - questionsPullCollection, questionsPullFunctions, questionsPullFunctionsCode, questionsPullSites, @@ -32,573 +35,846 @@ import { actionRunner, commandDescriptions, } from "../parser.js"; - -interface PullResourcesOptions { +import type { ConfigType } from "./config.js"; +import { createSettingsObject } from "../utils.js"; +import { ProjectNotInitializedError } from "./errors.js"; +import type { SettingsType, FunctionType, SiteType } from "./config.js"; +import { downloadDeploymentCode } from "./utils/deployment.js"; + +export interface PullOptions { + all?: boolean; + settings?: boolean; + functions?: boolean; + sites?: boolean; + collections?: boolean; + tables?: boolean; + buckets?: boolean; + teams?: boolean; + topics?: boolean; skipDeprecated?: boolean; + withVariables?: boolean; + noCode?: boolean; } interface PullFunctionsOptions { code?: boolean; withVariables?: boolean; + functionIds?: string[]; } interface PullSitesOptions { code?: boolean; withVariables?: boolean; + siteIds?: string[]; } -export const pullResources = async ({ - skipDeprecated = false, -}: PullResourcesOptions = {}): Promise => { - const project = localConfig.getProject(); - if (!project.projectId) { - error( - "Project configuration not found. Please run 'appwrite init project' to initialize your project first.", - ); - process.exit(1); +export interface PullSettingsResult { + projectName: string; + settings: SettingsType; + project: Models.Project; +} + +async function createPullInstance(): Promise { + const projectClient = await sdkForProject(); + const consoleClient = await sdkForConsole(); + const pullInstance = new Pull(projectClient, consoleClient); + + pullInstance.setConfigDirectoryPath(localConfig.configDirectoryPath); + return pullInstance; +} + +export class Pull { + private projectClient: Client; + private consoleClient: Client; + private configDirectoryPath: string; + private silent: boolean; + + constructor(projectClient: Client, consoleClient: Client, silent = false) { + this.projectClient = projectClient; + this.consoleClient = consoleClient; + this.configDirectoryPath = process.cwd(); + this.silent = silent; } - const actions: Record Promise> = { - settings: pullSettings, - functions: pullFunctions, - sites: pullSites, - collections: pullCollection, - tables: pullTable, - buckets: pullBucket, - teams: pullTeam, - messages: pullMessagingTopic, - }; + /** + * Set the base directory path for config files and resources + */ + public setConfigDirectoryPath(path: string): void { + this.configDirectoryPath = path; + } - if (skipDeprecated) { - delete actions.collections; + /** + * Log a message (respects silent mode) + */ + private log(message: string): void { + if (!this.silent) { + log(message); + } } - if (cliConfig.all) { - for (let action of Object.values(actions)) { - cliConfig.all = true; - await action({ returnOnZero: true }); + /** + * Log a success message (respects silent mode) + */ + private success(message: string): void { + if (!this.silent) { + success(message); } - } else { - const answers = await inquirer.prompt([questionsPullResources[0]]); + } - const action = actions[answers.resource]; - if (action !== undefined) { - await action({ returnOnZero: true }); + /** + * Log a warning message (respects silent mode) + */ + private warn(message: string): void { + if (!this.silent) { + warn(message); } } -}; -const pullSettings = async (): Promise => { - log("Pulling project settings ..."); + /** + * Pull resources from Appwrite project and return updated config + * + * @param config - Current configuration object + * @param options - Pull options specifying which resources to pull + * @returns Updated configuration object with pulled resources + */ + public async pullResources( + config: ConfigType, + options: PullOptions = { all: true, skipDeprecated: true }, + ): Promise { + const { skipDeprecated = true } = options; + if (!config.projectId) { + throw new ProjectNotInitializedError(); + } - try { - const projectsService = await getProjectsService(); - let response = await projectsService.get( - localConfig.getProject().projectId, - ); + const updatedConfig: ConfigType = { ...config }; + const shouldPullAll = options.all === true; + + if (shouldPullAll || options.settings) { + const settings = await this.pullSettings(config.projectId); + updatedConfig.settings = settings.settings; + updatedConfig.projectName = settings.projectName; + } + + if (shouldPullAll || options.functions) { + const functions = await this.pullFunctions({ + code: options.noCode === true ? false : true, + withVariables: options.withVariables, + }); + updatedConfig.functions = functions; + } + + if (shouldPullAll || options.sites) { + const sites = await this.pullSites({ + code: options.noCode === true ? false : true, + withVariables: options.withVariables, + }); + updatedConfig.sites = sites; + } + + if (shouldPullAll || options.tables) { + const { databases, tables } = await this.pullTables(); + updatedConfig.databases = databases; + updatedConfig.tables = tables; + } + + if (!skipDeprecated && (shouldPullAll || options.collections)) { + const { databases, collections } = await this.pullCollections(); + updatedConfig.databases = databases; + updatedConfig.collections = collections; + } - localConfig.setProject(response.$id, response.name, response); + if (shouldPullAll || options.buckets) { + const buckets = await this.pullBuckets(); + updatedConfig.buckets = buckets; + } + + if (shouldPullAll || options.teams) { + const teams = await this.pullTeams(); + updatedConfig.teams = teams; + } + + if (shouldPullAll || options.topics) { + const topics = await this.pullMessagingTopics(); + updatedConfig.topics = topics; + } - success(`Successfully pulled ${chalk.bold("all")} project settings.`); - } catch (e) { - throw e; + return updatedConfig; } -}; -const pullFunctions = async ({ - code, - withVariables, -}: PullFunctionsOptions = {}): Promise => { - process.chdir(localConfig.configDirectoryPath); + /** + * Pull project settings + */ + public async pullSettings(projectId: string): Promise { + this.log("Pulling project settings ..."); - log("Fetching functions ..."); - let total = 0; + const projectsService = new Projects(this.consoleClient); + const project = await projectsService.get({ projectId: projectId }); - const functionsService = await getFunctionsService(); - const fetchResponse = await functionsService.list([ - JSON.stringify({ method: "limit", values: [1] }), - ]); - if (fetchResponse["functions"].length <= 0) { - log("No functions found."); - success(`Successfully pulled ${chalk.bold(total)} functions.`); - return; + this.success(`Successfully pulled ${chalk.bold("all")} project settings.`); + + return { + projectName: project.name, + settings: createSettingsObject(project), + project, + }; } - const functions = cliConfig.all - ? ( - await paginate( - async () => (await getFunctionsService()).list(), + /** + * Pull functions from the project + */ + public async pullFunctions( + options: PullFunctionsOptions = {}, + ): Promise { + this.log("Fetching functions ..."); + + const originalCwd = process.cwd(); + process.chdir(this.configDirectoryPath); + + try { + const functionsService = new Functions(this.projectClient); + let functions: Models.Function[]; + + if (options.functionIds && options.functionIds.length > 0) { + functions = await Promise.all( + options.functionIds.map((id) => + functionsService.get({ + functionId: id, + }), + ), + ); + } else { + const fetchResponse = await functionsService.list({ + queries: [Query.limit(1)], + }); + + if (fetchResponse["functions"].length <= 0) { + this.log("No functions found."); + this.success(`Successfully pulled ${chalk.bold(0)} functions.`); + return []; + } + + const { functions: allFunctions } = await paginate( + async () => new Functions(this.projectClient).list(), {}, 100, "functions", - ) - ).functions - : (await inquirer.prompt(questionsPullFunctions)).functions; - - let allowCodePull: boolean | null = cliConfig.force === true ? true : null; + ); + functions = allFunctions; + } - for (let func of functions) { - total++; - log(`Pulling function ${chalk.bold(func["name"])} ...`); + const result: FunctionType[] = []; + + for (const func of functions) { + this.log(`Pulling function ${chalk.bold(func.name)} ...`); + + const funcPath = `functions/${func.name}`; + const holdingVars = func.vars || []; + + const functionConfig: FunctionType = { + $id: func.$id, + name: func.name, + runtime: func.runtime, + path: funcPath, + entrypoint: func.entrypoint, + execute: func.execute, + enabled: func.enabled, + logging: func.logging, + events: func.events, + schedule: func.schedule, + timeout: func.timeout, + commands: func.commands, + scopes: func.scopes, + specification: func.specification, + }; + + result.push(functionConfig); + + if (!fs.existsSync(funcPath)) { + fs.mkdirSync(funcPath, { recursive: true }); + } + + if (options.code !== false) { + await downloadDeploymentCode({ + resourceId: func["$id"], + resourcePath: funcPath, + holdingVars, + withVariables: options.withVariables, + listDeployments: () => + functionsService.listDeployments({ + functionId: func["$id"], + queries: [Query.limit(1), Query.orderDesc("$id")], + }), + getDownloadUrl: (deploymentId) => + functionsService.getDeploymentDownload({ + functionId: func["$id"], + deploymentId, + }), + projectClient: this.projectClient, + }); + } + } - const localFunction = localConfig.getFunction(func.$id); + if (options.code === false) { + this.warn("Source code download skipped."); + } - func["path"] = localFunction["path"]; - if (!localFunction["path"]) { - func["path"] = `functions/${func.name}`; + this.success( + `Successfully pulled ${chalk.bold(result.length)} functions.`, + ); + return result; + } finally { + process.chdir(originalCwd); } - const holdingVars = func["vars"]; - // We don't save var in to the config - delete func["vars"]; - localConfig.addFunction(func); + } - if (!fs.existsSync(func["path"])) { - fs.mkdirSync(func["path"], { recursive: true }); - } + /** + * Pull sites from the project + */ + public async pullSites(options: PullSitesOptions = {}): Promise { + this.log("Fetching sites ..."); - if (code === false) { - warn("Source code download skipped."); - continue; - } + const originalCwd = process.cwd(); + process.chdir(this.configDirectoryPath); - if (allowCodePull === null) { - const codeAnswer = await inquirer.prompt(questionsPullFunctionsCode); - allowCodePull = codeAnswer.override; - } + try { + const sitesService = new Sites(this.projectClient); + let sites: Models.Site[]; + + if (options.siteIds && options.siteIds.length > 0) { + sites = await Promise.all( + options.siteIds.map((id) => + sitesService.get({ + siteId: id, + }), + ), + ); + } else { + const fetchResponse = await sitesService.list({ + queries: [Query.limit(1)], + }); + + if (fetchResponse["sites"].length <= 0) { + this.log("No sites found."); + this.success(`Successfully pulled ${chalk.bold(0)} sites.`); + return []; + } + + const { sites: fetchedSites } = await paginate( + async () => new Sites(this.projectClient).list(), + {}, + 100, + "sites", + ); + sites = fetchedSites; + } - if (!allowCodePull) { - continue; + const result: SiteType[] = []; + + for (const site of sites) { + this.log(`Pulling site ${chalk.bold(site.name)} ...`); + + const sitePath = `sites/${site.name}`; + const holdingVars = site.vars || []; + + const siteConfig: SiteType = { + $id: site.$id, + name: site.name, + path: sitePath, + framework: site.framework, + enabled: site.enabled, + logging: site.logging, + timeout: site.timeout, + buildRuntime: site.buildRuntime, + adapter: site.adapter, + installCommand: site.installCommand, + buildCommand: site.buildCommand, + outputDirectory: site.outputDirectory, + fallbackFile: site.fallbackFile, + specification: site.specification, + }; + + result.push(siteConfig); + + if (!fs.existsSync(sitePath)) { + fs.mkdirSync(sitePath, { recursive: true }); + } + + if (options.code !== false) { + await downloadDeploymentCode({ + resourceId: site["$id"], + resourcePath: sitePath, + holdingVars, + withVariables: options.withVariables, + listDeployments: () => + sitesService.listDeployments({ + siteId: site["$id"], + queries: [Query.limit(1), Query.orderDesc("$id")], + }), + getDownloadUrl: (deploymentId) => + sitesService.getDeploymentDownload({ + siteId: site["$id"], + deploymentId, + }), + projectClient: this.projectClient, + }); + } + } + + if (options.code === false) { + this.warn("Source code download skipped."); + } + + this.success(`Successfully pulled ${chalk.bold(result.length)} sites.`); + return result; + } finally { + process.chdir(originalCwd); } + } - let deploymentId: string | null = null; + /** + * Pull collections from the project (deprecated) + */ + public async pullCollections(): Promise<{ + databases: any[]; + collections: any[]; + }> { + this.warn( + "appwrite pull collection has been deprecated. Please consider using 'appwrite pull tables' instead", + ); + this.log("Fetching collections ..."); - try { - const fetchResponse = await functionsService.listDeployments({ - functionId: func["$id"], - queries: [ - JSON.stringify({ method: "limit", values: [1] }), - JSON.stringify({ method: "orderDesc", values: ["$id"] }), - ], - }); + const databasesService = new Databases(this.projectClient); - if (fetchResponse["total"] > 0) { - deploymentId = fetchResponse["deployments"][0]["$id"]; - } - } catch {} + const fetchResponse = await databasesService.list([Query.limit(1)]); - if (deploymentId === null) { - log( - "Source code download skipped because function doesn't have any available deployment", + if (fetchResponse["databases"].length <= 0) { + this.log("No collections found."); + this.success( + `Successfully pulled ${chalk.bold(0)} collections from ${chalk.bold(0)} databases.`, ); - continue; + return { databases: [], collections: [] }; } - log("Pulling latest deployment code ..."); + const { databases } = await paginate( + async () => new Databases(this.projectClient).list(), + {}, + 100, + "databases", + ); - const compressedFileName = `${func["$id"]}-${+new Date()}.tar.gz`; - const downloadUrl = functionsService.getDeploymentDownload({ - functionId: func["$id"], - deploymentId: deploymentId, - }); + const allDatabases: any[] = []; + const allCollections: any[] = []; - const client = (await getFunctionsService()).client; - const downloadBuffer = await client.call( - "get", - new URL(downloadUrl), - {}, - {}, - "arrayBuffer", + for (const database of databases) { + this.log( + `Pulling all collections from ${chalk.bold(database.name)} database ...`, + ); + allDatabases.push(database); + + const { collections } = await paginate( + async () => + new Databases(this.projectClient).listCollections(database.$id), + {}, + 100, + "collections", + ); + + for (const collection of collections) { + allCollections.push({ + ...collection, + $createdAt: undefined, + $updatedAt: undefined, + }); + } + } + + this.success( + `Successfully pulled ${chalk.bold(allCollections.length)} collections from ${chalk.bold(allDatabases.length)} databases.`, ); - fs.writeFileSync(compressedFileName, Buffer.from(downloadBuffer as any)); + return { + databases: allDatabases, + collections: allCollections, + }; + } - tar.extract({ - sync: true, - cwd: func["path"], - file: compressedFileName, - strict: false, - }); + /** + * Pull tables from the project + */ + public async pullTables(): Promise<{ + databases: any[]; + tables: any[]; + }> { + this.log("Fetching tables ..."); - fs.rmSync(compressedFileName); + const tablesDBService = new TablesDB(this.projectClient); - if (withVariables) { - const envFileLocation = `${func["path"]}/.env`; - try { - fs.rmSync(envFileLocation); - } catch {} + const fetchResponse = await tablesDBService.list({ + queries: [Query.limit(1)], + }); - fs.writeFileSync( - envFileLocation, - holdingVars.map((r: any) => `${r.key}=${r.value}\n`).join(""), + if (fetchResponse["databases"].length <= 0) { + this.log("No tables found."); + this.success( + `Successfully pulled ${chalk.bold(0)} tables from ${chalk.bold(0)} tableDBs.`, ); + return { databases: [], tables: [] }; } - } - success(`Successfully pulled ${chalk.bold(total)} functions.`); -}; + const { databases } = await paginate( + async () => new TablesDB(this.projectClient).list(), + {}, + 100, + "databases", + ); -const pullSites = async ({ - code, - withVariables, -}: PullSitesOptions = {}): Promise => { - process.chdir(localConfig.configDirectoryPath); + const allDatabases: any[] = []; + const allTables: any[] = []; - log("Fetching sites ..."); - let total = 0; + for (const database of databases) { + this.log( + `Pulling all tables from ${chalk.bold(database.name)} database ...`, + ); + allDatabases.push(database); - const sitesService = await getSitesService(); - const fetchResponse = await sitesService.list([ - JSON.stringify({ method: "limit", values: [1] }), - ]); - if (fetchResponse["sites"].length <= 0) { - log("No sites found."); - success(`Successfully pulled ${chalk.bold(total)} sites.`); - return; - } + const { tables } = await paginate( + async () => new TablesDB(this.projectClient).listTables(database.$id), + {}, + 100, + "tables", + ); - const sites = cliConfig.all - ? ( - await paginate( - async () => (await getSitesService()).list(), - {}, - 100, - "sites", - ) - ).sites - : (await inquirer.prompt(questionsPullSites)).sites; + for (const table of tables) { + allTables.push({ + ...table, + $createdAt: undefined, + $updatedAt: undefined, + }); + } + } - let allowCodePull: boolean | null = cliConfig.force === true ? true : null; + this.success( + `Successfully pulled ${chalk.bold(allTables.length)} tables from ${chalk.bold(allDatabases.length)} tableDBs.`, + ); - for (let site of sites) { - total++; - log(`Pulling site ${chalk.bold(site["name"])} ...`); + return { + databases: allDatabases, + tables: allTables, + }; + } - const localSite = localConfig.getSite(site.$id); + /** + * Pull storage buckets from the project + */ + public async pullBuckets(): Promise { + this.log("Fetching buckets ..."); - site["path"] = localSite["path"]; - if (!localSite["path"]) { - site["path"] = `sites/${site.name}`; - } - const holdingVars = site["vars"]; - // We don't save var in to the config - delete site["vars"]; - localConfig.addSite(site); + const storageService = new Storage(this.projectClient); - if (!fs.existsSync(site["path"])) { - fs.mkdirSync(site["path"], { recursive: true }); - } + const fetchResponse = await storageService.listBuckets({ + queries: [Query.limit(1)], + }); - if (code === false) { - warn("Source code download skipped."); - continue; + if (fetchResponse["buckets"].length <= 0) { + this.log("No buckets found."); + this.success(`Successfully pulled ${chalk.bold(0)} buckets.`); + return []; } - if (allowCodePull === null) { - const codeAnswer = await inquirer.prompt(questionsPullSitesCode); - allowCodePull = codeAnswer.override; - } + const { buckets } = await paginate( + async () => new Storage(this.projectClient).listBuckets(), + {}, + 100, + "buckets", + ); - if (!allowCodePull) { - continue; + for (const bucket of buckets) { + this.log(`Pulling bucket ${chalk.bold(bucket.name)} ...`); } - let deploymentId: string | null = null; + this.success(`Successfully pulled ${chalk.bold(buckets.length)} buckets.`); - try { - const fetchResponse = await sitesService.listDeployments({ - siteId: site["$id"], - queries: [ - JSON.stringify({ method: "limit", values: [1] }), - JSON.stringify({ method: "orderDesc", values: ["$id"] }), - ], - }); + return buckets; + } - if (fetchResponse["total"] > 0) { - deploymentId = fetchResponse["deployments"][0]["$id"]; - } - } catch {} + /** + * Pull teams from the project + */ + public async pullTeams(): Promise { + this.log("Fetching teams ..."); - if (deploymentId === null) { - log( - "Source code download skipped because site doesn't have any available deployment", - ); - continue; + const teamsService = new Teams(this.projectClient); + + const fetchResponse = await teamsService.list({ + queries: [Query.limit(1)], + }); + + if (fetchResponse["teams"].length <= 0) { + this.log("No teams found."); + this.success(`Successfully pulled ${chalk.bold(0)} teams.`); + return []; + } + + const { teams } = await paginate( + async () => new Teams(this.projectClient).list(), + {}, + 100, + "teams", + ); + + for (const team of teams) { + this.log(`Pulling team ${chalk.bold(team.name)} ...`); } - log("Pulling latest deployment code ..."); + this.success(`Successfully pulled ${chalk.bold(teams.length)} teams.`); + + return teams; + } + + /** + * Pull messaging topics from the project + */ + public async pullMessagingTopics(): Promise { + this.log("Fetching topics ..."); - const compressedFileName = `${site["$id"]}-${+new Date()}.tar.gz`; - const downloadUrl = sitesService.getDeploymentDownload({ - siteId: site["$id"], - deploymentId: deploymentId, + const messagingService = new Messaging(this.projectClient); + + const fetchResponse = await messagingService.listTopics({ + queries: [Query.limit(1)], }); - const client = (await getSitesService()).client; - const downloadBuffer = await client.call( - "get", - new URL(downloadUrl), - {}, + if (fetchResponse["topics"].length <= 0) { + this.log("No topics found."); + this.success(`Successfully pulled ${chalk.bold(0)} topics.`); + return []; + } + + const { topics } = await paginate( + async () => new Messaging(this.projectClient).listTopics(), {}, - "arrayBuffer", + 100, + "topics", ); - fs.writeFileSync(compressedFileName, Buffer.from(downloadBuffer as any)); + for (const topic of topics) { + this.log(`Pulling topic ${chalk.bold(topic.name)} ...`); + } - tar.extract({ - sync: true, - cwd: site["path"], - file: compressedFileName, - strict: false, - }); + this.success(`Successfully pulled ${chalk.bold(topics.length)} topics.`); - fs.rmSync(compressedFileName); + return topics; + } +} - if (withVariables) { - const envFileLocation = `${site["path"]}/.env`; - try { - fs.rmSync(envFileLocation); - } catch {} +/** Helper methods for CLI commands */ - fs.writeFileSync( - envFileLocation, - holdingVars.map((r: any) => `${r.key}=${r.value}\n`).join(""), - ); +export const pullResources = async ({ + skipDeprecated = true, +}: { + skipDeprecated?: boolean; +} = {}): Promise => { + const project = localConfig.getProject(); + if (!project.projectId) { + error( + "Project configuration not found. Please run 'appwrite init project' to initialize your project first.", + ); + process.exit(1); + } + + const actions: Record Promise> = { + settings: pullSettings, + functions: pullFunctions, + sites: pullSites, + collections: pullCollection, + tables: pullTable, + buckets: pullBucket, + teams: pullTeam, + messages: pullMessagingTopic, + }; + + if (skipDeprecated) { + delete actions.collections; + } + + if (cliConfig.all) { + for (let action of Object.values(actions)) { + cliConfig.all = true; + await action({ returnOnZero: true }); + } + } else { + const answers = await inquirer.prompt([questionsPullResources[0]]); + + const action = actions[answers.resource]; + if (action !== undefined) { + await action({ returnOnZero: true }); } } +}; + +const pullSettings = async (): Promise => { + const pullInstance = await createPullInstance(); + const projectId = localConfig.getProject().projectId; + const settings = await pullInstance.pullSettings(projectId); - success(`Successfully pulled ${chalk.bold(total)} sites.`); + localConfig.setProject(projectId, settings.projectName, settings.project); }; -const pullCollection = async (): Promise => { - warn( - "appwrite pull collection has been deprecated. Please consider using 'appwrite pull tables' instead", - ); - log("Fetching collections ..."); - let totalDatabases = 0; - let totalCollections = 0; - - const databasesService = await getDatabasesService(); - const fetchResponse = await databasesService.list([ - JSON.stringify({ method: "limit", values: [1] }), - ]); - if (fetchResponse["databases"].length <= 0) { - log("No collections found."); - success( - `Successfully pulled ${chalk.bold(totalCollections)} collections from ${chalk.bold(totalDatabases)} databases.`, - ); +const pullFunctions = async ({ + code, + withVariables, +}: PullFunctionsOptions = {}): Promise => { + const functionsService = await getFunctionsService(); + const fetchResponse = await functionsService.list([Query.limit(1)]); + if (fetchResponse["functions"].length <= 0) { + log("No functions found."); + success(`Successfully pulled ${chalk.bold(0)} functions.`); return; } - let databases: string[] = cliConfig.ids; - - if (databases.length === 0) { - if (cliConfig.all) { - databases = ( + const functionsToCheck = cliConfig.all + ? ( await paginate( - async () => (await getDatabasesService()).list(), + async () => (await getFunctionsService()).list(), {}, 100, - "databases", + "functions", ) - ).databases.map((database: any) => database.$id); - } else { - databases = (await inquirer.prompt(questionsPullCollection)).databases; - } - } - - for (const databaseId of databases) { - const database = await databasesService.get(databaseId); + ).functions + : (await inquirer.prompt(questionsPullFunctions)).functions; - totalDatabases++; - log( - `Pulling all collections from ${chalk.bold(database["name"])} database ...`, - ); + let allowCodePull: boolean | null = cliConfig.force === true ? true : null; + if (code !== false && allowCodePull === null) { + const codeAnswer = await inquirer.prompt(questionsPullFunctionsCode); + allowCodePull = codeAnswer.override; + } - localConfig.addDatabase(database); + const shouldPullCode = code !== false && allowCodePull === true; + const selectedFunctionIds = functionsToCheck.map((f: any) => f.$id); - const { collections } = await paginate( - async () => (await getDatabasesService()).listCollections(databaseId), - {}, - 100, - "collections", - ); + const pullInstance = await createPullInstance(); + const functions = await pullInstance.pullFunctions({ + code: shouldPullCode, + withVariables, + functionIds: selectedFunctionIds, + }); - for (const collection of collections) { - totalCollections++; - localConfig.addCollection({ - ...collection, - $createdAt: undefined, - $updatedAt: undefined, - }); - } + for (const func of functions) { + const localFunction = localConfig.getFunction(func.$id); + func["path"] = localFunction["path"] || func["path"]; + localConfig.addFunction(func); } - - success( - `Successfully pulled ${chalk.bold(totalCollections)} collections from ${chalk.bold(totalDatabases)} databases.`, - ); }; -const pullTable = async (): Promise => { - log("Fetching tables ..."); - let totalTablesDBs = 0; - let totalTables = 0; - - const tablesDBService = await getTablesDBService(); - const fetchResponse = await tablesDBService.list([ - JSON.stringify({ method: "limit", values: [1] }), - ]); - if (fetchResponse["databases"].length <= 0) { - log("No tables found."); - success( - `Successfully pulled ${chalk.bold(totalTables)} tables from ${chalk.bold(totalTablesDBs)} tableDBs.`, - ); +const pullSites = async ({ + code, + withVariables, +}: PullSitesOptions = {}): Promise => { + const sitesService = await getSitesService(); + const fetchResponse = await sitesService.list({ + queries: [Query.limit(1)], + }); + if (fetchResponse["sites"].length <= 0) { + log("No sites found."); + success(`Successfully pulled ${chalk.bold(0)} sites.`); return; } - let databases: string[] = cliConfig.ids; - - if (databases.length === 0) { - if (cliConfig.all) { - databases = ( + const sitesToCheck = cliConfig.all + ? ( await paginate( - async () => (await getTablesDBService()).list(), + async () => (await getSitesService()).list(), {}, 100, - "databases", + "sites", ) - ).databases.map((database: any) => database.$id); - } else { - databases = (await inquirer.prompt(questionsPullCollection)).databases; - } + ).sites + : (await inquirer.prompt(questionsPullSites)).sites; + + let allowCodePull: boolean | null = cliConfig.force === true ? true : null; + if (code !== false && allowCodePull === null) { + const codeAnswer = await inquirer.prompt(questionsPullSitesCode); + allowCodePull = codeAnswer.override; } - for (const databaseId of databases) { - const database = await tablesDBService.get(databaseId); + const shouldPullCode = code !== false && allowCodePull === true; + const selectedSiteIds = sitesToCheck.map((s: any) => s.$id); - totalTablesDBs++; - log(`Pulling all tables from ${chalk.bold(database["name"])} database ...`); + const pullInstance = await createPullInstance(); + const sites = await pullInstance.pullSites({ + code: shouldPullCode, + withVariables, + siteIds: selectedSiteIds, + }); - localConfig.addTablesDB(database); + for (const site of sites) { + const localSite = localConfig.getSite(site.$id); + site["path"] = localSite["path"] || site["path"]; + localConfig.addSite(site); + } +}; - const { tables } = await paginate( - async () => (await getTablesDBService()).listTables(databaseId), - {}, - 100, - "tables", - ); +const pullCollection = async (): Promise => { + const pullInstance = await createPullInstance(); + const { databases, collections } = await pullInstance.pullCollections(); - for (const table of tables) { - totalTables++; - localConfig.addTable({ - ...table, - $createdAt: undefined, - $updatedAt: undefined, - }); - } + for (const database of databases) { + localConfig.addDatabase(database); } - success( - `Successfully pulled ${chalk.bold(totalTables)} tables from ${chalk.bold(totalTablesDBs)} tableDBs.`, - ); + for (const collection of collections) { + localConfig.addCollection(collection); + } }; -const pullBucket = async (): Promise => { - log("Fetching buckets ..."); - let total = 0; - - const storageService = await getStorageService(); - const fetchResponse = await storageService.listBuckets([ - JSON.stringify({ method: "limit", values: [1] }), - ]); - if (fetchResponse["buckets"].length <= 0) { - log("No buckets found."); - success(`Successfully pulled ${chalk.bold(total)} buckets.`); - return; +const pullTable = async (): Promise => { + const pullInstance = await createPullInstance(); + const { databases, tables } = await pullInstance.pullTables(); + + for (const database of databases) { + localConfig.addTablesDB(database); } - const { buckets } = await paginate( - async () => (await getStorageService()).listBuckets(), - {}, - 100, - "buckets", - ); + for (const table of tables) { + localConfig.addTable(table); + } +}; + +const pullBucket = async (): Promise => { + const pullInstance = await createPullInstance(); + const buckets = await pullInstance.pullBuckets(); for (const bucket of buckets) { - total++; - log(`Pulling bucket ${chalk.bold(bucket["name"])} ...`); localConfig.addBucket(bucket); } - - success(`Successfully pulled ${chalk.bold(total)} buckets.`); }; const pullTeam = async (): Promise => { - log("Fetching teams ..."); - let total = 0; - - const teamsService = await getTeamsService(); - const fetchResponse = await teamsService.list([ - JSON.stringify({ method: "limit", values: [1] }), - ]); - if (fetchResponse["teams"].length <= 0) { - log("No teams found."); - success(`Successfully pulled ${chalk.bold(total)} teams.`); - return; - } - - const { teams } = await paginate( - async () => (await getTeamsService()).list(), - {}, - 100, - "teams", - ); + const pullInstance = await createPullInstance(); + const teams = await pullInstance.pullTeams(); for (const team of teams) { - total++; - log(`Pulling team ${chalk.bold(team["name"])} ...`); localConfig.addTeam(team); } - - success(`Successfully pulled ${chalk.bold(total)} teams.`); }; const pullMessagingTopic = async (): Promise => { - log("Fetching topics ..."); - let total = 0; - - const messagingService = await getMessagingService(); - const fetchResponse = await messagingService.listTopics([ - JSON.stringify({ method: "limit", values: [1] }), - ]); - if (fetchResponse["topics"].length <= 0) { - log("No topics found."); - success(`Successfully pulled ${chalk.bold(total)} topics.`); - return; - } - - const { topics } = await paginate( - async () => (await getMessagingService()).listTopics(), - {}, - 100, - "topics", - ); + const pullInstance = await createPullInstance(); + const topics = await pullInstance.pullMessagingTopics(); for (const topic of topics) { - total++; - log(`Pulling topic ${chalk.bold(topic["name"])} ...`); localConfig.addMessagingTopic(topic); } - - success(`Successfully pulled ${chalk.bold(total)} topics.`); }; +/** Commander.js exports */ + export const pull = new Command("pull") .description(commandDescriptions["pull"]) .action(actionRunner(() => pullResources({ skipDeprecated: true }))); pull .command("all") - .description("Pull all resource.") + .description("Pull all resources") .action( actionRunner(() => { cliConfig.all = true; diff --git a/lib/commands/push.ts b/lib/commands/push.ts index b1ae3a60..123c0d63 100644 --- a/lib/commands/push.ts +++ b/lib/commands/push.ts @@ -7,18 +7,19 @@ import ID from "../id.js"; import { localConfig, globalConfig, - KeysAttributes, KeysFunction, KeysSite, - whitelistKeys, KeysTopics, KeysStorage, KeysTeams, KeysCollection, KeysTable, } from "../config.js"; -import { Spinner, SPINNER_ARC, SPINNER_DOTS } from "../spinner.js"; +import type { SettingsType, ConfigType } from "./config.js"; +import { createSettingsObject } from "../utils.js"; +import { Spinner, SPINNER_DOTS } from "../spinner.js"; import { paginate } from "../paginate.js"; +import { pushDeployment } from "./utils/deployment.js"; import { questionsPushBuckets, questionsPushTeams, @@ -27,8 +28,6 @@ import { questionsGetEntrypoint, questionsPushCollections, questionsPushTables, - questionPushChanges, - questionPushChangesConfirmation, questionsPushMessagingTopics, questionsPushResources, } from "../questions.js"; @@ -55,44 +54,48 @@ import { getTeamsService, getProjectsService, } from "../services.js"; -import { ApiService, AuthMethod } from "@appwrite.io/console"; +import { sdkForProject, sdkForConsole } from "../sdks.js"; +import { + ApiService, + AuthMethod, + AppwriteException, + Client, + Query, +} from "@appwrite.io/console"; import { checkDeployConditions } from "../utils.js"; +import { Pools } from "./utils/pools.js"; +import { Attributes, Collection } from "./utils/attributes.js"; +import { + getConfirmation, + approveChanges, + getObjectChanges, +} from "./utils/change-approval.js"; +import { checkAndApplyTablesDBChanges } from "./utils/database-sync.js"; -const STEP_SIZE = 100; // Resources const POLL_DEBOUNCE = 2000; // Milliseconds const POLL_DEFAULT_VALUE = 30; -let pollMaxDebounces = POLL_DEFAULT_VALUE; - -const changeableKeys = [ - "status", - "required", - "xdefault", - "elements", - "min", - "max", - "default", - "error", -]; - -interface ObjectChange { - group: string; - setting: string; - remote: string; - local: string; -} - -type ComparableValue = boolean | number | string | any[] | undefined; - -interface AttributeChange { - key: string; - attribute: any; - reason: string; - action: string; -} - -interface PushResourcesOptions { +export interface PushOptions { + all?: boolean; + settings?: boolean; + functions?: boolean; + sites?: boolean; + collections?: boolean; + tables?: boolean; + buckets?: boolean; + teams?: boolean; + topics?: boolean; skipDeprecated?: boolean; + functionOptions?: { + async?: boolean; + code?: boolean; + withVariables?: boolean; + }; + siteOptions?: { + async?: boolean; + code?: boolean; + withVariables?: boolean; + }; } interface PushSiteOptions { @@ -109,1195 +112,1391 @@ interface PushFunctionOptions { withVariables?: boolean; } -interface TablesDBChangesResult { - applied: boolean; - resyncNeeded: boolean; -} - interface PushTableOptions { attempts?: number; } -interface AwaitPools { - wipeAttributes: ( - databaseId: string, - collectionId: string, - iteration?: number, - ) => Promise; - - wipeIndexes: ( - databaseId: string, - collectionId: string, - iteration?: number, - ) => Promise; - - deleteAttributes: ( - databaseId: string, - collectionId: string, - attributeKeys: any[], - iteration?: number, - ) => Promise; - - expectAttributes: ( - databaseId: string, - collectionId: string, - attributeKeys: string[], - iteration?: number, - ) => Promise; - - deleteIndexes: ( - databaseId: string, - collectionId: string, - indexesKeys: any[], - iteration?: number, - ) => Promise; - - expectIndexes: ( - databaseId: string, - collectionId: string, - indexKeys: string[], - iteration?: number, - ) => Promise; -} +export class Push { + private projectClient: Client; + private consoleClient: Client; -const awaitPools: AwaitPools = { - wipeAttributes: async ( - databaseId: string, - collectionId: string, - iteration: number = 1, - ): Promise => { - if (iteration > pollMaxDebounces) { - return false; - } + constructor(projectClient: Client, consoleClient: Client) { + this.projectClient = projectClient; + this.consoleClient = consoleClient; + } - const databasesService = await getDatabasesService(); - const response = await databasesService.listAttributes( - databaseId, - collectionId, - [JSON.stringify({ method: "limit", values: [1] })], - ); - const { total } = response; + public async pushResources( + config: ConfigType, + options: PushOptions = { all: true, skipDeprecated: true }, + ): Promise<{ + results: Record; + errors: any[]; + }> { + const { skipDeprecated = true } = options; + const results: Record = {}; + const allErrors: any[] = []; + const shouldPushAll = options.all === true; - if (total === 0) { - return true; + // Push settings + if ( + (shouldPushAll || options.settings) && + (config.projectName || config.settings) + ) { + try { + log("Pushing settings ..."); + await this.pushSettings({ + projectId: config.projectId, + projectName: config.projectName, + settings: config.settings, + }); + results.settings = { success: true }; + } catch (e: any) { + allErrors.push(e); + results.settings = { success: false, error: e.message }; + } } - if (pollMaxDebounces === POLL_DEFAULT_VALUE) { - let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - pollMaxDebounces *= steps; - - log( - "Found a large number of attributes, increasing timeout to " + - (pollMaxDebounces * POLL_DEBOUNCE) / 1000 / 60 + - " minutes", - ); + // Push buckets + if ( + (shouldPushAll || options.buckets) && + config.buckets && + config.buckets.length > 0 + ) { + try { + log("Pushing buckets ..."); + const result = await this.pushBuckets(config.buckets); + results.buckets = result; + allErrors.push(...result.errors); + } catch (e: any) { + allErrors.push(e); + results.buckets = { successfullyPushed: 0, errors: [e] }; } } - await new Promise((resolve) => setTimeout(resolve, POLL_DEBOUNCE)); - - return await awaitPools.wipeAttributes( - databaseId, - collectionId, - iteration + 1, - ); - }, - wipeIndexes: async ( - databaseId: string, - collectionId: string, - iteration: number = 1, - ): Promise => { - if (iteration > pollMaxDebounces) { - return false; + // Push teams + if ( + (shouldPushAll || options.teams) && + config.teams && + config.teams.length > 0 + ) { + try { + log("Pushing teams ..."); + const result = await this.pushTeams(config.teams); + results.teams = result; + allErrors.push(...result.errors); + } catch (e: any) { + allErrors.push(e); + results.teams = { successfullyPushed: 0, errors: [e] }; + } } - const databasesService = await getDatabasesService(); - const response = await databasesService.listIndexes( - databaseId, - collectionId, - [JSON.stringify({ method: "limit", values: [1] })], - ); - const { total } = response; - - if (total === 0) { - return true; + // Push messaging topics + if ( + (shouldPushAll || options.topics) && + config.topics && + config.topics.length > 0 + ) { + try { + log("Pushing topics ..."); + const result = await this.pushMessagingTopics(config.topics); + results.topics = result; + allErrors.push(...result.errors); + } catch (e: any) { + allErrors.push(e); + results.topics = { successfullyPushed: 0, errors: [e] }; + } } - if (pollMaxDebounces === POLL_DEFAULT_VALUE) { - let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - pollMaxDebounces *= steps; - - log( - "Found a large number of indexes, increasing timeout to " + - (pollMaxDebounces * POLL_DEBOUNCE) / 1000 / 60 + - " minutes", + // Push functions + if ( + (shouldPushAll || options.functions) && + config.functions && + config.functions.length > 0 + ) { + try { + log("Pushing functions ..."); + const result = await this.pushFunctions( + config.functions, + options.functionOptions, ); + results.functions = result; + allErrors.push(...result.errors); + } catch (e: any) { + allErrors.push(e); + results.functions = { + successfullyPushed: 0, + successfullyDeployed: 0, + failedDeployments: [], + errors: [e], + }; } } - await new Promise((resolve) => setTimeout(resolve, POLL_DEBOUNCE)); - - return await awaitPools.wipeIndexes( - databaseId, - collectionId, - iteration + 1, - ); - }, - deleteAttributes: async ( - databaseId: string, - collectionId: string, - attributeKeys: any[], - iteration: number = 1, - ): Promise => { - if (iteration > pollMaxDebounces) { - return false; + // Push sites + if ( + (shouldPushAll || options.sites) && + config.sites && + config.sites.length > 0 + ) { + try { + log("Pushing sites ..."); + const result = await this.pushSites(config.sites, options.siteOptions); + results.sites = result; + allErrors.push(...result.errors); + } catch (e: any) { + allErrors.push(e); + results.sites = { + successfullyPushed: 0, + successfullyDeployed: 0, + failedDeployments: [], + errors: [e], + }; + } } - if (pollMaxDebounces === POLL_DEFAULT_VALUE) { - let steps = Math.max(1, Math.ceil(attributeKeys.length / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - pollMaxDebounces *= steps; + // Push tables + if ( + (shouldPushAll || options.tables) && + config.tables && + config.tables.length > 0 + ) { + try { + log("Pushing tables ..."); + const result = await this.pushTables(config.tables); + results.tables = result; + allErrors.push(...result.errors); + } catch (e: any) { + allErrors.push(e); + results.tables = { successfullyPushed: 0, errors: [e] }; + } + } - log( - "Found a large number of attributes to be deleted. Increasing timeout to " + - (pollMaxDebounces * POLL_DEBOUNCE) / 1000 / 60 + - " minutes", + // Push collections (unless skipDeprecated is true) + if ( + !skipDeprecated && + (shouldPushAll || options.collections) && + config.collections && + config.collections.length > 0 + ) { + try { + log("Pushing collections ..."); + // Add database names to collections + const collectionsWithDbNames = config.collections.map( + (collection: any) => { + const database = config.databases?.find( + (db: any) => db.$id === collection.databaseId, + ); + return { + ...collection, + databaseName: database?.name ?? collection.databaseId, + }; + }, ); + const result = await this.pushCollections(collectionsWithDbNames); + results.collections = result; + allErrors.push(...result.errors); + } catch (e: any) { + allErrors.push(e); + results.collections = { successfullyPushed: 0, errors: [e] }; } } - const { attributes } = await paginate( - async (args: any) => { - const databasesService = await getDatabasesService(); - return await databasesService.listAttributes( - args.databaseId, - args.collectionId, - args.queries || [], - ); - }, - { - databaseId, - collectionId, - }, - 100, - "attributes", - ); + return { + results, + errors: allErrors, + }; + } - const ready = attributeKeys.filter((attribute: any) => - attributes.includes(attribute.key), - ); + public async pushSettings(config: { + projectId: string; + projectName?: string; + settings?: SettingsType; + }): Promise { + const projectsService = await getProjectsService(this.consoleClient); + const projectId = config.projectId; + const projectName = config.projectName; + const settings = config.settings ?? {}; - if (ready.length === 0) { - return true; + if (projectName) { + await projectsService.update({ + projectId: projectId, + name: projectName, + }); } - await new Promise((resolve) => setTimeout(resolve, POLL_DEBOUNCE)); - - return await awaitPools.expectAttributes( - databaseId, - collectionId, - attributeKeys, - iteration + 1, - ); - }, - expectAttributes: async ( - databaseId: string, - collectionId: string, - attributeKeys: string[], - iteration: number = 1, - ): Promise => { - if (iteration > pollMaxDebounces) { - return false; + if (settings.services) { + for (let [service, status] of Object.entries(settings.services)) { + await projectsService.updateServiceStatus({ + projectId: projectId, + service: service as ApiService, + status: status, + }); + } } - if (pollMaxDebounces === POLL_DEFAULT_VALUE) { - let steps = Math.max(1, Math.ceil(attributeKeys.length / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - pollMaxDebounces *= steps; + if (settings.auth) { + if (settings.auth.security) { + await projectsService.updateAuthDuration({ + projectId, + duration: settings.auth.security.duration, + }); + await projectsService.updateAuthLimit({ + projectId, + limit: settings.auth.security.limit, + }); + await projectsService.updateAuthSessionsLimit({ + projectId, + limit: settings.auth.security.sessionsLimit, + }); + await projectsService.updateAuthPasswordDictionary({ + projectId, + enabled: settings.auth.security.passwordDictionary, + }); + await projectsService.updateAuthPasswordHistory({ + projectId, + limit: settings.auth.security.passwordHistory, + }); + await projectsService.updatePersonalDataCheck({ + projectId, + enabled: settings.auth.security.personalDataCheck, + }); + await projectsService.updateSessionAlerts({ + projectId, + alerts: settings.auth.security.sessionAlerts, + }); + await projectsService.updateMockNumbers({ + projectId, + numbers: settings.auth.security.mockNumbers, + }); + } - log( - "Creating a large number of attributes, increasing timeout to " + - (pollMaxDebounces * POLL_DEBOUNCE) / 1000 / 60 + - " minutes", - ); + if (settings.auth.methods) { + for (let [method, status] of Object.entries(settings.auth.methods)) { + await projectsService.updateAuthStatus({ + projectId, + method: method as AuthMethod, + status: status, + }); + } } } + } - const { attributes } = await paginate( - async (args: any) => { - const databasesService = await getDatabasesService(); - return await databasesService.listAttributes( - args.databaseId, - args.collectionId, - args.queries || [], - ); - }, - { - databaseId, - collectionId, - }, - 100, - "attributes", - ); + public async pushBuckets(buckets: any[]): Promise<{ + successfullyPushed: number; + errors: any[]; + }> { + let successfullyPushed = 0; + const errors: any[] = []; - const ready = attributes - .filter((attribute: any) => { - if (attributeKeys.includes(attribute.key)) { - if (["stuck", "failed"].includes(attribute.status)) { - throw new Error(`Attribute '${attribute.key}' failed!`); - } + for (const bucket of buckets) { + try { + log(`Pushing bucket ${chalk.bold(bucket["name"])} ...`); + const storageService = await getStorageService(this.projectClient); - return attribute.status === "available"; + try { + await storageService.getBucket(bucket["$id"]); + await storageService.updateBucket({ + bucketId: bucket["$id"], + name: bucket.name, + permissions: bucket["$permissions"], + fileSecurity: bucket.fileSecurity, + enabled: bucket.enabled, + maximumFileSize: bucket.maximumFileSize, + allowedFileExtensions: bucket.allowedFileExtensions, + encryption: bucket.encryption, + antivirus: bucket.antivirus, + compression: bucket.compression, + }); + } catch (e: unknown) { + if (e instanceof AppwriteException && Number(e.code) === 404) { + await storageService.createBucket({ + bucketId: bucket["$id"], + name: bucket.name, + permissions: bucket["$permissions"], + fileSecurity: bucket.fileSecurity, + enabled: bucket.enabled, + maximumFileSize: bucket.maximumFileSize, + allowedFileExtensions: bucket.allowedFileExtensions, + compression: bucket.compression, + encryption: bucket.encryption, + antivirus: bucket.antivirus, + }); + } else { + throw e; + } } - return false; - }) - .map((attribute: any) => attribute.key); - - if (ready.length === attributeKeys.length) { - return true; + successfullyPushed++; + } catch (e: any) { + errors.push(e); + error(`Failed to push bucket ${bucket["name"]}: ${e.message}`); + } } - await new Promise((resolve) => setTimeout(resolve, POLL_DEBOUNCE)); + return { + successfullyPushed, + errors, + }; + } - return await awaitPools.expectAttributes( - databaseId, - collectionId, - attributeKeys, - iteration + 1, - ); - }, - deleteIndexes: async ( - databaseId: string, - collectionId: string, - indexesKeys: any[], - iteration: number = 1, - ): Promise => { - if (iteration > pollMaxDebounces) { - return false; - } + public async pushTeams(teams: any[]): Promise<{ + successfullyPushed: number; + errors: any[]; + }> { + let successfullyPushed = 0; + const errors: any[] = []; + + for (const team of teams) { + try { + log(`Pushing team ${chalk.bold(team["name"])} ...`); + const teamsService = await getTeamsService(this.projectClient); - if (pollMaxDebounces === POLL_DEFAULT_VALUE) { - let steps = Math.max(1, Math.ceil(indexesKeys.length / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - pollMaxDebounces *= steps; + try { + await teamsService.get(team["$id"]); + await teamsService.updateName({ + teamId: team["$id"], + name: team.name, + }); + } catch (e: unknown) { + if (e instanceof AppwriteException && Number(e.code) === 404) { + await teamsService.create({ + teamId: team["$id"], + name: team.name, + }); + } else { + throw e; + } + } - log( - "Found a large number of indexes to be deleted. Increasing timeout to " + - (pollMaxDebounces * POLL_DEBOUNCE) / 1000 / 60 + - " minutes", - ); + successfullyPushed++; + } catch (e: any) { + errors.push(e); + error(`Failed to push team ${team["name"]}: ${e.message}`); } } - const { indexes } = await paginate( - async (args: any) => { - const databasesService = await getDatabasesService(); - return await databasesService.listIndexes( - args.databaseId, - args.collectionId, - args.queries || [], - ); - }, - { - databaseId, - collectionId, - }, - 100, - "indexes", - ); + return { + successfullyPushed, + errors, + }; + } - const ready = indexesKeys.filter((index: any) => - indexes.includes(index.key), - ); + public async pushMessagingTopics(topics: any[]): Promise<{ + successfullyPushed: number; + errors: any[]; + }> { + let successfullyPushed = 0; + const errors: any[] = []; - if (ready.length === 0) { - return true; - } + for (const topic of topics) { + try { + log(`Pushing topic ${chalk.bold(topic["name"])} ...`); + const messagingService = await getMessagingService(this.projectClient); - await new Promise((resolve) => setTimeout(resolve, POLL_DEBOUNCE)); + try { + await messagingService.getTopic(topic["$id"]); + await messagingService.updateTopic({ + topicId: topic["$id"], + name: topic.name, + subscribe: topic.subscribe, + }); + } catch (e: unknown) { + if (e instanceof AppwriteException && Number(e.code) === 404) { + await messagingService.createTopic({ + topicId: topic["$id"], + name: topic.name, + subscribe: topic.subscribe, + }); + } else { + throw e; + } + } - return await awaitPools.expectIndexes( - databaseId, - collectionId, - indexesKeys, - iteration + 1, - ); - }, - expectIndexes: async ( - databaseId: string, - collectionId: string, - indexKeys: string[], - iteration: number = 1, - ): Promise => { - if (iteration > pollMaxDebounces) { - return false; + success(`Created ${topic.name} ( ${topic["$id"]} )`); + successfullyPushed++; + } catch (e: any) { + errors.push(e); + error(`Failed to push topic ${topic["name"]}: ${e.message}`); + } } - if (pollMaxDebounces === POLL_DEFAULT_VALUE) { - let steps = Math.max(1, Math.ceil(indexKeys.length / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - pollMaxDebounces *= steps; + return { + successfullyPushed, + errors, + }; + } + + public async pushFunctions( + functions: any[], + options: { + async?: boolean; + code?: boolean; + withVariables?: boolean; + } = {}, + ): Promise<{ + successfullyPushed: number; + successfullyDeployed: number; + failedDeployments: any[]; + errors: any[]; + }> { + const { async: asyncDeploy, code, withVariables } = options; + + Spinner.start(false); + let successfullyPushed = 0; + let successfullyDeployed = 0; + const failedDeployments: any[] = []; + const errors: any[] = []; - log( - "Creating a large number of indexes, increasing timeout to " + - (pollMaxDebounces * POLL_DEBOUNCE) / 1000 / 60 + - " minutes", - ); - } - } + await Promise.all( + functions.map(async (func: any) => { + let response: any = {}; + + const ignore = func.ignore ? "appwrite.config.json" : ".gitignore"; + let functionExists = false; + let deploymentCreated = false; + + const updaterRow = new Spinner({ + status: "", + resource: func.name, + id: func["$id"], + end: `Ignoring using: ${ignore}`, + }); - const { indexes } = await paginate( - async (args: any) => { - const databasesService = await getDatabasesService(); - return await databasesService.listIndexes( - args.databaseId, - args.collectionId, - args.queries || [], - ); - }, - { - databaseId, - collectionId, - }, - 100, - "indexes", - ); + updaterRow.update({ status: "Getting" }).startSpinner(SPINNER_DOTS); + const functionsService = await getFunctionsService(this.projectClient); + try { + response = await functionsService.get({ functionId: func["$id"] }); + functionExists = true; + if (response.runtime !== func.runtime) { + updaterRow.fail({ + errorMessage: `Runtime mismatch! (local=${func.runtime},remote=${response.runtime}) Please delete remote function or update your appwrite.config.json`, + }); + return; + } + + updaterRow + .update({ status: "Updating" }) + .replaceSpinner(SPINNER_DOTS); - const ready = indexes - .filter((index: any) => { - if (indexKeys.includes(index.key)) { - if (["stuck", "failed"].includes(index.status)) { - throw new Error(`Index '${index.key}' failed!`); + response = await functionsService.update({ + functionId: func["$id"], + name: func.name, + runtime: func.runtime, + execute: func.execute, + events: func.events, + schedule: func.schedule, + timeout: func.timeout, + enabled: func.enabled, + logging: func.logging, + entrypoint: func.entrypoint, + commands: func.commands, + scopes: func.scopes, + specification: func.specification, + }); + } catch (e: any) { + if (Number(e.code) === 404) { + functionExists = false; + } else { + errors.push(e); + updaterRow.fail({ + errorMessage: + e.message ?? "General error occurs please try again", + }); + return; } + } + + if (!functionExists) { + updaterRow + .update({ status: "Creating" }) + .replaceSpinner(SPINNER_DOTS); + + try { + response = await functionsService.create({ + functionId: func.$id, + name: func.name, + runtime: func.runtime, + execute: func.execute, + events: func.events, + schedule: func.schedule, + timeout: func.timeout, + enabled: func.enabled, + logging: func.logging, + entrypoint: func.entrypoint, + commands: func.commands, + scopes: func.scopes, + specification: func.specification, + }); + + let domain = ""; + try { + const consoleService = await getConsoleService( + this.projectClient, + ); + const variables = await consoleService.variables(); + domain = ID.unique() + "." + variables["_APP_DOMAIN_FUNCTIONS"]; + } catch (error) { + console.error("Error fetching console variables."); + throw error; + } + + try { + const proxyService = await getProxyService(this.projectClient); + await proxyService.createFunctionRule(domain, func.$id); + } catch (error) { + console.error("Error creating function rule."); + throw error; + } - return index.status === "available"; + updaterRow.update({ status: "Created" }); + } catch (e: any) { + errors.push(e); + updaterRow.fail({ + errorMessage: + e.message ?? "General error occurs please try again", + }); + return; + } } - return false; - }) - .map((index: any) => index.key); + if (withVariables) { + updaterRow + .update({ status: "Updating variables" }) + .replaceSpinner(SPINNER_DOTS); - if (ready.length >= indexKeys.length) { - return true; - } + const functionsServiceForVars = await getFunctionsService( + this.projectClient, + ); + const { variables } = await paginate( + async (args: any) => { + return await functionsServiceForVars.listVariables({ + functionId: args.functionId, + }); + }, + { + functionId: func["$id"], + }, + 100, + "variables", + ); - await new Promise((resolve) => setTimeout(resolve, POLL_DEBOUNCE)); + await Promise.all( + variables.map(async (variable: any) => { + const functionsServiceDel = await getFunctionsService( + this.projectClient, + ); + await functionsServiceDel.deleteVariable({ + functionId: func["$id"], + variableId: variable["$id"], + }); + }), + ); - return await awaitPools.expectIndexes( - databaseId, - collectionId, - indexKeys, - iteration + 1, - ); - }, -}; + const envFileLocation = `${func["path"]}/.env`; + let envVariables: Array<{ key: string; value: string }> = []; + try { + if (fs.existsSync(envFileLocation)) { + const envObject = parseDotenv( + fs.readFileSync(envFileLocation, "utf8"), + ); + envVariables = Object.entries(envObject || {}).map( + ([key, value]) => ({ key, value }), + ); + } + } catch (error) { + envVariables = []; + } + await Promise.all( + envVariables.map(async (variable) => { + const functionsServiceCreate = await getFunctionsService( + this.projectClient, + ); + await functionsServiceCreate.createVariable({ + functionId: func["$id"], + key: variable.key, + value: variable.value, + secret: false, + }); + }), + ); + } -const getConfirmation = async (): Promise => { - if (cliConfig.force) { - return true; - } + if (code === false) { + successfullyPushed++; + successfullyDeployed++; + updaterRow.update({ status: "Pushed" }); + updaterRow.stopSpinner(); + return; + } - async function fixConfirmation(): Promise { - const answers = await inquirer.prompt(questionPushChangesConfirmation); - if (answers.changes !== "YES" && answers.changes !== "NO") { - return await fixConfirmation(); - } + try { + updaterRow.update({ status: "Pushing" }).replaceSpinner(SPINNER_DOTS); + const functionsServiceDeploy = await getFunctionsService( + this.projectClient, + ); - return answers.changes; - } + const result = await pushDeployment({ + resourcePath: func.path, + createDeployment: async (codeFile) => { + return await functionsServiceDeploy.createDeployment({ + functionId: func["$id"], + entrypoint: func.entrypoint, + commands: func.commands, + code: codeFile, + activate: true, + }); + }, + pollForStatus: false, + }); - let answers = await inquirer.prompt(questionPushChanges); + response = result.deployment; + updaterRow.update({ status: "Pushed" }); - if (answers.changes !== "YES" && answers.changes !== "NO") { - answers.changes = await fixConfirmation(); - } + deploymentCreated = true; + successfullyPushed++; + } catch (e: any) { + errors.push(e); - if (answers.changes === "YES") { - return true; - } + switch (e.code) { + case "ENOENT": + updaterRow.fail({ + errorMessage: "Not found in the current directory. Skipping...", + }); + break; + default: + updaterRow.fail({ + errorMessage: + e.message ?? "An unknown error occurred. Please try again.", + }); + } + } - warn("Skipping push action. Changes were not applied."); - return false; -}; -const isEmpty = (value: any): boolean => - value === null || - value === undefined || - (typeof value === "string" && value.trim().length === 0) || - (Array.isArray(value) && value.length === 0); - -const approveChanges = async ( - resource: any[], - resourceGetFunction: Function, - keys: Set, - resourceName: string, - resourcePlural: string, - skipKeys: string[] = [], - secondId: string = "", - secondResourceName: string = "", -): Promise => { - log("Checking for changes ..."); - const changes: any[] = []; - - await Promise.all( - resource.map(async (localResource) => { - try { - const options: Record = { - [resourceName]: localResource["$id"], - }; + if (deploymentCreated && !asyncDeploy) { + try { + const deploymentId = response["$id"]; + updaterRow.update({ + status: "Deploying", + end: "Checking deployment status...", + }); + + while (true) { + const functionsServicePoll = await getFunctionsService( + this.projectClient, + ); + response = await functionsServicePoll.getDeployment({ + functionId: func["$id"], + deploymentId: deploymentId, + }); + + const status = response["status"]; + if (status === "ready") { + successfullyDeployed++; + + let url = ""; + const proxyServiceUrl = await getProxyService( + this.projectClient, + ); + const res = await proxyServiceUrl.listRules({ + queries: [ + Query.limit(1), + Query.equal("deploymentResourceType", "function"), + Query.equal("deploymentResourceId", func["$id"]), + Query.equal("trigger", "manual"), + ], + }); + + if (Number(res.total) === 1) { + url = `https://${res.rules[0].domain}`; + } + + updaterRow.update({ status: "Deployed", end: url }); + + break; + } else if (status === "failed") { + failedDeployments.push({ + name: func["name"], + $id: func["$id"], + deployment: response["$id"], + }); + updaterRow.fail({ errorMessage: `Failed to deploy` }); + + break; + } else { + updaterRow.update({ + status: "Deploying", + end: `Current status: ${status}`, + }); + } - if (secondId !== "" && secondResourceName !== "") { - options[secondResourceName] = localResource[secondId]; + await new Promise((resolve) => + setTimeout(resolve, POLL_DEBOUNCE * 1.5), + ); + } + } catch (e: any) { + errors.push(e); + updaterRow.fail({ + errorMessage: + e.message ?? "Unknown error occurred. Please try again", + }); + } } - const remoteResource = await resourceGetFunction(options); + updaterRow.stopSpinner(); + }), + ); + + Spinner.stop(); + + return { + successfullyPushed, + successfullyDeployed, + failedDeployments, + errors, + }; + } + + public async pushSites( + sites: any[], + options: { + async?: boolean; + code?: boolean; + withVariables?: boolean; + } = {}, + ): Promise<{ + successfullyPushed: number; + successfullyDeployed: number; + failedDeployments: any[]; + errors: any[]; + }> { + const { async: asyncDeploy, code, withVariables } = options; + + Spinner.start(false); + let successfullyPushed = 0; + let successfullyDeployed = 0; + const failedDeployments: any[] = []; + const errors: any[] = []; - for (let [key, value] of Object.entries( - whitelistKeys(remoteResource, keys), - )) { - if (skipKeys.includes(key)) { - continue; + await Promise.all( + sites.map(async (site: any) => { + let response: any = {}; + + const ignore = site.ignore ? "appwrite.config.json" : ".gitignore"; + let siteExists = false; + let deploymentCreated = false; + + const updaterRow = new Spinner({ + status: "", + resource: site.name, + id: site["$id"], + end: `Ignoring using: ${ignore}`, + }); + + updaterRow.update({ status: "Getting" }).startSpinner(SPINNER_DOTS); + + const sitesService = await getSitesService(this.projectClient); + try { + response = await sitesService.get({ siteId: site["$id"] }); + siteExists = true; + if (response.framework !== site.framework) { + updaterRow.fail({ + errorMessage: `Framework mismatch! (local=${site.framework},remote=${response.framework}) Please delete remote site or update your appwrite.config.json`, + }); + return; } - if (isEmpty(value) && isEmpty(localResource[key])) { - continue; + updaterRow + .update({ status: "Updating" }) + .replaceSpinner(SPINNER_DOTS); + + response = await sitesService.update({ + siteId: site["$id"], + name: site.name, + framework: site.framework, + enabled: site.enabled, + logging: site.logging, + timeout: site.timeout, + installCommand: site.installCommand, + buildCommand: site.buildCommand, + outputDirectory: site.outputDirectory, + buildRuntime: site.buildRuntime, + adapter: site.adapter, + specification: site.specification, + }); + } catch (e: any) { + if (Number(e.code) === 404) { + siteExists = false; + } else { + errors.push(e); + updaterRow.fail({ + errorMessage: + e.message ?? "General error occurs please try again", + }); + return; } + } - if (Array.isArray(value) && Array.isArray(localResource[key])) { - if (JSON.stringify(value) !== JSON.stringify(localResource[key])) { - changes.push({ - id: localResource["$id"], - key, - remote: chalk.red((value as string[]).join("\n")), - local: chalk.green(localResource[key].join("\n")), - }); + if (!siteExists) { + updaterRow + .update({ status: "Creating" }) + .replaceSpinner(SPINNER_DOTS); + + try { + response = await sitesService.create({ + siteId: site.$id, + name: site.name, + framework: site.framework, + enabled: site.enabled, + logging: site.logging, + timeout: site.timeout, + installCommand: site.installCommand, + buildCommand: site.buildCommand, + outputDirectory: site.outputDirectory, + buildRuntime: site.buildRuntime, + adapter: site.adapter, + specification: site.specification, + }); + + let domain = ""; + try { + const consoleService = await getConsoleService( + this.projectClient, + ); + const variables = await consoleService.variables(); + domain = ID.unique() + "." + variables["_APP_DOMAIN_SITES"]; + } catch (error) { + console.error("Error fetching console variables."); + throw error; } - } else if (value !== localResource[key]) { - changes.push({ - id: localResource["$id"], - key, - remote: chalk.red(value), - local: chalk.green(localResource[key]), + + try { + const proxyService = await getProxyService(this.projectClient); + await proxyService.createSiteRule(domain, site.$id); + } catch (error) { + console.error("Error creating site rule."); + throw error; + } + + updaterRow.update({ status: "Created" }); + } catch (e: any) { + errors.push(e); + updaterRow.fail({ + errorMessage: + e.message ?? "General error occurs please try again", }); + return; } } - } catch (e: any) { - if (Number(e.code) !== 404) { - throw e; - } - } - }), - ); - if (changes.length === 0) { - return true; - } + if (withVariables) { + updaterRow + .update({ status: "Creating variables" }) + .replaceSpinner(SPINNER_DOTS); - drawTable(changes); - if ((await getConfirmation()) === true) { - return true; - } + const sitesServiceForVars = await getSitesService(this.projectClient); + const { variables } = await paginate( + async (args: any) => { + return await sitesServiceForVars.listVariables({ + siteId: args.siteId, + }); + }, + { + siteId: site["$id"], + }, + 100, + "variables", + ); - success(`Successfully pushed 0 ${resourcePlural}.`); - return false; -}; + await Promise.all( + variables.map(async (variable: any) => { + const sitesServiceDel = await getSitesService(this.projectClient); + await sitesServiceDel.deleteVariable({ + siteId: site["$id"], + variableId: variable["$id"], + }); + }), + ); -const getObjectChanges = >( - remote: T, - local: T, - index: keyof T, - what: string, -): ObjectChange[] => { - const changes: ObjectChange[] = []; + const envFileLocation = `${site["path"]}/.env`; + let envVariables: Array<{ key: string; value: string }> = []; + try { + if (fs.existsSync(envFileLocation)) { + const envObject = parseDotenv( + fs.readFileSync(envFileLocation, "utf8"), + ); + envVariables = Object.entries(envObject || {}).map( + ([key, value]) => ({ key, value }), + ); + } + } catch (error) { + envVariables = []; + } + await Promise.all( + envVariables.map(async (variable) => { + const sitesServiceCreate = await getSitesService( + this.projectClient, + ); + await sitesServiceCreate.createVariable({ + siteId: site["$id"], + key: variable.key, + value: variable.value, + secret: false, + }); + }), + ); + } - const remoteNested = remote[index]; - const localNested = local[index]; + if (code === false) { + successfullyPushed++; + successfullyDeployed++; + updaterRow.update({ status: "Pushed" }); + updaterRow.stopSpinner(); + return; + } - if ( - remoteNested && - localNested && - typeof remoteNested === "object" && - !Array.isArray(remoteNested) && - typeof localNested === "object" && - !Array.isArray(localNested) - ) { - const remoteObj = remoteNested as Record; - const localObj = localNested as Record; + try { + updaterRow.update({ status: "Pushing" }).replaceSpinner(SPINNER_DOTS); + const sitesServiceDeploy = await getSitesService(this.projectClient); + + const result = await pushDeployment({ + resourcePath: site.path, + createDeployment: async (codeFile) => { + return await sitesServiceDeploy.createDeployment({ + siteId: site["$id"], + installCommand: site.installCommand, + buildCommand: site.buildCommand, + outputDirectory: site.outputDirectory, + code: codeFile, + activate: true, + }); + }, + pollForStatus: false, + }); - for (const [service, status] of Object.entries(remoteObj)) { - const localValue = localObj[service]; - let valuesEqual = false; + response = result.deployment; + updaterRow.update({ status: "Pushed" }); + deploymentCreated = true; + successfullyPushed++; + } catch (e: any) { + errors.push(e); - if (Array.isArray(status) && Array.isArray(localValue)) { - valuesEqual = JSON.stringify(status) === JSON.stringify(localValue); - } else { - valuesEqual = status === localValue; - } + switch (e.code) { + case "ENOENT": + updaterRow.fail({ + errorMessage: "Not found in the current directory. Skipping...", + }); + break; + default: + updaterRow.fail({ + errorMessage: + e.message ?? "An unknown error occurred. Please try again.", + }); + } + } - if (!valuesEqual) { - changes.push({ - group: what, - setting: service, - remote: chalk.red(String(status ?? "")), - local: chalk.green(String(localValue ?? "")), - }); - } - } - } + if (deploymentCreated && !asyncDeploy) { + try { + const deploymentId = response["$id"]; + updaterRow.update({ + status: "Deploying", + end: "Checking deployment status...", + }); - return changes; -}; + while (true) { + const sitesServicePoll = await getSitesService( + this.projectClient, + ); + response = await sitesServicePoll.getDeployment({ + siteId: site["$id"], + deploymentId: deploymentId, + }); -const createAttribute = async ( - databaseId: string, - collectionId: string, - attribute: any, -): Promise => { - const databasesService = await getDatabasesService(); - switch (attribute.type) { - case "string": - switch (attribute.format) { - case "email": - return databasesService.createEmailAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - }); - case "url": - return databasesService.createUrlAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - }); - case "ip": - return databasesService.createIpAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - }); - case "enum": - return databasesService.createEnumAttribute({ - databaseId, - collectionId, - key: attribute.key, - elements: attribute.elements, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - }); - default: - return databasesService.createStringAttribute({ - databaseId, - collectionId, - key: attribute.key, - size: attribute.size, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - encrypt: attribute.encrypt, - }); - } - case "integer": - return databasesService.createIntegerAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - min: attribute.min, - max: attribute.max, - xdefault: attribute.default, - array: attribute.array, - }); - case "double": - return databasesService.createFloatAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - min: attribute.min, - max: attribute.max, - xdefault: attribute.default, - array: attribute.array, - }); - case "boolean": - return databasesService.createBooleanAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - }); - case "datetime": - return databasesService.createDatetimeAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - }); - case "relationship": - return databasesService.createRelationshipAttribute({ - databaseId, - collectionId, - relatedCollectionId: - attribute.relatedTable ?? attribute.relatedCollection, - type: attribute.relationType, - twoWay: attribute.twoWay, - key: attribute.key, - twoWayKey: attribute.twoWayKey, - onDelete: attribute.onDelete, - }); - case "point": - return databasesService.createPointAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "linestring": - return databasesService.createLineAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "polygon": - return databasesService.createPolygonAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - default: - throw new Error(`Unsupported attribute type: ${attribute.type}`); - } -}; + const status = response["status"]; + if (status === "ready") { + successfullyDeployed++; + + let url = ""; + const proxyServiceUrl = await getProxyService( + this.projectClient, + ); + const res = await proxyServiceUrl.listRules([ + Query.limit(1), + Query.equal("deploymentResourceType", "site"), + Query.equal("deploymentResourceId", site["$id"]), + Query.equal("trigger", "manual"), + ]); + + if (Number(res.total) === 1) { + url = `https://${res.rules[0].domain}`; + } + + updaterRow.update({ status: "Deployed", end: url }); + + break; + } else if (status === "failed") { + failedDeployments.push({ + name: site["name"], + $id: site["$id"], + deployment: response["$id"], + }); + updaterRow.fail({ errorMessage: `Failed to deploy` }); + + break; + } else { + updaterRow.update({ + status: "Deploying", + end: `Current status: ${status}`, + }); + } -const updateAttribute = async ( - databaseId: string, - collectionId: string, - attribute: any, -): Promise => { - const databasesService = await getDatabasesService(); - switch (attribute.type) { - case "string": - switch (attribute.format) { - case "email": - return databasesService.updateEmailAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "url": - return databasesService.updateUrlAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "ip": - return databasesService.updateIpAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "enum": - return databasesService.updateEnumAttribute({ - databaseId, - collectionId, - key: attribute.key, - elements: attribute.elements, - required: attribute.required, - xdefault: attribute.default, - }); - default: - return databasesService.updateStringAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - } - case "integer": - return databasesService.updateIntegerAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - min: attribute.min, - max: attribute.max, - xdefault: attribute.default, - }); - case "double": - return databasesService.updateFloatAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - min: attribute.min, - max: attribute.max, - xdefault: attribute.default, - }); - case "boolean": - return databasesService.updateBooleanAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "datetime": - return databasesService.updateDatetimeAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "relationship": - return databasesService.updateRelationshipAttribute({ - databaseId, - collectionId, - key: attribute.key, - onDelete: attribute.onDelete, - }); - case "point": - return databasesService.updatePointAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "linestring": - return databasesService.updateLineAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - case "polygon": - return databasesService.updatePolygonAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - }); - default: - throw new Error(`Unsupported attribute type: ${attribute.type}`); - } -}; -const deleteAttribute = async ( - collection: any, - attribute: any, - isIndex: boolean = false, -): Promise => { - log( - `Deleting ${isIndex ? "index" : "attribute"} ${attribute.key} of ${collection.name} ( ${collection["$id"]} )`, - ); + await new Promise((resolve) => + setTimeout(resolve, POLL_DEBOUNCE * 1.5), + ); + } + } catch (e: any) { + errors.push(e); + updaterRow.fail({ + errorMessage: + e.message ?? "Unknown error occurred. Please try again", + }); + } + } - const databasesService = await getDatabasesService(); - if (isIndex) { - await databasesService.deleteIndex( - collection["databaseId"], - collection["$id"], - attribute.key, + updaterRow.stopSpinner(); + }), ); - return; - } - await databasesService.deleteAttribute( - collection["databaseId"], - collection["$id"], - attribute.key, - ); -}; + Spinner.stop(); -const isEqual = (a: any, b: any): boolean => { - if (a === b) return true; + return { + successfullyPushed, + successfullyDeployed, + failedDeployments, + errors, + }; + } - if (a && b && typeof a === "object" && typeof b === "object") { - if ( - a.constructor && - a.constructor.name === "BigNumber" && - b.constructor && - b.constructor.name === "BigNumber" - ) { - return a.eq(b); - } + public async pushTables( + tables: any[], + attempts?: number, + ): Promise<{ + successfullyPushed: number; + errors: any[]; + }> { + const pollMaxDebounces = attempts ?? POLL_DEFAULT_VALUE; + const pools = new Pools(pollMaxDebounces); + const attributes = new Attributes(pools); - if (typeof a.equals === "function") { - return a.equals(b); - } + let tablesChanged = new Set(); + const errors: any[] = []; - if (typeof a.eq === "function") { - return a.eq(b); - } - } + // Parallel tables actions + await Promise.all( + tables.map(async (table: any) => { + try { + const tablesService = await getTablesDBService(this.projectClient); + const remoteTable = await tablesService.getTable({ + databaseId: table["databaseId"], + tableId: table["$id"], + }); - if (typeof a === "number" && typeof b === "number") { - if (isNaN(a) && isNaN(b)) return true; - if (!isFinite(a) && !isFinite(b)) return a === b; - return Math.abs(a - b) < Number.EPSILON; - } + const changes: string[] = []; + if (remoteTable.name !== table.name) changes.push("name"); + if (remoteTable.rowSecurity !== table.rowSecurity) + changes.push("rowSecurity"); + if (remoteTable.enabled !== table.enabled) changes.push("enabled"); + if ( + JSON.stringify(remoteTable["$permissions"]) !== + JSON.stringify(table["$permissions"]) + ) + changes.push("permissions"); + + if (changes.length > 0) { + await tablesService.updateTable( + table["databaseId"], + table["$id"], + table.name, + table.rowSecurity, + table["$permissions"], + ); - return false; -}; + success( + `Updated ${table.name} ( ${table["$id"]} ) - ${changes.join(", ")}`, + ); + tablesChanged.add(table["$id"]); + } + table.remoteVersion = remoteTable; -const compareAttribute = ( - remote: any, - local: any, - reason: string, - key: string, -): string => { - if (isEmpty(remote) && isEmpty(local)) { - return reason; - } + table.isExisted = true; + } catch (e: any) { + if (Number(e.code) === 404) { + log( + `Table ${table.name} does not exist in the project. Creating ... `, + ); + const tablesService = await getTablesDBService(this.projectClient); + await tablesService.createTable( + table["databaseId"], + table["$id"], + table.name, + table.rowSecurity, + table["$permissions"], + ); - if (Array.isArray(remote) && Array.isArray(local)) { - if (JSON.stringify(remote) !== JSON.stringify(local)) { - const bol = reason === "" ? "" : "\n"; - reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}`; - } - } else if (!isEqual(remote, local)) { - const bol = reason === "" ? "" : "\n"; - reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}`; - } + success(`Created ${table.name} ( ${table["$id"]} )`); + tablesChanged.add(table["$id"]); + } else { + errors.push(e); + throw e; + } + } + }), + ); - return reason; -}; + // Serialize attribute actions + for (let table of tables) { + let columns = table.columns; + let indexes = table.indexes; -/** - * Check if attribute non-changeable fields has been changed - * If so return the differences as an object. - */ -const checkAttributeChanges = ( - remote: any, - local: any, - collection: any, - recreating: boolean = true, -): AttributeChange | undefined => { - if (local === undefined) { - return undefined; - } + if (table.isExisted) { + columns = await attributes.attributesToCreate( + table.remoteVersion.columns, + table.columns, + table as Collection, + ); + indexes = await attributes.attributesToCreate( + table.remoteVersion.indexes, + table.indexes, + table as Collection, + true, + ); - const keyName = `${chalk.yellow(local.key)} in ${collection.name} (${collection["$id"]})`; - const action = chalk.cyan(recreating ? "recreating" : "changing"); - let reason = ""; - let attribute = recreating ? remote : local; + if ( + Array.isArray(columns) && + columns.length <= 0 && + Array.isArray(indexes) && + indexes.length <= 0 + ) { + continue; + } + } - for (let key of Object.keys(remote)) { - if (!KeysAttributes.has(key)) { - continue; - } + log( + `Pushing table ${table.name} ( ${table["databaseId"]} - ${table["$id"]} ) attributes`, + ); - if (changeableKeys.includes(key)) { - if (!recreating) { - reason = compareAttribute(remote[key], local[key], reason, key); + try { + await attributes.createColumns(columns, table as Collection); + } catch (e) { + errors.push(e); + throw e; } - continue; - } - if (!recreating) { - continue; + try { + await attributes.createIndexes(indexes, table as Collection); + } catch (e) { + errors.push(e); + throw e; + } + tablesChanged.add(table["$id"]); + success(`Successfully pushed ${table.name} ( ${table["$id"]} )`); } - reason = compareAttribute(remote[key], local[key], reason, key); + return { + successfullyPushed: tablesChanged.size, + errors, + }; } - return reason === "" - ? undefined - : { key: keyName, attribute, reason, action }; -}; + public async pushCollections(collections: any[]): Promise<{ + successfullyPushed: number; + errors: any[]; + }> { + const pools = new Pools(POLL_DEFAULT_VALUE); + const attributes = new Attributes(pools); -/** - * Check if attributes contain the given attribute - */ -const attributesContains = (attribute: any, attributes: any[]): any => - attributes.find((attr) => attr.key === attribute.key); - -const generateChangesObject = ( - attribute: any, - collection: any, - isAdding: boolean, -): AttributeChange => { - return { - key: `${chalk.yellow(attribute.key)} in ${collection.name} (${collection["$id"]})`, - attribute: attribute, - reason: isAdding - ? "Field isn't present on the remote server" - : "Field isn't present on the appwrite.config.json file", - action: isAdding ? chalk.green("adding") : chalk.red("deleting"), - }; -}; + const errors: any[] = []; -/** - * Filter deleted and recreated attributes, - * return list of attributes to create - */ -const attributesToCreate = async ( - remoteAttributes: any[], - localAttributes: any[], - collection: any, - isIndex: boolean = false, -): Promise => { - const deleting = remoteAttributes - .filter((attribute) => !attributesContains(attribute, localAttributes)) - .map((attr) => generateChangesObject(attr, collection, false)); - const adding = localAttributes - .filter((attribute) => !attributesContains(attribute, remoteAttributes)) - .map((attr) => generateChangesObject(attr, collection, true)); - const conflicts = remoteAttributes - .map((attribute) => - checkAttributeChanges( - attribute, - attributesContains(attribute, localAttributes), - collection, - ), - ) - .filter((attribute) => attribute !== undefined) as AttributeChange[]; - const changes = remoteAttributes - .map((attribute) => - checkAttributeChanges( - attribute, - attributesContains(attribute, localAttributes), - collection, - false, - ), - ) - .filter((attribute) => attribute !== undefined) - .filter( - (attribute) => - conflicts.filter((attr) => attribute!.key === attr.key).length !== 1, - ) as AttributeChange[]; - - let changedAttributes: any[] = []; - const changing = [...deleting, ...adding, ...conflicts, ...changes]; - if (changing.length === 0) { - return changedAttributes; - } + const databases = Array.from( + new Set(collections.map((collection: any) => collection["databaseId"])), + ); - log( - !cliConfig.force - ? "There are pending changes in your collection deployment" - : "List of applied changes", - ); + // Parallel db actions + await Promise.all( + databases.map(async (databaseId: any) => { + const databasesService = await getDatabasesService(this.projectClient); + try { + const database = await databasesService.get(databaseId); - drawTable( - changing.map((change) => { - return { Key: change.key, Action: change.action, Reason: change.reason }; - }), - ); + // Note: We can't get the local database name here since we don't have access to localConfig + // This will need to be handled by the caller if needed + const localDatabaseName = + collections.find((c: any) => c.databaseId === databaseId) + ?.databaseName ?? databaseId; - if (!cliConfig.force) { - if (deleting.length > 0 && !isIndex) { - console.log( - `${chalk.red("------------------------------------------------------")}`, - ); - console.log( - `${chalk.red("| WARNING: Attribute deletion may cause loss of data |")}`, - ); - console.log( - `${chalk.red("------------------------------------------------------")}`, - ); - console.log(); - } - if (conflicts.length > 0 && !isIndex) { - console.log( - `${chalk.red("--------------------------------------------------------")}`, - ); - console.log( - `${chalk.red("| WARNING: Attribute recreation may cause loss of data |")}`, - ); - console.log( - `${chalk.red("--------------------------------------------------------")}`, - ); - console.log(); - } + if (database.name !== localDatabaseName) { + await databasesService.update(databaseId, localDatabaseName); - if ((await getConfirmation()) !== true) { - return changedAttributes; - } - } + success(`Updated ${localDatabaseName} ( ${databaseId} ) name`); + } + } catch (err) { + log(`Database ${databaseId} not found. Creating it now ...`); - if (conflicts.length > 0) { - changedAttributes = conflicts.map((change) => change.attribute); - await Promise.all( - changedAttributes.map((changed) => - deleteAttribute(collection, changed, isIndex), - ), - ); - remoteAttributes = remoteAttributes.filter( - (attribute) => !attributesContains(attribute, changedAttributes), - ); - } + const localDatabaseName = + collections.find((c: any) => c.databaseId === databaseId) + ?.databaseName ?? databaseId; - if (changes.length > 0) { - changedAttributes = changes.map((change) => change.attribute); - await Promise.all( - changedAttributes.map((changed) => - updateAttribute(collection["databaseId"], collection["$id"], changed), - ), + await databasesService.create(databaseId, localDatabaseName); + } + }), ); - } - const deletingAttributes = deleting.map((change) => change.attribute); - await Promise.all( - deletingAttributes.map((attribute) => - deleteAttribute(collection, attribute, isIndex), - ), - ); - const attributeKeys = [ - ...remoteAttributes.map((attribute: any) => attribute.key), - ...deletingAttributes.map((attribute: any) => attribute.key), - ]; - - if (attributeKeys.length) { - const deleteAttributesPoolStatus = await awaitPools.deleteAttributes( - collection["databaseId"], - collection["$id"], - attributeKeys, - ); + // Parallel collection actions + await Promise.all( + collections.map(async (collection: any) => { + try { + const databasesService = await getDatabasesService( + this.projectClient, + ); + const remoteCollection = await databasesService.getCollection( + collection["databaseId"], + collection["$id"], + ); - if (!deleteAttributesPoolStatus) { - throw new Error("Attribute deletion timed out."); - } - } + if (remoteCollection.name !== collection.name) { + await databasesService.updateCollection( + collection["databaseId"], + collection["$id"], + collection.name, + ); - return localAttributes.filter( - (attribute) => !attributesContains(attribute, remoteAttributes), - ); -}; + success(`Updated ${collection.name} ( ${collection["$id"]} ) name`); + } + collection.remoteVersion = remoteCollection; -const createIndexes = async ( - indexes: any[], - collection: any, -): Promise => { - log(`Creating indexes ...`); - - const databasesService = await getDatabasesService(); - for (let index of indexes) { - await databasesService.createIndex( - collection["databaseId"], - collection["$id"], - index.key, - index.type, - index.columns ?? index.attributes, - index.orders, + collection.isExisted = true; + } catch (e: any) { + if (Number(e.code) === 404) { + log( + `Collection ${collection.name} does not exist in the project. Creating ... `, + ); + const databasesService = await getDatabasesService( + this.projectClient, + ); + await databasesService.createCollection( + collection["databaseId"], + collection["$id"], + collection.name, + collection.documentSecurity, + collection["$permissions"], + ); + } else { + errors.push(e); + throw e; + } + } + }), ); - } - const result = await awaitPools.expectIndexes( - collection["databaseId"], - collection["$id"], - indexes.map((index: any) => index.key), - ); - - if (!result) { - throw new Error("Index creation timed out."); - } + let numberOfCollections = 0; + // Serialize attribute actions + for (let collection of collections) { + let collectionAttributes = collection.attributes; + let indexes = collection.indexes; + + if (collection.isExisted) { + collectionAttributes = await attributes.attributesToCreate( + collection.remoteVersion.attributes, + collection.attributes, + collection as Collection, + ); + indexes = await attributes.attributesToCreate( + collection.remoteVersion.indexes, + collection.indexes, + collection as Collection, + true, + ); - success(`Created ${indexes.length} indexes`); -}; + if ( + Array.isArray(collectionAttributes) && + collectionAttributes.length <= 0 && + Array.isArray(indexes) && + indexes.length <= 0 + ) { + continue; + } + } -const createAttributes = async ( - attributes: any[], - collection: any, -): Promise => { - for (let attribute of attributes) { - if (attribute.side !== "child") { - await createAttribute( - collection["databaseId"], - collection["$id"], - attribute, + log( + `Pushing collection ${collection.name} ( ${collection["databaseId"]} - ${collection["$id"]} ) attributes`, ); - } - } - - const result = await awaitPools.expectAttributes( - collection["databaseId"], - collection["$id"], - collection.attributes - .filter((attribute: any) => attribute.side !== "child") - .map((attribute: any) => attribute.key), - ); - - if (!result) { - throw new Error(`Attribute creation timed out.`); - } - success(`Created ${attributes.length} attributes`); -}; + try { + await attributes.createAttributes( + collectionAttributes, + collection as Collection, + ); + } catch (e) { + errors.push(e); + throw e; + } -const createColumns = async (columns: any[], table: any): Promise => { - for (let column of columns) { - if (column.side !== "child") { - await createAttribute(table["databaseId"], table["$id"], column); + try { + await attributes.createIndexes(indexes, collection as Collection); + } catch (e) { + errors.push(e); + throw e; + } + numberOfCollections++; + success( + `Successfully pushed ${collection.name} ( ${collection["$id"]} )`, + ); } - } - - const result = await awaitPools.expectAttributes( - table["databaseId"], - table["$id"], - table.columns - .filter((column: any) => column.side !== "child") - .map((column: any) => column.key), - ); - if (!result) { - throw new Error(`Column creation timed out.`); + return { + successfullyPushed: numberOfCollections, + errors, + }; } +} - success(`Created ${columns.length} columns`); -}; +async function createPushInstance(): Promise { + const projectClient = await sdkForProject(); + const consoleClient = await sdkForConsole(); + return new Push(projectClient, consoleClient); +} const pushResources = async ({ skipDeprecated = false, -}: PushResourcesOptions = {}): Promise => { - const actions: Record Promise> = { - settings: pushSettings, - functions: pushFunction, - sites: pushSite, - collections: pushCollection, - tables: pushTable, - buckets: pushBucket, - teams: pushTeam, - messages: pushMessagingTopic, - }; +}: { + skipDeprecated?: boolean; +} = {}): Promise => { + if (cliConfig.all) { + checkDeployConditions(localConfig); - if (skipDeprecated) { - delete actions.collections; - } + const pushInstance = await createPushInstance(); + const config = localConfig.getProject() as ConfigType; - if (cliConfig.all) { - for (let action of Object.values(actions)) { - await action(); - } + await pushInstance.pushResources(config, { + skipDeprecated, + functionOptions: { code: true, withVariables: false }, + siteOptions: { code: true, withVariables: false }, + }); } else { + const actions: Record Promise> = { + settings: pushSettings, + functions: pushFunction, + sites: pushSite, + collections: pushCollection, + tables: pushTable, + buckets: pushBucket, + teams: pushTeam, + messages: pushMessagingTopic, + }; + + if (skipDeprecated) { + delete actions.collections; + } + const answers = await inquirer.prompt(questionsPushResources); const action = actions[answers.resource]; @@ -1316,7 +1515,7 @@ const pushSettings = async (): Promise => { localConfig.getProject().projectId, ); - const remoteSettings = localConfig.createSettingsObject(response ?? {}); + const remoteSettings = createSettingsObject(response); const localSettings = localConfig.getProject().projectSettings ?? {}; log("Checking for changes ..."); @@ -1354,77 +1553,34 @@ const pushSettings = async (): Promise => { try { log("Pushing project settings ..."); - const projectsService = await getProjectsService(); - const projectId = localConfig.getProject().projectId; - const projectName = localConfig.getProject().projectName; - const settings = localConfig.getProject().projectSettings ?? {}; + const pushInstance = await createPushInstance(); + const config = localConfig.getProject(); + const settings = config.projectSettings ?? {}; - if (projectName) { + if (config.projectName) { log("Applying project name ..."); - await projectsService.update(projectId, projectName); } if (settings.services) { log("Applying service statuses ..."); - for (let [service, status] of Object.entries(settings.services)) { - await projectsService.updateServiceStatus( - projectId, - service as ApiService, - status, - ); - } } if (settings.auth) { if (settings.auth.security) { log("Applying auth security settings ..."); - await projectsService.updateAuthDuration( - projectId, - settings.auth.security.duration, - ); - await projectsService.updateAuthLimit( - projectId, - settings.auth.security.limit, - ); - await projectsService.updateAuthSessionsLimit( - projectId, - settings.auth.security.sessionsLimit, - ); - await projectsService.updateAuthPasswordDictionary( - projectId, - settings.auth.security.passwordDictionary, - ); - await projectsService.updateAuthPasswordHistory( - projectId, - settings.auth.security.passwordHistory, - ); - await projectsService.updatePersonalDataCheck( - projectId, - settings.auth.security.personalDataCheck, - ); - await projectsService.updateSessionAlerts( - projectId, - settings.auth.security.sessionAlerts, - ); - await projectsService.updateMockNumbers( - projectId, - settings.auth.security.mockNumbers, - ); } if (settings.auth.methods) { log("Applying auth methods statuses ..."); - - for (let [method, status] of Object.entries(settings.auth.methods)) { - await projectsService.updateAuthStatus( - projectId, - method as AuthMethod, - status, - ); - } } } + await pushInstance.pushSettings({ + projectId: config.projectId, + projectName: config.projectName, + settings: config.projectSettings, + }); + success(`Successfully pushed ${chalk.bold("all")} project settings.`); } catch (e) { throw e; @@ -1508,293 +1664,19 @@ const pushSite = async ({ log("Pushing sites ..."); - Spinner.start(false); - let successfullyPushed = 0; - let successfullyDeployed = 0; - const failedDeployments: any[] = []; - const errors: any[] = []; - - await Promise.all( - sites.map(async (site: any) => { - let response: any = {}; - - const ignore = site.ignore ? "appwrite.config.json" : ".gitignore"; - let siteExists = false; - let deploymentCreated = false; - - const updaterRow = new Spinner({ - status: "", - resource: site.name, - id: site["$id"], - end: `Ignoring using: ${ignore}`, - }); - - updaterRow.update({ status: "Getting" }).startSpinner(SPINNER_DOTS); - - const sitesService = await getSitesService(); - try { - response = await sitesService.get({ siteId: site["$id"] }); - siteExists = true; - if (response.framework !== site.framework) { - updaterRow.fail({ - errorMessage: `Framework mismatch! (local=${site.framework},remote=${response.framework}) Please delete remote site or update your appwrite.config.json`, - }); - return; - } - - updaterRow.update({ status: "Updating" }).replaceSpinner(SPINNER_ARC); - - response = await sitesService.update({ - siteId: site["$id"], - name: site.name, - framework: site.framework, - enabled: site.enabled, - logging: site.logging, - timeout: site.timeout, - installCommand: site.installCommand, - buildCommand: site.buildCommand, - outputDirectory: site.outputDirectory, - buildRuntime: site.buildRuntime, - adapter: site.adapter, - specification: site.specification, - }); - } catch (e: any) { - if (Number(e.code) === 404) { - siteExists = false; - } else { - errors.push(e); - updaterRow.fail({ - errorMessage: e.message ?? "General error occurs please try again", - }); - return; - } - } - - if (!siteExists) { - updaterRow.update({ status: "Creating" }).replaceSpinner(SPINNER_DOTS); - - try { - response = await sitesService.create({ - siteId: site.$id, - name: site.name, - framework: site.framework, - enabled: site.enabled, - logging: site.logging, - timeout: site.timeout, - installCommand: site.installCommand, - buildCommand: site.buildCommand, - outputDirectory: site.outputDirectory, - buildRuntime: site.buildRuntime, - adapter: site.adapter, - specification: site.specification, - }); - - let domain = ""; - try { - const consoleService = await getConsoleService(); - const variables = await consoleService.variables(); - domain = ID.unique() + "." + variables["_APP_DOMAIN_SITES"]; - } catch (error) { - console.error("Error fetching console variables."); - throw error; - } - - try { - const proxyService = await getProxyService(); - const rule = await proxyService.createSiteRule(domain, site.$id); - } catch (error) { - console.error("Error creating site rule."); - throw error; - } - - updaterRow.update({ status: "Created" }); - } catch (e: any) { - errors.push(e); - updaterRow.fail({ - errorMessage: e.message ?? "General error occurs please try again", - }); - return; - } - } - - if (withVariables) { - updaterRow - .update({ status: "Creating variables" }) - .replaceSpinner(SPINNER_ARC); - - const sitesService = await getSitesService(); - const { variables } = await paginate( - async (args: any) => { - return await sitesService.listVariables({ siteId: args.siteId }); - }, - { - siteId: site["$id"], - }, - 100, - "variables", - ); - - await Promise.all( - variables.map(async (variable: any) => { - const sitesService = await getSitesService(); - await sitesService.deleteVariable({ - siteId: site["$id"], - variableId: variable["$id"], - }); - }), - ); - - const envFileLocation = `${site["path"]}/.env`; - let envVariables: Array<{ key: string; value: string }> = []; - try { - if (fs.existsSync(envFileLocation)) { - const envObject = parseDotenv( - fs.readFileSync(envFileLocation, "utf8"), - ); - envVariables = Object.entries(envObject || {}).map( - ([key, value]) => ({ key, value }), - ); - } - } catch (error) { - // Handle parsing errors gracefully - envVariables = []; - } - await Promise.all( - envVariables.map(async (variable) => { - const sitesService = await getSitesService(); - await sitesService.createVariable({ - siteId: site["$id"], - key: variable.key, - value: variable.value, - secret: false, - }); - }), - ); - } - - if (code === false) { - successfullyPushed++; - successfullyDeployed++; - updaterRow.update({ status: "Pushed" }); - updaterRow.stopSpinner(); - return; - } - - try { - updaterRow.update({ status: "Pushing" }).replaceSpinner(SPINNER_ARC); - const sitesService = await getSitesService(); - response = await sitesService.createDeployment({ - siteId: site["$id"], - installCommand: site.installCommand, - buildCommand: site.buildCommand, - outputDirectory: site.outputDirectory, - code: site.path, - activate: true, - }); - - updaterRow.update({ status: "Pushed" }); - deploymentCreated = true; - successfullyPushed++; - } catch (e: any) { - errors.push(e); - - switch (e.code) { - case "ENOENT": - updaterRow.fail({ - errorMessage: "Not found in the current directory. Skipping...", - }); - break; - default: - updaterRow.fail({ - errorMessage: - e.message ?? "An unknown error occurred. Please try again.", - }); - } - } - - if (deploymentCreated && !asyncDeploy) { - try { - const deploymentId = response["$id"]; - updaterRow.update({ - status: "Deploying", - end: "Checking deployment status...", - }); - let pollChecks = 0; - - while (true) { - const sitesService = await getSitesService(); - response = await sitesService.getDeployment({ - siteId: site["$id"], - deploymentId: deploymentId, - }); - - const status = response["status"]; - if (status === "ready") { - successfullyDeployed++; - - let url = ""; - const proxyService = await getProxyService(); - const res = await proxyService.listRules([ - JSON.stringify({ method: "limit", values: [1] }), - JSON.stringify({ - method: "equal", - attribute: "deploymentResourceType", - values: ["site"], - }), - JSON.stringify({ - method: "equal", - attribute: "deploymentResourceId", - values: [site["$id"]], - }), - JSON.stringify({ - method: "equal", - attribute: "trigger", - values: ["manual"], - }), - ]); - - if (Number(res.total) === 1) { - url = res.rules[0].domain; - } - - updaterRow.update({ status: "Deployed", end: url }); - - break; - } else if (status === "failed") { - failedDeployments.push({ - name: site["name"], - $id: site["$id"], - deployment: response["$id"], - }); - updaterRow.fail({ errorMessage: `Failed to deploy` }); - - break; - } else { - updaterRow.update({ - status: "Deploying", - end: `Current status: ${status}`, - }); - } - - pollChecks++; - await new Promise((resolve) => - setTimeout(resolve, POLL_DEBOUNCE * 1.5), - ); - } - } catch (e: any) { - errors.push(e); - updaterRow.fail({ - errorMessage: - e.message ?? "Unknown error occurred. Please try again", - }); - } - } - - updaterRow.stopSpinner(); - }), - ); + const pushInstance = await createPushInstance(); + const result = await pushInstance.pushSites(sites, { + async: asyncDeploy, + code, + withVariables, + }); - Spinner.stop(); + const { + successfullyPushed, + successfullyDeployed, + failedDeployments, + errors, + } = result; failedDeployments.forEach((failed) => { const { name, deployment, $id } = failed; @@ -1844,357 +1726,77 @@ const pushFunction = async ({ functionIds.push( ...functions.map((func: any) => { return func.$id; - }), - ); - } - - if (functionIds.length <= 0) { - const answers = await inquirer.prompt(questionsPushFunctions); - if (answers.functions) { - functionIds.push(...answers.functions); - } - } - - if (functionIds.length === 0) { - log("No functions found."); - hint( - "Use 'appwrite pull functions' to synchronize existing one, or use 'appwrite init function' to create a new one.", - ); - return; - } - - let functions = functionIds.map((id: string) => { - const functions = localConfig.getFunctions(); - const func = functions.find((f: any) => f.$id === id); - - if (!func) { - throw new Error("Function '" + id + "' not found."); - } - - return func; - }); - - log("Validating functions ..."); - // Validation is done BEFORE pushing so the deployment process can be run in async with progress update - for (let func of functions) { - if (!func.entrypoint) { - log(`Function ${func.name} is missing an entrypoint.`); - const answers = await inquirer.prompt(questionsGetEntrypoint); - func.entrypoint = answers.entrypoint; - localConfig.addFunction(func); - } - } - - if ( - !(await approveChanges( - functions, - async (args: any) => { - const functionsService = await getFunctionsService(); - return await functionsService.get({ functionId: args.functionId }); - }, - KeysFunction, - "functionId", - "functions", - ["vars"], - )) - ) { - return; - } - - log("Pushing functions ..."); - - Spinner.start(false); - let successfullyPushed = 0; - let successfullyDeployed = 0; - const failedDeployments: any[] = []; - const errors: any[] = []; - - await Promise.all( - functions.map(async (func: any) => { - let response: any = {}; - - const ignore = func.ignore ? "appwrite.config.json" : ".gitignore"; - let functionExists = false; - let deploymentCreated = false; - - const updaterRow = new Spinner({ - status: "", - resource: func.name, - id: func["$id"], - end: `Ignoring using: ${ignore}`, - }); - - updaterRow.update({ status: "Getting" }).startSpinner(SPINNER_DOTS); - const functionsService = await getFunctionsService(); - try { - response = await functionsService.get({ functionId: func["$id"] }); - functionExists = true; - if (response.runtime !== func.runtime) { - updaterRow.fail({ - errorMessage: `Runtime mismatch! (local=${func.runtime},remote=${response.runtime}) Please delete remote function or update your appwrite.config.json`, - }); - return; - } - - updaterRow.update({ status: "Updating" }).replaceSpinner(SPINNER_ARC); - - response = await functionsService.update({ - functionId: func["$id"], - name: func.name, - runtime: func.runtime, - execute: func.execute, - events: func.events, - schedule: func.schedule, - timeout: func.timeout, - enabled: func.enabled, - logging: func.logging, - entrypoint: func.entrypoint, - commands: func.commands, - scopes: func.scopes, - specification: func.specification, - }); - } catch (e: any) { - if (Number(e.code) === 404) { - functionExists = false; - } else { - errors.push(e); - updaterRow.fail({ - errorMessage: e.message ?? "General error occurs please try again", - }); - return; - } - } - - if (!functionExists) { - updaterRow.update({ status: "Creating" }).replaceSpinner(SPINNER_DOTS); - - try { - response = await functionsService.create({ - functionId: func.$id, - name: func.name, - runtime: func.runtime, - execute: func.execute, - events: func.events, - schedule: func.schedule, - timeout: func.timeout, - enabled: func.enabled, - logging: func.logging, - entrypoint: func.entrypoint, - commands: func.commands, - scopes: func.scopes, - specification: func.specification, - }); - - let domain = ""; - try { - const consoleService = await getConsoleService(); - const variables = await consoleService.variables(); - domain = ID.unique() + "." + variables["_APP_DOMAIN_FUNCTIONS"]; - } catch (error) { - console.error("Error fetching console variables."); - throw error; - } - - try { - const proxyService = await getProxyService(); - const rule = await proxyService.createFunctionRule( - domain, - func.$id, - ); - } catch (error) { - console.error("Error creating function rule."); - throw error; - } - - updaterRow.update({ status: "Created" }); - } catch (e: any) { - errors.push(e); - updaterRow.fail({ - errorMessage: e.message ?? "General error occurs please try again", - }); - return; - } - } - - if (withVariables) { - updaterRow - .update({ status: "Updating variables" }) - .replaceSpinner(SPINNER_ARC); - - const functionsService = await getFunctionsService(); - const { variables } = await paginate( - async (args: any) => { - return await functionsService.listVariables({ - functionId: args.functionId, - }); - }, - { - functionId: func["$id"], - }, - 100, - "variables", - ); - - await Promise.all( - variables.map(async (variable: any) => { - const functionsService = await getFunctionsService(); - await functionsService.deleteVariable({ - functionId: func["$id"], - variableId: variable["$id"], - }); - }), - ); - - const envFileLocation = `${func["path"]}/.env`; - let envVariables: Array<{ key: string; value: string }> = []; - try { - if (fs.existsSync(envFileLocation)) { - const envObject = parseDotenv( - fs.readFileSync(envFileLocation, "utf8"), - ); - envVariables = Object.entries(envObject || {}).map( - ([key, value]) => ({ key, value }), - ); - } - } catch (error) { - // Handle parsing errors gracefully - envVariables = []; - } - await Promise.all( - envVariables.map(async (variable) => { - const functionsService = await getFunctionsService(); - await functionsService.createVariable({ - functionId: func["$id"], - key: variable.key, - value: variable.value, - secret: false, - }); - }), - ); - } - - if (code === false) { - successfullyPushed++; - successfullyDeployed++; - updaterRow.update({ status: "Pushed" }); - updaterRow.stopSpinner(); - return; - } - - try { - updaterRow.update({ status: "Pushing" }).replaceSpinner(SPINNER_ARC); - const functionsService = await getFunctionsService(); - response = await functionsService.createDeployment({ - functionId: func["$id"], - entrypoint: func.entrypoint, - commands: func.commands, - code: func.path, - activate: true, - }); - - updaterRow.update({ status: "Pushed" }); - deploymentCreated = true; - successfullyPushed++; - } catch (e: any) { - errors.push(e); + }), + ); + } - switch (e.code) { - case "ENOENT": - updaterRow.fail({ - errorMessage: "Not found in the current directory. Skipping...", - }); - break; - default: - updaterRow.fail({ - errorMessage: - e.message ?? "An unknown error occurred. Please try again.", - }); - } - } + if (functionIds.length <= 0) { + const answers = await inquirer.prompt(questionsPushFunctions); + if (answers.functions) { + functionIds.push(...answers.functions); + } + } - if (deploymentCreated && !asyncDeploy) { - try { - const deploymentId = response["$id"]; - updaterRow.update({ - status: "Deploying", - end: "Checking deployment status...", - }); - let pollChecks = 0; + if (functionIds.length === 0) { + log("No functions found."); + hint( + "Use 'appwrite pull functions' to synchronize existing one, or use 'appwrite init function' to create a new one.", + ); + return; + } - while (true) { - const functionsService = await getFunctionsService(); - response = await functionsService.getDeployment({ - functionId: func["$id"], - deploymentId: deploymentId, - }); + let functions = functionIds.map((id: string) => { + const functions = localConfig.getFunctions(); + const func = functions.find((f: any) => f.$id === id); - const status = response["status"]; - if (status === "ready") { - successfullyDeployed++; - - let url = ""; - const proxyService = await getProxyService(); - const res = await proxyService.listRules([ - JSON.stringify({ method: "limit", values: [1] }), - JSON.stringify({ - method: "equal", - attribute: "deploymentResourceType", - values: ["function"], - }), - JSON.stringify({ - method: "equal", - attribute: "deploymentResourceId", - values: [func["$id"]], - }), - JSON.stringify({ - method: "equal", - attribute: "trigger", - values: ["manual"], - }), - ]); - - if (Number(res.total) === 1) { - url = res.rules[0].domain; - } + if (!func) { + throw new Error("Function '" + id + "' not found."); + } - updaterRow.update({ status: "Deployed", end: url }); + return func; + }); - break; - } else if (status === "failed") { - failedDeployments.push({ - name: func["name"], - $id: func["$id"], - deployment: response["$id"], - }); - updaterRow.fail({ errorMessage: `Failed to deploy` }); + log("Validating functions ..."); + for (let func of functions) { + if (!func.entrypoint) { + log(`Function ${func.name} is missing an entrypoint.`); + const answers = await inquirer.prompt(questionsGetEntrypoint); + func.entrypoint = answers.entrypoint; + localConfig.addFunction(func); + } + } - break; - } else { - updaterRow.update({ - status: "Deploying", - end: `Current status: ${status}`, - }); - } + if ( + !(await approveChanges( + functions, + async (args: any) => { + const functionsService = await getFunctionsService(); + return await functionsService.get({ functionId: args.functionId }); + }, + KeysFunction, + "functionId", + "functions", + ["vars"], + )) + ) { + return; + } - pollChecks++; - await new Promise((resolve) => - setTimeout(resolve, POLL_DEBOUNCE * 1.5), - ); - } - } catch (e: any) { - errors.push(e); - updaterRow.fail({ - errorMessage: - e.message ?? "Unknown error occurred. Please try again", - }); - } - } + log("Pushing functions ..."); - updaterRow.stopSpinner(); - }), - ); + const pushInstance = await createPushInstance(); + const result = await pushInstance.pushFunctions(functions, { + async: asyncDeploy, + code, + withVariables, + }); - Spinner.stop(); + const { + successfullyPushed, + successfullyDeployed, + failedDeployments, + errors, + } = result; failedDeployments.forEach((failed) => { const { name, deployment, $id } = failed; @@ -2226,194 +1828,20 @@ const pushFunction = async ({ } }; -const checkAndApplyTablesDBChanges = - async (): Promise => { - log("Checking for tablesDB changes ..."); - - const localTablesDBs = localConfig.getTablesDBs(); - const { databases: remoteTablesDBs } = await paginate( - async (args: any) => { - const tablesDBService = await getTablesDBService(); - return await tablesDBService.list(args.queries || []); - }, - {}, - 100, - "databases", - ); - - if (localTablesDBs.length === 0 && remoteTablesDBs.length === 0) { - return { applied: false, resyncNeeded: false }; - } - - const changes: any[] = []; - const toCreate: any[] = []; - const toUpdate: any[] = []; - const toDelete: any[] = []; - - // Check for deletions - remote DBs that aren't in local config - for (const remoteDB of remoteTablesDBs) { - const localDB = localTablesDBs.find((db: any) => db.$id === remoteDB.$id); - if (!localDB) { - toDelete.push(remoteDB); - changes.push({ - id: remoteDB.$id, - action: chalk.red("deleting"), - key: "Database", - remote: remoteDB.name, - local: "(deleted locally)", - }); - } - } - - // Check for additions and updates - for (const localDB of localTablesDBs) { - const remoteDB = remoteTablesDBs.find( - (db: any) => db.$id === localDB.$id, - ); - - if (!remoteDB) { - toCreate.push(localDB); - changes.push({ - id: localDB.$id, - action: chalk.green("creating"), - key: "Database", - remote: "(does not exist)", - local: localDB.name, - }); - } else { - let hasChanges = false; - - if (remoteDB.name !== localDB.name) { - hasChanges = true; - changes.push({ - id: localDB.$id, - action: chalk.yellow("updating"), - key: "Name", - remote: remoteDB.name, - local: localDB.name, - }); - } - - if (remoteDB.enabled !== localDB.enabled) { - hasChanges = true; - changes.push({ - id: localDB.$id, - action: chalk.yellow("updating"), - key: "Enabled", - remote: remoteDB.enabled, - local: localDB.enabled, - }); - } - - if (hasChanges) { - toUpdate.push(localDB); - } - } - } - - if (changes.length === 0) { - return { applied: false, resyncNeeded: false }; - } - - log("Found changes in tablesDB resource:"); - drawTable(changes); - - if (toDelete.length > 0) { - console.log( - `${chalk.red("------------------------------------------------------------------")}`, - ); - console.log( - `${chalk.red("| WARNING: Database deletion will also delete all related tables |")}`, - ); - console.log( - `${chalk.red("------------------------------------------------------------------")}`, - ); - console.log(); - } - - if ((await getConfirmation()) !== true) { - return { applied: false, resyncNeeded: false }; - } - - // Apply deletions first - let needsResync = false; - for (const db of toDelete) { - try { - log(`Deleting database ${db.name} ( ${db.$id} ) ...`); - const tablesDBService = await getTablesDBService(); - await tablesDBService.delete(db.$id); - success(`Deleted ${db.name} ( ${db.$id} )`); - needsResync = true; - } catch (e: any) { - error( - `Failed to delete database ${db.name} ( ${db.$id} ): ${e.message}`, - ); - throw new Error( - `Database sync failed during deletion of ${db.$id}. Some changes may have been applied.`, - ); - } - } - - // Apply creations - for (const db of toCreate) { - try { - log(`Creating database ${db.name} ( ${db.$id} ) ...`); - const tablesDBService = await getTablesDBService(); - await tablesDBService.create(db.$id, db.name, db.enabled); - success(`Created ${db.name} ( ${db.$id} )`); - } catch (e: any) { - error( - `Failed to create database ${db.name} ( ${db.$id} ): ${e.message}`, - ); - throw new Error( - `Database sync failed during creation of ${db.$id}. Some changes may have been applied.`, - ); - } - } - - // Apply updates - for (const db of toUpdate) { - try { - log(`Updating database ${db.name} ( ${db.$id} ) ...`); - const tablesDBService = await getTablesDBService(); - await tablesDBService.update(db.$id, db.name, db.enabled); - success(`Updated ${db.name} ( ${db.$id} )`); - } catch (e: any) { - error( - `Failed to update database ${db.name} ( ${db.$id} ): ${e.message}`, - ); - throw new Error( - `Database sync failed during update of ${db.$id}. Some changes may have been applied.`, - ); - } - } - - if (toDelete.length === 0) { - console.log(); - } - - return { applied: true, resyncNeeded: needsResync }; - }; - const pushTable = async ({ attempts, }: PushTableOptions = {}): Promise => { const tables: any[] = []; - if (attempts) { - pollMaxDebounces = attempts; - } - - const { applied: tablesDBApplied, resyncNeeded } = - await checkAndApplyTablesDBChanges(); + const { resyncNeeded } = await checkAndApplyTablesDBChanges(); if (resyncNeeded) { - log("Resyncing configuration due to tablesDB deletions ..."); + log("Resyncing configuration due to tables deletions ..."); const remoteTablesDBs = ( await paginate( async (args: any) => { - const tablesDBService = await getTablesDBService(); - return await tablesDBService.list(args.queries || []); + const tablesService = await getTablesDBService(); + return await tablesService.list(args.queries || []); }, {}, 100, @@ -2433,7 +1861,7 @@ const pushTable = async ({ const validTablesDBs = localTablesDBs.filter((db: any) => remoteDatabaseIds.has(db.$id), ); - localConfig.set("tablesDB", validTablesDBs); + localConfig.set("tables", validTablesDBs); success("Configuration resynced successfully."); console.log(); @@ -2448,8 +1876,8 @@ const pushTable = async ({ try { const { tables: remoteTables } = await paginate( async (args: any) => { - const tablesDBService = await getTablesDBService(); - return await tablesDBService.listTables( + const tablesService = await getTablesDBService(); + return await tablesService.listTables( args.databaseId, args.queries || [], ); @@ -2496,8 +1924,8 @@ const pushTable = async ({ log( `Deleting table ${table.name} ( ${table.$id} ) from database ${table.databaseName} ...`, ); - const tablesDBService = await getTablesDBService(); - await tablesDBService.deleteTable(table.databaseId, table.$id); + const tablesService = await getTablesDBService(); + await tablesService.deleteTable(table.databaseId, table.$id); success(`Deleted ${table.name} ( ${table.$id} )`); } catch (e: any) { error( @@ -2537,8 +1965,8 @@ const pushTable = async ({ !(await approveChanges( tables, async (args: any) => { - const tablesDBService = await getTablesDBService(); - return await tablesDBService.getTable(args.databaseId, args.tableId); + const tablesService = await getTablesDBService(); + return await tablesService.getTable(args.databaseId, args.tableId); }, KeysTable, "tableId", @@ -2550,129 +1978,31 @@ const pushTable = async ({ ) { return; } - let tablesChanged = new Set(); - - // Parallel tables actions - await Promise.all( - tables.map(async (table: any) => { - try { - const tablesDBService = await getTablesDBService(); - const remoteTable = await tablesDBService.getTable( - table["databaseId"], - table["$id"], - ); - - const changes: string[] = []; - if (remoteTable.name !== table.name) changes.push("name"); - if (remoteTable.rowSecurity !== table.rowSecurity) - changes.push("rowSecurity"); - if (remoteTable.enabled !== table.enabled) changes.push("enabled"); - if ( - JSON.stringify(remoteTable["$permissions"]) !== - JSON.stringify(table["$permissions"]) - ) - changes.push("permissions"); - - if (changes.length > 0) { - await tablesDBService.updateTable( - table["databaseId"], - table["$id"], - table.name, - table.rowSecurity, - table["$permissions"], - ); - - success( - `Updated ${table.name} ( ${table["$id"]} ) - ${changes.join(", ")}`, - ); - tablesChanged.add(table["$id"]); - } - table.remoteVersion = remoteTable; - - table.isExisted = true; - } catch (e: any) { - if (Number(e.code) === 404) { - log( - `Table ${table.name} does not exist in the project. Creating ... `, - ); - const tablesDBService = await getTablesDBService(); - await tablesDBService.createTable( - table["databaseId"], - table["$id"], - table.name, - table.rowSecurity, - table["$permissions"], - ); - - success(`Created ${table.name} ( ${table["$id"]} )`); - tablesChanged.add(table["$id"]); - } else { - throw e; - } - } - }), - ); - - // Serialize attribute actions - for (let table of tables) { - let columns = table.columns; - let indexes = table.indexes; - - if (table.isExisted) { - columns = await attributesToCreate( - table.remoteVersion.columns, - table.columns, - table, - ); - indexes = await attributesToCreate( - table.remoteVersion.indexes, - table.indexes, - table, - true, - ); - if ( - Array.isArray(columns) && - columns.length <= 0 && - Array.isArray(indexes) && - indexes.length <= 0 - ) { - continue; - } - } + log("Pushing tables ..."); - log( - `Pushing table ${table.name} ( ${table["databaseId"]} - ${table["$id"]} ) attributes`, - ); + const pushInstance = await createPushInstance(); + const result = await pushInstance.pushTables(tables, attempts); - try { - await createColumns(columns, table); - } catch (e) { - throw e; - } + const { successfullyPushed, errors } = result; - try { - await createIndexes(indexes, table); - } catch (e) { - throw e; - } - tablesChanged.add(table["$id"]); - success(`Successfully pushed ${table.name} ( ${table["$id"]} )`); + if (successfullyPushed === 0) { + error("No tables were pushed."); + } else { + success(`Successfully pushed ${successfullyPushed} tables.`); } - success(`Successfully pushed ${tablesChanged.size} tables`); + if (cliConfig.verbose) { + errors.forEach((e) => console.error(e)); + } }; -const pushCollection = async ({ attempts }): Promise => { +const pushCollection = async ({}: PushTableOptions = {}): Promise => { warn( "appwrite push collection has been deprecated. Please consider using 'appwrite push tables' instead", ); const collections: any[] = []; - if (attempts) { - pollMaxDebounces = attempts; - } - if (cliConfig.all) { checkDeployConditions(localConfig); collections.push(...localConfig.getCollections()); @@ -2698,37 +2028,11 @@ const pushCollection = async ({ attempts }): Promise => { return; } - const databases = Array.from( - new Set(collections.map((collection: any) => collection["databaseId"])), - ); - - // Parallel db actions - await Promise.all( - databases.map(async (databaseId: any) => { - const localDatabase = localConfig.getDatabase(databaseId); - - const databasesService = await getDatabasesService(); - try { - const database = await databasesService.get(databaseId); - - if (database.name !== (localDatabase.name ?? databaseId)) { - await databasesService.update( - databaseId, - localDatabase.name ?? databaseId, - ); - - success(`Updated ${localDatabase.name} ( ${databaseId} ) name`); - } - } catch (err) { - log(`Database ${databaseId} not found. Creating it now ...`); - - await databasesService.create( - databaseId, - localDatabase.name ?? databaseId, - ); - } - }), - ); + // Add database names to collections for the class method + collections.forEach((collection: any) => { + const localDatabase = localConfig.getDatabase(collection.databaseId); + collection.databaseName = localDatabase.name ?? collection.databaseId; + }); if ( !(await approveChanges( @@ -2750,101 +2054,26 @@ const pushCollection = async ({ attempts }): Promise => { ) { return; } - // Parallel collection actions - await Promise.all( - collections.map(async (collection: any) => { - try { - const databasesService = await getDatabasesService(); - const remoteCollection = await databasesService.getCollection( - collection["databaseId"], - collection["$id"], - ); - - if (remoteCollection.name !== collection.name) { - await databasesService.updateCollection( - collection["databaseId"], - collection["$id"], - collection.name, - ); - - success(`Updated ${collection.name} ( ${collection["$id"]} ) name`); - } - collection.remoteVersion = remoteCollection; - - collection.isExisted = true; - } catch (e: any) { - if (Number(e.code) === 404) { - log( - `Collection ${collection.name} does not exist in the project. Creating ... `, - ); - const databasesService = await getDatabasesService(); - await databasesService.createCollection( - collection["databaseId"], - collection["$id"], - collection.name, - collection.documentSecurity, - collection["$permissions"], - ); - } else { - throw e; - } - } - }), - ); - let numberOfCollections = 0; - // Serialize attribute actions - for (let collection of collections) { - let attributes = collection.attributes; - let indexes = collection.indexes; - - if (collection.isExisted) { - attributes = await attributesToCreate( - collection.remoteVersion.attributes, - collection.attributes, - collection, - ); - indexes = await attributesToCreate( - collection.remoteVersion.indexes, - collection.indexes, - collection, - true, - ); - if ( - Array.isArray(attributes) && - attributes.length <= 0 && - Array.isArray(indexes) && - indexes.length <= 0 - ) { - continue; - } - } + log("Pushing collections ..."); - log( - `Pushing collection ${collection.name} ( ${collection["databaseId"]} - ${collection["$id"]} ) attributes`, - ); + const pushInstance = await createPushInstance(); + const result = await pushInstance.pushCollections(collections); - try { - await createAttributes(attributes, collection); - } catch (e) { - throw e; - } + const { successfullyPushed, errors } = result; - try { - await createIndexes(indexes, collection); - } catch (e) { - throw e; - } - numberOfCollections++; - success(`Successfully pushed ${collection.name} ( ${collection["$id"]} )`); + if (successfullyPushed === 0) { + error("No collections were pushed."); + } else { + success(`Successfully pushed ${successfullyPushed} collections.`); } - success(`Successfully pushed ${numberOfCollections} collections`); + if (cliConfig.verbose) { + errors.forEach((e) => console.error(e)); + } }; const pushBucket = async (): Promise => { - let response: any = {}; - let bucketIds: string[] = []; const configBuckets = localConfig.getBuckets(); @@ -2892,55 +2121,23 @@ const pushBucket = async (): Promise => { log("Pushing buckets ..."); - for (let bucket of buckets) { - log(`Pushing bucket ${chalk.bold(bucket["name"])} ...`); + const pushInstance = await createPushInstance(); + const result = await pushInstance.pushBuckets(buckets); - const storageService = await getStorageService(); - try { - response = await storageService.getBucket(bucket["$id"]); - - await storageService.updateBucket( - bucket["$id"], - bucket.name, - bucket["$permissions"], - bucket.fileSecurity, - bucket.enabled, - bucket.maximumFileSize, - bucket.allowedFileExtensions, - bucket.encryption, - bucket.antivirus, - bucket.compression, - ); - } catch (e: any) { - if (Number(e.code) === 404) { - log( - `Bucket ${bucket.name} does not exist in the project. Creating ... `, - ); + const { successfullyPushed, errors } = result; - response = await storageService.createBucket( - bucket["$id"], - bucket.name, - bucket["$permissions"], - bucket.fileSecurity, - bucket.enabled, - bucket.maximumFileSize, - bucket.allowedFileExtensions, - bucket.compression, - bucket.encryption, - bucket.antivirus, - ); - } else { - throw e; - } - } + if (successfullyPushed === 0) { + error("No buckets were pushed."); + } else { + success(`Successfully pushed ${successfullyPushed} buckets.`); } - success(`Successfully pushed ${buckets.length} buckets.`); + if (cliConfig.verbose) { + errors.forEach((e) => console.error(e)); + } }; const pushTeam = async (): Promise => { - let response: any = {}; - let teamIds: string[] = []; const configTeams = localConfig.getTeams(); @@ -2988,31 +2185,23 @@ const pushTeam = async (): Promise => { log("Pushing teams ..."); - for (let team of teams) { - log(`Pushing team ${chalk.bold(team["name"])} ...`); + const pushInstance = await createPushInstance(); + const result = await pushInstance.pushTeams(teams); - const teamsService = await getTeamsService(); - try { - response = await teamsService.get(team["$id"]); - - await teamsService.updateName(team["$id"], team.name); - } catch (e: any) { - if (Number(e.code) === 404) { - log(`Team ${team.name} does not exist in the project. Creating ... `); + const { successfullyPushed, errors } = result; - response = await teamsService.create(team["$id"], team.name); - } else { - throw e; - } - } + if (successfullyPushed === 0) { + error("No teams were pushed."); + } else { + success(`Successfully pushed ${successfullyPushed} teams.`); } - success(`Successfully pushed ${teams.length} teams.`); + if (cliConfig.verbose) { + errors.forEach((e) => console.error(e)); + } }; const pushMessagingTopic = async (): Promise => { - let response: any = {}; - let topicsIds: string[] = []; const configTopics = localConfig.getMessagingTopics(); @@ -3060,37 +2249,20 @@ const pushMessagingTopic = async (): Promise => { log("Pushing topics ..."); - for (let topic of topics) { - log(`Pushing topic ${chalk.bold(topic["name"])} ...`); - - const messagingService = await getMessagingService(); - try { - response = await messagingService.getTopic(topic["$id"]); - log(`Topic ${topic.name} ( ${topic["$id"]} ) already exists.`); + const pushInstance = await createPushInstance(); + const result = await pushInstance.pushMessagingTopics(topics); - await messagingService.updateTopic( - topic["$id"], - topic.name, - topic.subscribe, - ); - } catch (e: any) { - if (Number(e.code) === 404) { - log(`Topic ${topic.name} does not exist in the project. Creating ... `); - - response = await messagingService.createTopic( - topic["$id"], - topic.name, - topic.subscribe, - ); + const { successfullyPushed, errors } = result; - success(`Created ${topic.name} ( ${topic["$id"]} )`); - } else { - throw e; - } - } + if (successfullyPushed === 0) { + error("No topics were pushed."); + } else { + success(`Successfully pushed ${successfullyPushed} topics.`); } - success(`Successfully pushed ${topics.length} topics.`); + if (cliConfig.verbose) { + errors.forEach((e) => console.error(e)); + } }; export const push = new Command("push") diff --git a/lib/commands/schema.ts b/lib/commands/schema.ts new file mode 100644 index 00000000..7b7a21a8 --- /dev/null +++ b/lib/commands/schema.ts @@ -0,0 +1,103 @@ +import { Client } from "@appwrite.io/console"; +import type { ConfigType } from "./config.js"; +import { ConfigSchema } from "./config.js"; +import { Pull, PullOptions } from "./pull.js"; +import { Push, PushOptions } from "./push.js"; +import { parseWithBetterErrors } from "./utils/error-formatter.js"; +import JSONbig from "json-bigint"; +import * as fs from "fs"; +import { Db } from "./db.js"; + +const JSONBig = JSONbig({ storeAsString: false }); + +export class Schema { + private pullCommand: Pull; + private pushCommand: Push; + public db: Db; + + constructor({ + projectClient, + consoleClient, + }: { + projectClient: Client; + consoleClient: Client; + }) { + this.pullCommand = new Pull(projectClient, consoleClient); + this.pushCommand = new Push(projectClient, consoleClient); + this.db = new Db(); + } + + /** + * Validates the provided configuration object against the schema. + * + * @param config - The configuration object to validate. + * @returns The validated and possibly transformed configuration object. + * @throws If the configuration does not match the schema. + */ + public validate(config: ConfigType): ConfigType { + return parseWithBetterErrors( + ConfigSchema, + config, + "Configuration schema validation", + config, + ); + } + + /** + * Pulls the current schema and resources from the remote Appwrite project. + * + * @param config - The local configuration object. + * @param options - Optional settings for the pull operation. + * @returns A Promise that resolves to the updated configuration object reflecting the remote state. + */ + public async pull( + config: ConfigType, + options: PullOptions = { all: true }, + ): Promise { + return await this.pullCommand.pullResources(config, options); + } + + /** + * Pushes the local configuration and schema to the remote Appwrite project. + * Optionally syncs the config file by pulling the updated state from the server after push. + * + * @param config - The local configuration object to push. + * @param options - Optional settings for the push operation. Use `force: true` to allow destructive changes. + * @param configPath - Optional path to the config file. If provided, the config will be synced after push. + * @returns A Promise that resolves when the push operation is complete. + * @throws {DestructiveChangeError} When destructive changes are detected and force is not enabled. + */ + public async push( + config: ConfigType, + options: PushOptions, + configPath?: string, + ): Promise { + await this.pushCommand.pushResources(config, options); + + if (configPath) { + const updatedConfig = await this.pullCommand.pullResources(config); + this.write(updatedConfig, configPath); + } + } + + /** + * Reads the configuration object from a file. + * + * @param path - The path to the file to read. + * @returns The configuration object. + */ + public read(path: string): ConfigType { + return JSONBig.parse(fs.readFileSync(path, "utf8")) as ConfigType; + } + + /** + * Writes the configuration object to a file. + * + * @param config - The configuration object to write. + * @param path - The path to the file to write. + * @returns void + */ + public write(config: ConfigType, path: string): void { + fs.writeFileSync(path, JSONBig.stringify(config, null, 4)); + } +} diff --git a/lib/commands/update.ts b/lib/commands/update.ts index f4445b90..af2f8816 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -1,18 +1,8 @@ -import fs from "fs"; -import path from "path"; import { spawn } from "child_process"; import { Command } from "commander"; import chalk from "chalk"; import inquirer from "inquirer"; -import { - success, - log, - warn, - error, - hint, - actionRunner, - commandDescriptions, -} from "../parser.js"; +import { success, log, warn, error, hint, actionRunner } from "../parser.js"; import { getLatestVersion, compareVersions } from "../utils.js"; import packageJson from "../../package.json" with { type: "json" }; const { version } = packageJson; diff --git a/lib/commands/utils/attributes.ts b/lib/commands/utils/attributes.ts new file mode 100644 index 00000000..589d84f3 --- /dev/null +++ b/lib/commands/utils/attributes.ts @@ -0,0 +1,734 @@ +import chalk from "chalk"; +import { getDatabasesService } from "../../services.js"; +import { KeysAttributes } from "../../config.js"; +import { log, success, cliConfig, drawTable } from "../../parser.js"; +import { Pools } from "./pools.js"; +import inquirer from "inquirer"; + +const changeableKeys = [ + "status", + "required", + "xdefault", + "elements", + "min", + "max", + "default", + "error", +]; + +export interface AttributeChange { + key: string; + attribute: any; + reason: string; + action: string; +} + +export interface Collection { + $id: string; + databaseId: string; + name: string; + attributes?: any[]; + indexes?: any[]; + columns?: any[]; + [key: string]: any; +} + +const questionPushChanges = [ + { + type: "input", + name: "changes", + message: 'Type "YES" to confirm or "NO" to cancel:', + }, +]; + +const questionPushChangesConfirmation = [ + { + type: "input", + name: "changes", + message: + 'Incorrect answer. Please type "YES" to confirm or "NO" to cancel:', + }, +]; + +export class Attributes { + private pools: Pools; + + constructor(pools?: Pools) { + this.pools = pools || new Pools(); + } + + private getConfirmation = async (): Promise => { + if (cliConfig.force) { + return true; + } + + async function fixConfirmation(): Promise { + const answers = await inquirer.prompt(questionPushChangesConfirmation); + if (answers.changes !== "YES" && answers.changes !== "NO") { + return await fixConfirmation(); + } + + return answers.changes; + } + + let answers = await inquirer.prompt(questionPushChanges); + + if (answers.changes !== "YES" && answers.changes !== "NO") { + answers.changes = await fixConfirmation(); + } + + if (answers.changes === "YES") { + return true; + } + + return false; + }; + + private isEmpty = (value: any): boolean => + value === null || + value === undefined || + (typeof value === "string" && value.trim().length === 0) || + (Array.isArray(value) && value.length === 0); + + private isEqual = (a: any, b: any): boolean => { + if (a === b) return true; + + if (a && b && typeof a === "object" && typeof b === "object") { + if ( + a.constructor && + a.constructor.name === "BigNumber" && + b.constructor && + b.constructor.name === "BigNumber" + ) { + return a.eq(b); + } + + if (typeof a.equals === "function") { + return a.equals(b); + } + + if (typeof a.eq === "function") { + return a.eq(b); + } + } + + if (typeof a === "number" && typeof b === "number") { + if (isNaN(a) && isNaN(b)) return true; + if (!isFinite(a) && !isFinite(b)) return a === b; + return Math.abs(a - b) < Number.EPSILON; + } + + return false; + }; + + private compareAttribute = ( + remote: any, + local: any, + reason: string, + key: string, + ): string => { + if (this.isEmpty(remote) && this.isEmpty(local)) { + return reason; + } + + if (Array.isArray(remote) && Array.isArray(local)) { + if (JSON.stringify(remote) !== JSON.stringify(local)) { + const bol = reason === "" ? "" : "\n"; + reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}`; + } + } else if (!this.isEqual(remote, local)) { + const bol = reason === "" ? "" : "\n"; + reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}`; + } + + return reason; + }; + + /** + * Check if attribute non-changeable fields has been changed + * If so return the differences as an object. + */ + private checkAttributeChanges = ( + remote: any, + local: any, + collection: Collection, + recreating: boolean = true, + ): AttributeChange | undefined => { + if (local === undefined) { + return undefined; + } + + const keyName = `${chalk.yellow(local.key)} in ${collection.name} (${collection["$id"]})`; + const action = chalk.cyan(recreating ? "recreating" : "changing"); + let reason = ""; + let attribute = recreating ? remote : local; + + for (let key of Object.keys(remote)) { + if (!KeysAttributes.has(key)) { + continue; + } + + if (changeableKeys.includes(key)) { + if (!recreating) { + reason = this.compareAttribute(remote[key], local[key], reason, key); + } + continue; + } + + if (!recreating) { + continue; + } + + reason = this.compareAttribute(remote[key], local[key], reason, key); + } + + return reason === "" + ? undefined + : { key: keyName, attribute, reason, action }; + }; + + /** + * Check if attributes contain the given attribute + */ + private attributesContains = (attribute: any, attributes: any[]): any => + attributes.find((attr) => attr.key === attribute.key); + + private generateChangesObject = ( + attribute: any, + collection: Collection, + isAdding: boolean, + ): AttributeChange => { + return { + key: `${chalk.yellow(attribute.key)} in ${collection.name} (${collection["$id"]})`, + attribute: attribute, + reason: isAdding + ? "Field isn't present on the remote server" + : "Field isn't present on the appwrite.config.json file", + action: isAdding ? chalk.green("adding") : chalk.red("deleting"), + }; + }; + + public createAttribute = async ( + databaseId: string, + collectionId: string, + attribute: any, + ): Promise => { + const databasesService = await getDatabasesService(); + switch (attribute.type) { + case "string": + switch (attribute.format) { + case "email": + return databasesService.createEmailAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + }); + case "url": + return databasesService.createUrlAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + }); + case "ip": + return databasesService.createIpAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + }); + case "enum": + return databasesService.createEnumAttribute({ + databaseId, + collectionId, + key: attribute.key, + elements: attribute.elements, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + }); + default: + return databasesService.createStringAttribute({ + databaseId, + collectionId, + key: attribute.key, + size: attribute.size, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + encrypt: attribute.encrypt, + }); + } + case "integer": + return databasesService.createIntegerAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: attribute.min, + max: attribute.max, + xdefault: attribute.default, + array: attribute.array, + }); + case "double": + return databasesService.createFloatAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: attribute.min, + max: attribute.max, + xdefault: attribute.default, + array: attribute.array, + }); + case "boolean": + return databasesService.createBooleanAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + }); + case "datetime": + return databasesService.createDatetimeAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + }); + case "relationship": + return databasesService.createRelationshipAttribute({ + databaseId, + collectionId, + relatedCollectionId: + attribute.relatedTable ?? attribute.relatedCollection, + type: attribute.relationType, + twoWay: attribute.twoWay, + key: attribute.key, + twoWayKey: attribute.twoWayKey, + onDelete: attribute.onDelete, + }); + case "point": + return databasesService.createPointAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "linestring": + return databasesService.createLineAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "polygon": + return databasesService.createPolygonAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + default: + throw new Error(`Unsupported attribute type: ${attribute.type}`); + } + }; + + public updateAttribute = async ( + databaseId: string, + collectionId: string, + attribute: any, + ): Promise => { + const databasesService = await getDatabasesService(); + switch (attribute.type) { + case "string": + switch (attribute.format) { + case "email": + return databasesService.updateEmailAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "url": + return databasesService.updateUrlAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "ip": + return databasesService.updateIpAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "enum": + return databasesService.updateEnumAttribute({ + databaseId, + collectionId, + key: attribute.key, + elements: attribute.elements, + required: attribute.required, + xdefault: attribute.default, + }); + default: + return databasesService.updateStringAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + } + case "integer": + return databasesService.updateIntegerAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: attribute.min, + max: attribute.max, + xdefault: attribute.default, + }); + case "double": + return databasesService.updateFloatAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: attribute.min, + max: attribute.max, + xdefault: attribute.default, + }); + case "boolean": + return databasesService.updateBooleanAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "datetime": + return databasesService.updateDatetimeAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "relationship": + return databasesService.updateRelationshipAttribute({ + databaseId, + collectionId, + key: attribute.key, + onDelete: attribute.onDelete, + }); + case "point": + return databasesService.updatePointAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "linestring": + return databasesService.updateLineAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + case "polygon": + return databasesService.updatePolygonAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + }); + default: + throw new Error(`Unsupported attribute type: ${attribute.type}`); + } + }; + + public deleteAttribute = async ( + collection: Collection, + attribute: any, + isIndex: boolean = false, + ): Promise => { + log( + `Deleting ${isIndex ? "index" : "attribute"} ${attribute.key} of ${collection.name} ( ${collection["$id"]} )`, + ); + + const databasesService = await getDatabasesService(); + if (isIndex) { + await databasesService.deleteIndex( + collection["databaseId"], + collection["$id"], + attribute.key, + ); + return; + } + + await databasesService.deleteAttribute( + collection["databaseId"], + collection["$id"], + attribute.key, + ); + }; + + /** + * Filter deleted and recreated attributes, + * return list of attributes to create + */ + public attributesToCreate = async ( + remoteAttributes: any[], + localAttributes: any[], + collection: Collection, + isIndex: boolean = false, + ): Promise => { + const deleting = remoteAttributes + .filter( + (attribute) => !this.attributesContains(attribute, localAttributes), + ) + .map((attr) => this.generateChangesObject(attr, collection, false)); + const adding = localAttributes + .filter( + (attribute) => !this.attributesContains(attribute, remoteAttributes), + ) + .map((attr) => this.generateChangesObject(attr, collection, true)); + const conflicts = remoteAttributes + .map((attribute) => + this.checkAttributeChanges( + attribute, + this.attributesContains(attribute, localAttributes), + collection, + ), + ) + .filter((attribute) => attribute !== undefined) as AttributeChange[]; + const changes = remoteAttributes + .map((attribute) => + this.checkAttributeChanges( + attribute, + this.attributesContains(attribute, localAttributes), + collection, + false, + ), + ) + .filter((attribute) => attribute !== undefined) + .filter( + (attribute) => + conflicts.filter((attr) => attribute!.key === attr.key).length !== 1, + ) as AttributeChange[]; + + let changedAttributes: any[] = []; + const changing = [...deleting, ...adding, ...conflicts, ...changes]; + if (changing.length === 0) { + return changedAttributes; + } + + log( + !cliConfig.force + ? "There are pending changes in your collection deployment" + : "List of applied changes", + ); + + drawTable( + changing.map((change) => { + return { + Key: change.key, + Action: change.action, + Reason: change.reason, + }; + }), + ); + + if (!cliConfig.force) { + if (deleting.length > 0 && !isIndex) { + console.log( + `${chalk.red("------------------------------------------------------")}`, + ); + console.log( + `${chalk.red("| WARNING: Attribute deletion may cause loss of data |")}`, + ); + console.log( + `${chalk.red("------------------------------------------------------")}`, + ); + console.log(); + } + if (conflicts.length > 0 && !isIndex) { + console.log( + `${chalk.red("--------------------------------------------------------")}`, + ); + console.log( + `${chalk.red("| WARNING: Attribute recreation may cause loss of data |")}`, + ); + console.log( + `${chalk.red("--------------------------------------------------------")}`, + ); + console.log(); + } + + if ((await this.getConfirmation()) !== true) { + return changedAttributes; + } + } + + if (conflicts.length > 0) { + changedAttributes = conflicts.map((change) => change.attribute); + await Promise.all( + changedAttributes.map((changed) => + this.deleteAttribute(collection, changed, isIndex), + ), + ); + remoteAttributes = remoteAttributes.filter( + (attribute) => !this.attributesContains(attribute, changedAttributes), + ); + } + + if (changes.length > 0) { + changedAttributes = changes.map((change) => change.attribute); + await Promise.all( + changedAttributes.map((changed) => + this.updateAttribute( + collection["databaseId"], + collection["$id"], + changed, + ), + ), + ); + } + + const deletingAttributes = deleting.map((change) => change.attribute); + await Promise.all( + deletingAttributes.map((attribute) => + this.deleteAttribute(collection, attribute, isIndex), + ), + ); + const attributeKeys = [ + ...remoteAttributes.map((attribute: any) => attribute.key), + ...deletingAttributes.map((attribute: any) => attribute.key), + ]; + + if (attributeKeys.length) { + const deleteAttributesPoolStatus = + await this.pools.waitForAttributeDeletion( + collection["databaseId"], + collection["$id"], + attributeKeys, + ); + + if (!deleteAttributesPoolStatus) { + throw new Error("Attribute deletion timed out."); + } + } + + return localAttributes.filter( + (attribute) => !this.attributesContains(attribute, remoteAttributes), + ); + }; + + public createIndexes = async ( + indexes: any[], + collection: Collection, + ): Promise => { + log(`Creating indexes ...`); + + const databasesService = await getDatabasesService(); + for (let index of indexes) { + await databasesService.createIndex( + collection["databaseId"], + collection["$id"], + index.key, + index.type, + index.columns ?? index.attributes, + index.orders, + ); + } + + const result = await this.pools.expectIndexes( + collection["databaseId"], + collection["$id"], + indexes.map((index: any) => index.key), + ); + + if (!result) { + throw new Error("Index creation timed out."); + } + + success(`Created ${indexes.length} indexes`); + }; + + public createAttributes = async ( + attributes: any[], + collection: Collection, + ): Promise => { + for (let attribute of attributes) { + if (attribute.side !== "child") { + await this.createAttribute( + collection["databaseId"], + collection["$id"], + attribute, + ); + } + } + + const result = await this.pools.expectAttributes( + collection["databaseId"], + collection["$id"], + (collection.attributes || []) + .filter((attribute: any) => attribute.side !== "child") + .map((attribute: any) => attribute.key), + ); + + if (!result) { + throw new Error(`Attribute creation timed out.`); + } + + success(`Created ${attributes.length} attributes`); + }; + + public createColumns = async ( + columns: any[], + table: Collection, + ): Promise => { + for (let column of columns) { + if (column.side !== "child") { + await this.createAttribute(table["databaseId"], table["$id"], column); + } + } + + const result = await this.pools.expectAttributes( + table["databaseId"], + table["$id"], + (table.columns || []) + .filter((column: any) => column.side !== "child") + .map((column: any) => column.key), + ); + + if (!result) { + throw new Error(`Column creation timed out.`); + } + + success(`Created ${columns.length} columns`); + }; +} diff --git a/lib/commands/utils/change-approval.ts b/lib/commands/utils/change-approval.ts new file mode 100644 index 00000000..cda6d1e6 --- /dev/null +++ b/lib/commands/utils/change-approval.ts @@ -0,0 +1,186 @@ +import chalk from "chalk"; +import inquirer from "inquirer"; +import { cliConfig, success, warn, log, drawTable } from "../../parser.js"; +import { whitelistKeys } from "../../config.js"; +import { + questionPushChanges, + questionPushChangesConfirmation, +} from "../../questions.js"; + +/** + * Check if a value is considered empty + */ +export const isEmpty = (value: any): boolean => + value === null || + value === undefined || + (typeof value === "string" && value.trim().length === 0) || + (Array.isArray(value) && value.length === 0); + +/** + * Prompt user for confirmation to proceed with push + */ +export const getConfirmation = async (): Promise => { + if (cliConfig.force) { + return true; + } + + async function fixConfirmation(): Promise { + const answers = await inquirer.prompt(questionPushChangesConfirmation); + if (answers.changes !== "YES" && answers.changes !== "NO") { + return await fixConfirmation(); + } + + return answers.changes; + } + + let answers = await inquirer.prompt(questionPushChanges); + + if (answers.changes !== "YES" && answers.changes !== "NO") { + answers.changes = await fixConfirmation(); + } + + if (answers.changes === "YES") { + return true; + } + + warn("Skipping push action. Changes were not applied."); + return false; +}; + +/** + * Compare two objects and return their differences + */ +interface ObjectChange { + group: string; + setting: string; + remote: string; + local: string; +} + +type ComparableValue = boolean | number | string | any[] | undefined; + +export const getObjectChanges = >( + remote: T, + local: T, + index: keyof T, + what: string, +): ObjectChange[] => { + const changes: ObjectChange[] = []; + + const remoteNested = remote[index]; + const localNested = local[index]; + + if ( + remoteNested && + localNested && + typeof remoteNested === "object" && + !Array.isArray(remoteNested) && + typeof localNested === "object" && + !Array.isArray(localNested) + ) { + const remoteObj = remoteNested as Record; + const localObj = localNested as Record; + + for (const [service, status] of Object.entries(remoteObj)) { + const localValue = localObj[service]; + let valuesEqual = false; + + if (Array.isArray(status) && Array.isArray(localValue)) { + valuesEqual = JSON.stringify(status) === JSON.stringify(localValue); + } else { + valuesEqual = status === localValue; + } + + if (!valuesEqual) { + changes.push({ + group: what, + setting: service, + remote: chalk.red(String(status ?? "")), + local: chalk.green(String(localValue ?? "")), + }); + } + } + } + + return changes; +}; + +/** + * Approve changes before pushing resources + * Compares local resources with remote resources and prompts user for confirmation + */ +export const approveChanges = async ( + resource: any[], + resourceGetFunction: Function, + keys: Set, + resourceName: string, + resourcePlural: string, + skipKeys: string[] = [], + secondId: string = "", + secondResourceName: string = "", +): Promise => { + log("Checking for changes ..."); + const changes: any[] = []; + + await Promise.all( + resource.map(async (localResource) => { + try { + const options: Record = { + [resourceName]: localResource["$id"], + }; + + if (secondId !== "" && secondResourceName !== "") { + options[secondResourceName] = localResource[secondId]; + } + + const remoteResource = await resourceGetFunction(options); + + for (let [key, value] of Object.entries( + whitelistKeys(remoteResource, keys), + )) { + if (skipKeys.includes(key)) { + continue; + } + + if (isEmpty(value) && isEmpty(localResource[key])) { + continue; + } + + if (Array.isArray(value) && Array.isArray(localResource[key])) { + if (JSON.stringify(value) !== JSON.stringify(localResource[key])) { + changes.push({ + id: localResource["$id"], + key, + remote: chalk.red((value as string[]).join("\n")), + local: chalk.green(localResource[key].join("\n")), + }); + } + } else if (value !== localResource[key]) { + changes.push({ + id: localResource["$id"], + key, + remote: chalk.red(value), + local: chalk.green(localResource[key]), + }); + } + } + } catch (e: any) { + if (Number(e.code) !== 404) { + throw e; + } + } + }), + ); + + if (changes.length === 0) { + return true; + } + + drawTable(changes); + if ((await getConfirmation()) === true) { + return true; + } + + success(`Successfully pushed 0 ${resourcePlural}.`); + return false; +}; diff --git a/lib/commands/utils/database-sync.ts b/lib/commands/utils/database-sync.ts new file mode 100644 index 00000000..008e9ae1 --- /dev/null +++ b/lib/commands/utils/database-sync.ts @@ -0,0 +1,180 @@ +import chalk from "chalk"; +import { localConfig } from "../../config.js"; +import { log, success, error, drawTable } from "../../parser.js"; +import { paginate } from "../../paginate.js"; +import { getTablesDBService } from "../../services.js"; +import { getConfirmation } from "./change-approval.js"; + +export interface TablesDBChangesResult { + applied: boolean; + resyncNeeded: boolean; +} + +/** + * Check for and apply changes to tablesDB (databases) + * Handles creation, update, and deletion of databases + */ +export const checkAndApplyTablesDBChanges = + async (): Promise => { + log("Checking for tablesDB changes ..."); + + const localTablesDBs = localConfig.getTablesDBs(); + const { databases: remoteTablesDBs } = await paginate( + async (args: any) => { + const tablesDBService = await getTablesDBService(); + return await tablesDBService.list(args.queries || []); + }, + {}, + 100, + "databases", + ); + + if (localTablesDBs.length === 0 && remoteTablesDBs.length === 0) { + return { applied: false, resyncNeeded: false }; + } + + const changes: any[] = []; + const toCreate: any[] = []; + const toUpdate: any[] = []; + const toDelete: any[] = []; + + // Check for deletions - remote DBs that aren't in local config + for (const remoteDB of remoteTablesDBs) { + const localDB = localTablesDBs.find((db: any) => db.$id === remoteDB.$id); + if (!localDB) { + toDelete.push(remoteDB); + changes.push({ + id: remoteDB.$id, + action: chalk.red("deleting"), + key: "Database", + remote: remoteDB.name, + local: "(deleted locally)", + }); + } + } + + // Check for additions and updates + for (const localDB of localTablesDBs) { + const remoteDB = remoteTablesDBs.find( + (db: any) => db.$id === localDB.$id, + ); + + if (!remoteDB) { + toCreate.push(localDB); + changes.push({ + id: localDB.$id, + action: chalk.green("creating"), + key: "Database", + remote: "(does not exist)", + local: localDB.name, + }); + } else { + let hasChanges = false; + + if (remoteDB.name !== localDB.name) { + hasChanges = true; + changes.push({ + id: localDB.$id, + action: chalk.yellow("updating"), + key: "Name", + remote: remoteDB.name, + local: localDB.name, + }); + } + + if (remoteDB.enabled !== localDB.enabled) { + hasChanges = true; + changes.push({ + id: localDB.$id, + action: chalk.yellow("updating"), + key: "Enabled", + remote: remoteDB.enabled, + local: localDB.enabled, + }); + } + + if (hasChanges) { + toUpdate.push(localDB); + } + } + } + + if (changes.length === 0) { + return { applied: false, resyncNeeded: false }; + } + + log("Found changes in tablesDB resource:"); + drawTable(changes); + + if (toDelete.length > 0) { + console.log( + `${chalk.red("------------------------------------------------------------------")}`, + ); + console.log( + `${chalk.red("| WARNING: Database deletion will also delete all related tables |")}`, + ); + console.log( + `${chalk.red("------------------------------------------------------------------")}`, + ); + console.log(); + } + + if ((await getConfirmation()) !== true) { + return { applied: false, resyncNeeded: false }; + } + + // Apply deletions first + let needsResync = false; + for (const db of toDelete) { + try { + log(`Deleting database ${db.name} ( ${db.$id} ) ...`); + const tablesDBService = await getTablesDBService(); + await tablesDBService.delete(db.$id); + success(`Deleted ${db.name} ( ${db.$id} )`); + needsResync = true; + } catch (e: any) { + error( + `Failed to delete database ${db.name} ( ${db.$id} ): ${e.message}`, + ); + throw new Error( + `Database sync failed during deletion of ${db.$id}. Some changes may have been applied.`, + ); + } + } + + // Apply creations + for (const db of toCreate) { + try { + log(`Creating database ${db.name} ( ${db.$id} ) ...`); + const tablesDBService = await getTablesDBService(); + await tablesDBService.create(db.$id, db.name, db.enabled); + success(`Created ${db.name} ( ${db.$id} )`); + } catch (e: any) { + error( + `Failed to create database ${db.name} ( ${db.$id} ): ${e.message}`, + ); + throw new Error( + `Database sync failed during creation of ${db.$id}. Some changes may have been applied.`, + ); + } + } + + // Apply updates + for (const db of toUpdate) { + try { + log(`Updating database ${db.name} ( ${db.$id} ) ...`); + const tablesDBService = await getTablesDBService(); + await tablesDBService.update(db.$id, db.name, db.enabled); + success(`Updated ${db.name} ( ${db.$id} )`); + } catch (e: any) { + error( + `Failed to update database ${db.name} ( ${db.$id} ): ${e.message}`, + ); + throw new Error( + `Database sync failed during update of ${db.$id}. Some changes may have been applied.`, + ); + } + } + + return { applied: true, resyncNeeded: needsResync }; + }; diff --git a/lib/commands/utils/deployment.ts b/lib/commands/utils/deployment.ts new file mode 100644 index 00000000..fff7161b --- /dev/null +++ b/lib/commands/utils/deployment.ts @@ -0,0 +1,181 @@ +import fs from "fs"; +import path from "path"; +import tar from "tar"; +import { Client, AppwriteException } from "@appwrite.io/console"; +import { error } from "../../parser.js"; + +const POLL_DEBOUNCE = 2000; // Milliseconds + +/** + * Package a directory into a tar.gz File object for deployment + * @private - Only used internally by pushDeployment + */ +async function packageDirectory(dirPath: string): Promise { + const tempFile = `${dirPath.replace(/[^a-zA-Z0-9]/g, "_")}-${Date.now()}.tar.gz`; + + await tar.create( + { + gzip: true, + file: tempFile, + cwd: dirPath, + }, + ["."], + ); + + const buffer = fs.readFileSync(tempFile); + fs.unlinkSync(tempFile); + + return new File([buffer], path.basename(tempFile), { + type: "application/gzip", + }); +} + +/** + * Download and extract deployment code for a resource + */ +export async function downloadDeploymentCode(params: { + resourceId: string; + resourcePath: string; + holdingVars: { key: string; value: string }[]; + withVariables?: boolean; + listDeployments: () => Promise; + getDownloadUrl: (deploymentId: string) => string; + projectClient: Client; +}): Promise { + const { + resourceId, + resourcePath, + holdingVars, + withVariables, + listDeployments, + getDownloadUrl, + projectClient, + } = params; + + let deploymentId: string | null = null; + try { + const deployments = await listDeployments(); + if (deployments["total"] > 0) { + deploymentId = deployments["deployments"][0]["$id"]; + } + } catch (e: unknown) { + if (e instanceof AppwriteException) { + error(e.message); + return; + } else { + throw e; + } + } + + if (deploymentId === null) { + return; + } + + const compressedFileName = `${resourceId}-${+new Date()}.tar.gz`; + const downloadUrl = getDownloadUrl(deploymentId); + + const downloadBuffer = await projectClient.call( + "get", + new URL(downloadUrl), + {}, + {}, + "arrayBuffer", + ); + + try { + fs.writeFileSync(compressedFileName, Buffer.from(downloadBuffer as any)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to write deployment archive to "${compressedFileName}": ${message}`, + ); + } + + tar.extract({ + sync: true, + cwd: resourcePath, + file: compressedFileName, + strict: false, + }); + + fs.rmSync(compressedFileName); + + if (withVariables) { + const envFileLocation = `${resourcePath}/.env`; + try { + fs.rmSync(envFileLocation); + } catch {} + + fs.writeFileSync( + envFileLocation, + holdingVars.map((r) => `${r.key}=${r.value}\n`).join(""), + ); + } +} + +export interface PushDeploymentParams { + resourcePath: string; + createDeployment: (codeFile: File) => Promise; + getDeployment?: (deploymentId: string) => Promise; + pollForStatus?: boolean; + onStatusUpdate?: (status: string) => void; +} + +export interface PushDeploymentResult { + deployment: any; + wasPolled: boolean; + finalStatus?: string; +} + +/** + * Push a deployment for a resource (function or site) + * Handles packaging, creating the deployment, and optionally polling for status + */ +export async function pushDeployment( + params: PushDeploymentParams, +): Promise { + const { + resourcePath, + createDeployment, + getDeployment, + pollForStatus = false, + onStatusUpdate, + } = params; + + // Package the directory + const codeFile = await packageDirectory(resourcePath); + + // Create the deployment + let deployment = await createDeployment(codeFile); + + // Poll for deployment status if requested + let finalStatus: string | undefined; + let wasPolled = false; + + if (pollForStatus && getDeployment) { + wasPolled = true; + const deploymentId = deployment["$id"]; + + while (true) { + deployment = await getDeployment(deploymentId); + const status = deployment["status"]; + + if (onStatusUpdate) { + onStatusUpdate(status); + } + + if (status === "ready" || status === "failed") { + finalStatus = status; + break; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_DEBOUNCE * 1.5)); + } + } + + return { + deployment, + wasPolled, + finalStatus, + }; +} diff --git a/lib/commands/utils/error-formatter.ts b/lib/commands/utils/error-formatter.ts new file mode 100644 index 00000000..fae21ec4 --- /dev/null +++ b/lib/commands/utils/error-formatter.ts @@ -0,0 +1,417 @@ +import { ZodError, ZodIssue, z } from "zod"; +import type { + $ZodIssueInvalidType, + $ZodIssueTooBig, + $ZodIssueTooSmall, + $ZodIssueUnrecognizedKeys, + $ZodIssueNotMultipleOf, + $ZodIssueInvalidStringFormat, +} from "zod/v4/core"; + +/** + * Formats a Zod validation error into a human-readable message + */ +export class ZodErrorFormatter { + static formatError(error: ZodError, contextData?: any): string { + const issues = error.issues; + + if (issues.length === 1) { + return this.formatIssue(issues[0], contextData); + } + + const messages = issues.map( + (issue) => `• ${this.formatIssue(issue, contextData)}`, + ); + return `Found ${issues.length} validation errors:\n\n${messages.join("\n\n")}`; + } + + private static formatIssue(issue: ZodIssue, contextData?: any): string { + const location = this.formatPath(issue.path, contextData); + const locationText = location ? ` at ${location}` : ""; + + switch (issue.code) { + case "unrecognized_keys": { + const unrecognizedIssue = issue as $ZodIssueUnrecognizedKeys; + const keys = unrecognizedIssue.keys.map((key) => `"${key}"`).join(", "); + const propertyWord = + unrecognizedIssue.keys.length === 1 ? "property" : "properties"; + return `Unexpected ${propertyWord} ${keys}${locationText}`; + } + + case "invalid_type": { + const invalidTypeIssue = issue as $ZodIssueInvalidType; + return `Expected ${invalidTypeIssue.expected}, got ${this.formatValue(invalidTypeIssue.input)}${locationText}`; + } + + case "too_small": { + const tooSmallIssue = issue as $ZodIssueTooSmall; + const minimum = tooSmallIssue.minimum; + const origin = tooSmallIssue.origin; + + if (origin === "array") { + const itemWord = minimum === 1 ? "item" : "items"; + return `Array${locationText} must have at least ${minimum} ${itemWord}`; + } else if (origin === "string") { + const charWord = minimum === 1 ? "character" : "characters"; + return `String${locationText} must be at least ${minimum} ${charWord}`; + } else if (origin === "number") { + return `Number${locationText} must be at least ${minimum}`; + } + return `Value${locationText} must be at least ${minimum}`; + } + + case "too_big": { + const tooBigIssue = issue as $ZodIssueTooBig; + const maximum = tooBigIssue.maximum; + const origin = tooBigIssue.origin; + + if (origin === "array") { + const itemWord = maximum === 1 ? "item" : "items"; + return `Array${locationText} must have at most ${maximum} ${itemWord}`; + } else if (origin === "string") { + const charWord = maximum === 1 ? "character" : "characters"; + return `String${locationText} must be at most ${maximum} ${charWord}`; + } else if (origin === "number") { + return `Number${locationText} must be at most ${maximum}`; + } + return `Value${locationText} must be at most ${maximum}`; + } + + case "invalid_format": { + const formatIssue = issue as $ZodIssueInvalidStringFormat; + return `Invalid ${formatIssue.format} format${locationText}`; + } + + case "invalid_union": { + // Check if this is an enum validation error by examining the issue context + const unionIssue = issue as any; + if (unionIssue.unionErrors && unionIssue.unionErrors.length > 0) { + // Look for enum-like validation errors + const enumError = unionIssue.unionErrors.find( + (err: any) => + err.issues && + err.issues.some( + (subIssue: any) => + subIssue.code === "invalid_literal" || + subIssue.code === "invalid_enum_value", + ), + ); + + if (enumError) { + // Extract allowed values from the enum error + const enumIssues = enumError.issues.filter( + (subIssue: any) => + subIssue.code === "invalid_literal" || + subIssue.code === "invalid_enum_value", + ); + + if (enumIssues.length > 0) { + // Try to extract the allowed values + const allowedValues = this.extractAllowedEnumValues( + unionIssue.unionErrors, + ); + if (allowedValues.length > 0) { + const valuesList = allowedValues + .map((v) => `"${v}"`) + .join(", "); + return `Invalid value${locationText}. Expected one of: ${valuesList}`; + } + } + } + } + return `Invalid value${locationText}. None of the allowed types matched`; + } + + case "custom": + return `${issue.message || "Custom validation failed"}${locationText}`; + + case "not_multiple_of": { + const multipleOfIssue = issue as $ZodIssueNotMultipleOf; + return `Number${locationText} must be a multiple of ${multipleOfIssue.divisor}`; + } + + case "invalid_key": + return `Invalid key${locationText}`; + + case "invalid_element": + return `Invalid element${locationText}`; + + case "invalid_value": { + const invalidValueIssue = issue as any; + if ( + invalidValueIssue.values && + Array.isArray(invalidValueIssue.values) + ) { + const allowedValues = invalidValueIssue.values + .map((v: any) => `"${v}"`) + .join(", "); + return `Invalid value${locationText}. Expected one of: ${allowedValues}`; + } + return `Invalid value${locationText}`; + } + + default: + return `${(issue as ZodIssue).message || "Validation failed"}${locationText}`; + } + } + + private static formatValue(value: unknown): string { + if (value === null) { + return "null"; + } + if (value === undefined) { + return "undefined"; + } + if (typeof value === "string") { + return `"${value}"`; + } + return String(value); + } + + private static formatPath(path: PropertyKey[], contextData?: any): string { + if (path.length === 0) return ""; + + const formatted: string[] = []; + + for (let i = 0; i < path.length; i++) { + const segment = path[i]; + + if (typeof segment === "number") { + formatted.push(`[${segment}]`); + } else if (typeof segment === "string") { + if (i === 0) { + formatted.push(segment); + } else { + formatted.push(`.${segment}`); + } + } else { + // Handle symbol keys by converting to string + formatted.push(`.${String(segment)}`); + } + } + + return this.humanizePath(formatted.join(""), contextData); + } + + private static humanizePath(path: string, contextData?: any): string { + // Try to resolve names from context data first + if (contextData) { + const resolvedPath = this.resolvePathWithNames(path, contextData); + if (resolvedPath !== path) { + return resolvedPath; + } + } + + const patterns = [ + { + pattern: /^collections\[(\d+)\]\.attributes\[(\d+)\]$/, + replacement: "Collections $1 → attributes $2", + }, + { + pattern: /^collections\[(\d+)\]\.indexes\[(\d+)\]$/, + replacement: "Collections $1 → indexes $2", + }, + { + pattern: /^collections\[(\d+)\]$/, + replacement: "Collections $1", + }, + { pattern: /^databases\[(\d+)\]$/, replacement: "Databases $1" }, + { pattern: /^functions\[(\d+)\]$/, replacement: "Functions $1" }, + { pattern: /^sites\[(\d+)\]$/, replacement: "Sites $1" }, + { pattern: /^buckets\[(\d+)\]$/, replacement: "Buckets $1" }, + { pattern: /^teams\[(\d+)\]$/, replacement: "Teams $1" }, + { pattern: /^topics\[(\d+)\]$/, replacement: "Topics $1" }, + { + pattern: /^settings\.auth\.methods$/, + replacement: "auth.methods", + }, + { + pattern: /^settings\.auth\.security$/, + replacement: "auth.security", + }, + { pattern: /^settings\.services$/, replacement: "services" }, + ]; + + for (const { pattern, replacement } of patterns) { + if (pattern.test(path)) { + return path.replace(pattern, replacement); + } + } + + return path + .replace(/\[(\d+)\]/g, " $1") + .replace(/\./g, " → ") + .replace(/^(\w)/, (match) => match.toUpperCase()); + } + + private static resolvePathWithNames(path: string, contextData: any): string { + // Handle collections and their attributes/indexes + const collectionAttributeMatch = path.match( + /^collections\[(\d+)\]\.attributes\[(\d+)\](.*)$/, + ); + if (collectionAttributeMatch) { + const [, collectionIndex, attributeIndex, remainder] = + collectionAttributeMatch; + const collection = contextData.collections?.[parseInt(collectionIndex)]; + const attribute = collection?.attributes?.[parseInt(attributeIndex)]; + + if (collection && attribute) { + const collectionName = collection.name || collection.$id; + const attributeName = attribute.key; + return `Collections "${collectionName}" → attributes "${attributeName}"${remainder}`; + } + } + + const collectionIndexMatch = path.match( + /^collections\[(\d+)\]\.indexes\[(\d+)\](.*)$/, + ); + if (collectionIndexMatch) { + const [, collectionIndex, indexIndex, remainder] = collectionIndexMatch; + const collection = contextData.collections?.[parseInt(collectionIndex)]; + const index = collection?.indexes?.[parseInt(indexIndex)]; + + if (collection && index) { + const collectionName = collection.name || collection.$id; + const indexName = index.key; + return `Collections "${collectionName}" → indexes "${indexName}"${remainder}`; + } + } + + const collectionMatch = path.match(/^collections\[(\d+)\](.*)$/); + if (collectionMatch) { + const [, collectionIndex, remainder] = collectionMatch; + const collection = contextData.collections?.[parseInt(collectionIndex)]; + + if (collection) { + const collectionName = collection.name || collection.$id; + return `Collections "${collectionName}"${remainder}`; + } + } + + // Handle databases + const databaseMatch = path.match(/^databases\[(\d+)\](.*)$/); + if (databaseMatch) { + const [, databaseIndex, remainder] = databaseMatch; + const database = contextData.databases?.[parseInt(databaseIndex)]; + + if (database) { + const databaseName = database.name || database.$id; + return `Databases "${databaseName}"${remainder}`; + } + } + + // Handle functions + const functionMatch = path.match(/^functions\[(\d+)\](.*)$/); + if (functionMatch) { + const [, functionIndex, remainder] = functionMatch; + const func = contextData.functions?.[parseInt(functionIndex)]; + + if (func) { + const functionName = func.name || func.$id; + return `Functions "${functionName}"${remainder}`; + } + } + + // Handle sites + const siteMatch = path.match(/^sites\[(\d+)\](.*)$/); + if (siteMatch) { + const [, siteIndex, remainder] = siteMatch; + const site = contextData.sites?.[parseInt(siteIndex)]; + + if (site) { + const siteName = site.name || site.$id; + return `Sites "${siteName}"${remainder}`; + } + } + + // Handle buckets + const bucketMatch = path.match(/^buckets\[(\d+)\](.*)$/); + if (bucketMatch) { + const [, bucketIndex, remainder] = bucketMatch; + const bucket = contextData.buckets?.[parseInt(bucketIndex)]; + + if (bucket) { + const bucketName = bucket.name || bucket.$id; + return `Buckets "${bucketName}"${remainder}`; + } + } + + // Handle teams + const teamMatch = path.match(/^teams\[(\d+)\](.*)$/); + if (teamMatch) { + const [, teamIndex, remainder] = teamMatch; + const team = contextData.teams?.[parseInt(teamIndex)]; + + if (team) { + const teamName = team.name || team.$id; + return `Teams "${teamName}"${remainder}`; + } + } + + // Handle topics + const topicMatch = path.match(/^topics\[(\d+)\](.*)$/); + if (topicMatch) { + const [, topicIndex, remainder] = topicMatch; + const topic = contextData.topics?.[parseInt(topicIndex)]; + + if (topic) { + const topicName = topic.name || topic.$id; + return `Topics "${topicName}"${remainder}`; + } + } + + return path; + } + + private static extractAllowedEnumValues(unionErrors: any[]): string[] { + const allowedValues = new Set(); + + for (const error of unionErrors) { + if (error.issues) { + for (const issue of error.issues) { + if ( + issue.code === "invalid_literal" && + issue.expected !== undefined + ) { + allowedValues.add(String(issue.expected)); + } else if (issue.code === "invalid_enum_value" && issue.options) { + issue.options.forEach((option: any) => + allowedValues.add(String(option)), + ); + } + } + } + } + + return Array.from(allowedValues).sort(); + } +} + +/** + * Helper function to wrap Zod parse calls with better error formatting + * This function outputs the error directly to console and exits the process + */ +export function parseWithBetterErrors( + schema: z.ZodTypeAny, + data: unknown, + context?: string, + contextData?: any, +): T { + try { + return schema.parse(data) as T; + } catch (error) { + if (error instanceof ZodError) { + const formattedMessage = ZodErrorFormatter.formatError( + error, + contextData, + ); + const errorMessage = context + ? `āŒ ${context}: ${formattedMessage}` + : `āŒ ${formattedMessage}`; + + console.error(errorMessage); + process.exit(1); + } + throw error; + } +} diff --git a/lib/commands/utils/pools.ts b/lib/commands/utils/pools.ts new file mode 100644 index 00000000..1866da44 --- /dev/null +++ b/lib/commands/utils/pools.ts @@ -0,0 +1,354 @@ +import { getDatabasesService } from "../../services.js"; +import { paginate } from "../../paginate.js"; +import { log } from "../../parser.js"; + +export class Pools { + private STEP_SIZE = 100; // Resources + private POLL_DEBOUNCE = 2000; // Milliseconds + private pollMaxDebounces = 30; + private POLL_DEFAULT_VALUE = 30; + + constructor(pollMaxDebounces?: number) { + if (pollMaxDebounces) { + this.pollMaxDebounces = pollMaxDebounces; + } + } + + public setPollMaxDebounces(value: number): void { + this.pollMaxDebounces = value; + } + + public getPollMaxDebounces(): number { + return this.pollMaxDebounces; + } + + public wipeAttributes = async ( + databaseId: string, + collectionId: string, + iteration: number = 1, + ): Promise => { + if (iteration > this.pollMaxDebounces) { + return false; + } + + const databasesService = await getDatabasesService(); + const response = await databasesService.listAttributes( + databaseId, + collectionId, + [JSON.stringify({ method: "limit", values: [1] })], + ); + const { total } = response; + + if (total === 0) { + return true; + } + + if (this.pollMaxDebounces === this.POLL_DEFAULT_VALUE) { + let steps = Math.max(1, Math.ceil(total / this.STEP_SIZE)); + if (steps > 1 && iteration === 1) { + this.pollMaxDebounces *= steps; + + log( + "Found a large number of attributes, increasing timeout to " + + (this.pollMaxDebounces * this.POLL_DEBOUNCE) / 1000 / 60 + + " minutes", + ); + } + } + + await new Promise((resolve) => setTimeout(resolve, this.POLL_DEBOUNCE)); + + return await this.wipeAttributes(databaseId, collectionId, iteration + 1); + }; + + public wipeIndexes = async ( + databaseId: string, + collectionId: string, + iteration: number = 1, + ): Promise => { + if (iteration > this.pollMaxDebounces) { + return false; + } + + const databasesService = await getDatabasesService(); + const response = await databasesService.listIndexes( + databaseId, + collectionId, + [JSON.stringify({ method: "limit", values: [1] })], + ); + const { total } = response; + + if (total === 0) { + return true; + } + + if (this.pollMaxDebounces === this.POLL_DEFAULT_VALUE) { + let steps = Math.max(1, Math.ceil(total / this.STEP_SIZE)); + if (steps > 1 && iteration === 1) { + this.pollMaxDebounces *= steps; + + log( + "Found a large number of indexes, increasing timeout to " + + (this.pollMaxDebounces * this.POLL_DEBOUNCE) / 1000 / 60 + + " minutes", + ); + } + } + + await new Promise((resolve) => setTimeout(resolve, this.POLL_DEBOUNCE)); + + return await this.wipeIndexes(databaseId, collectionId, iteration + 1); + }; + + public waitForAttributeDeletion = async ( + databaseId: string, + collectionId: string, + attributeKeys: any[], + iteration: number = 1, + ): Promise => { + if (iteration > this.pollMaxDebounces) { + return false; + } + + if (this.pollMaxDebounces === this.POLL_DEFAULT_VALUE) { + let steps = Math.max(1, Math.ceil(attributeKeys.length / this.STEP_SIZE)); + if (steps > 1 && iteration === 1) { + this.pollMaxDebounces *= steps; + + log( + "Found a large number of attributes to be deleted. Increasing timeout to " + + (this.pollMaxDebounces * this.POLL_DEBOUNCE) / 1000 / 60 + + " minutes", + ); + } + } + + const { attributes } = await paginate( + async (args: any) => { + const databasesService = await getDatabasesService(); + return await databasesService.listAttributes({ + databaseId: args.databaseId, + collectionId: args.collectionId, + queries: args.queries || [], + }); + }, + { + databaseId, + collectionId, + }, + 100, + "attributes", + ); + + const ready = attributeKeys.filter((attribute: any) => + attributes.includes(attribute.key), + ); + + if (ready.length === 0) { + return true; + } + + await new Promise((resolve) => setTimeout(resolve, this.POLL_DEBOUNCE)); + + return await this.expectAttributes( + databaseId, + collectionId, + attributeKeys, + iteration + 1, + ); + }; + + public expectAttributes = async ( + databaseId: string, + collectionId: string, + attributeKeys: string[], + iteration: number = 1, + ): Promise => { + if (iteration > this.pollMaxDebounces) { + return false; + } + + if (this.pollMaxDebounces === this.POLL_DEFAULT_VALUE) { + let steps = Math.max(1, Math.ceil(attributeKeys.length / this.STEP_SIZE)); + if (steps > 1 && iteration === 1) { + this.pollMaxDebounces *= steps; + + log( + "Creating a large number of attributes, increasing timeout to " + + (this.pollMaxDebounces * this.POLL_DEBOUNCE) / 1000 / 60 + + " minutes", + ); + } + } + + const { attributes } = await paginate( + async (args: any) => { + const databasesService = await getDatabasesService(); + return await databasesService.listAttributes( + args.databaseId, + args.collectionId, + args.queries || [], + ); + }, + { + databaseId, + collectionId, + }, + 100, + "attributes", + ); + + const ready = attributes + .filter((attribute: any) => { + if (attributeKeys.includes(attribute.key)) { + if (["stuck", "failed"].includes(attribute.status)) { + throw new Error(`Attribute '${attribute.key}' failed!`); + } + + return attribute.status === "available"; + } + + return false; + }) + .map((attribute: any) => attribute.key); + + if (ready.length === attributeKeys.length) { + return true; + } + + await new Promise((resolve) => setTimeout(resolve, this.POLL_DEBOUNCE)); + + return await this.expectAttributes( + databaseId, + collectionId, + attributeKeys, + iteration + 1, + ); + }; + + public deleteIndexes = async ( + databaseId: string, + collectionId: string, + indexesKeys: any[], + iteration: number = 1, + ): Promise => { + if (iteration > this.pollMaxDebounces) { + return false; + } + + if (this.pollMaxDebounces === this.POLL_DEFAULT_VALUE) { + let steps = Math.max(1, Math.ceil(indexesKeys.length / this.STEP_SIZE)); + if (steps > 1 && iteration === 1) { + this.pollMaxDebounces *= steps; + + log( + "Found a large number of indexes to be deleted. Increasing timeout to " + + (this.pollMaxDebounces * this.POLL_DEBOUNCE) / 1000 / 60 + + " minutes", + ); + } + } + + const { indexes } = await paginate( + async (args: any) => { + const databasesService = await getDatabasesService(); + return await databasesService.listIndexes( + args.databaseId, + args.collectionId, + args.queries || [], + ); + }, + { + databaseId, + collectionId, + }, + 100, + "indexes", + ); + + const ready = indexesKeys.filter((index: any) => + indexes.includes(index.key), + ); + + if (ready.length === 0) { + return true; + } + + await new Promise((resolve) => setTimeout(resolve, this.POLL_DEBOUNCE)); + + return await this.expectIndexes( + databaseId, + collectionId, + indexesKeys, + iteration + 1, + ); + }; + + public expectIndexes = async ( + databaseId: string, + collectionId: string, + indexKeys: string[], + iteration: number = 1, + ): Promise => { + if (iteration > this.pollMaxDebounces) { + return false; + } + + if (this.pollMaxDebounces === this.POLL_DEFAULT_VALUE) { + let steps = Math.max(1, Math.ceil(indexKeys.length / this.STEP_SIZE)); + if (steps > 1 && iteration === 1) { + this.pollMaxDebounces *= steps; + + log( + "Creating a large number of indexes, increasing timeout to " + + (this.pollMaxDebounces * this.POLL_DEBOUNCE) / 1000 / 60 + + " minutes", + ); + } + } + + const { indexes } = await paginate( + async (args: any) => { + const databasesService = await getDatabasesService(); + return await databasesService.listIndexes( + args.databaseId, + args.collectionId, + args.queries || [], + ); + }, + { + databaseId, + collectionId, + }, + 100, + "indexes", + ); + + const ready = indexes + .filter((index: any) => { + if (indexKeys.includes(index.key)) { + if (["stuck", "failed"].includes(index.status)) { + throw new Error(`Index '${index.key}' failed!`); + } + + return index.status === "available"; + } + + return false; + }) + .map((index: any) => index.key); + + if (ready.length >= indexKeys.length) { + return true; + } + + await new Promise((resolve) => setTimeout(resolve, this.POLL_DEBOUNCE)); + + return await this.expectIndexes( + databaseId, + collectionId, + indexKeys, + iteration + 1, + ); + }; +} diff --git a/lib/config.ts b/lib/config.ts index b2bf54b7..6ebd5a67 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -3,22 +3,25 @@ import fs from "fs"; import _path from "path"; import process from "process"; import JSONbig from "json-bigint"; +import type { Models } from "@appwrite.io/console"; import type { - BucketConfig, - CollectionConfig, + BucketType, + CollectionType, + FunctionType, + ConfigType, + SettingsType, + SiteType, + TableType, + TeamType, + TopicType, +} from "./commands/config.js"; +import type { + SessionData, ConfigData, Entity, - FunctionConfig, GlobalConfigData, - ProjectConfigData, - ProjectSettings, - RawProjectSettings, - SessionData, - SiteConfig, - TableConfig, - TeamConfig, - TopicConfig, } from "./types.js"; +import { createSettingsObject } from "./utils.js"; const JSONBigInt = JSONbig({ storeAsString: false }); @@ -302,7 +305,7 @@ class Config { } } -class Local extends Config { +class Local extends Config { static CONFIG_FILE_PATH = "appwrite.config.json"; static CONFIG_FILE_PATH_LEGACY = "appwrite.json"; configDirectoryPath = ""; @@ -347,21 +350,21 @@ class Local extends Config { } getEndpoint(): string { - return (this.get("endpoint" as keyof ProjectConfigData) as string) || ""; + return this.get("endpoint") || ""; } setEndpoint(endpoint: string): void { - this.set("endpoint" as any, endpoint); + this.set("endpoint", endpoint); } - getSites(): SiteConfig[] { + getSites(): SiteType[] { if (!this.has("sites")) { return []; } return this.get("sites") ?? []; } - getSite($id: string): SiteConfig | Record { + getSite($id: string): SiteType | Record { if (!this.has("sites")) { return {}; } @@ -376,7 +379,7 @@ class Local extends Config { return {}; } - addSite(props: SiteConfig): void { + addSite(props: SiteType): void { props = whitelistKeys(props, KeysSite, { vars: KeysVars, }); @@ -401,14 +404,14 @@ class Local extends Config { this.set("sites", sites); } - getFunctions(): FunctionConfig[] { + getFunctions(): FunctionType[] { if (!this.has("functions")) { return []; } return this.get("functions") ?? []; } - getFunction($id: string): FunctionConfig | Record { + getFunction($id: string): FunctionType | Record { if (!this.has("functions")) { return {}; } @@ -423,7 +426,7 @@ class Local extends Config { return {}; } - addFunction(props: FunctionConfig): void { + addFunction(props: FunctionType): void { props = whitelistKeys(props, KeysFunction, { vars: KeysVars, }); @@ -448,14 +451,14 @@ class Local extends Config { this.set("functions", functions); } - getCollections(): CollectionConfig[] { + getCollections(): CollectionType[] { if (!this.has("collections")) { return []; } return this.get("collections") ?? []; } - getCollection($id: string): CollectionConfig | Record { + getCollection($id: string): CollectionType | Record { if (!this.has("collections")) { return {}; } @@ -470,7 +473,7 @@ class Local extends Config { return {}; } - addCollection(props: CollectionConfig): void { + addCollection(props: CollectionType): void { props = whitelistKeys(props, KeysCollection, { attributes: KeysAttributes, indexes: KeyIndexes, @@ -495,14 +498,14 @@ class Local extends Config { this.set("collections", collections); } - getTables(): TableConfig[] { + getTables(): TableType[] { if (!this.has("tables")) { return []; } return this.get("tables") ?? []; } - getTable($id: string): TableConfig | Record { + getTable($id: string): TableType | Record { if (!this.has("tables")) { return {}; } @@ -517,7 +520,7 @@ class Local extends Config { return {}; } - addTable(props: TableConfig): void { + addTable(props: TableType): void { props = whitelistKeys(props, KeysTable, { columns: KeysColumns, indexes: KeyIndexesColumns, @@ -542,14 +545,14 @@ class Local extends Config { this.set("tables", tables); } - getBuckets(): BucketConfig[] { + getBuckets(): BucketType[] { if (!this.has("buckets")) { return []; } return this.get("buckets") ?? []; } - getBucket($id: string): BucketConfig | Record { + getBucket($id: string): BucketType | Record { if (!this.has("buckets")) { return {}; } @@ -564,7 +567,7 @@ class Local extends Config { return {}; } - addBucket(props: BucketConfig): void { + addBucket(props: BucketType): void { props = whitelistKeys(props, KeysStorage); if (!this.has("buckets")) { @@ -583,14 +586,14 @@ class Local extends Config { this.set("buckets", buckets); } - getMessagingTopics(): TopicConfig[] { + getMessagingTopics(): TopicType[] { if (!this.has("topics")) { return []; } return this.get("topics") ?? []; } - getMessagingTopic($id: string): TopicConfig | Record { + getMessagingTopic($id: string): TopicType | Record { if (!this.has("topics")) { return {}; } @@ -605,7 +608,7 @@ class Local extends Config { return {}; } - addMessagingTopic(props: TopicConfig): void { + addMessagingTopic(props: TopicType): void { props = whitelistKeys(props, KeysTopics); if (!this.has("topics")) { @@ -648,14 +651,14 @@ class Local extends Config { this._addDBEntity("databases", props, KeysDatabase); } - getTeams(): TeamConfig[] { + getTeams(): TeamType[] { if (!this.has("teams")) { return []; } return this.get("teams") ?? []; } - getTeam($id: string): TeamConfig | Record { + getTeam($id: string): TeamType | Record { if (!this.has("teams")) { return {}; } @@ -670,7 +673,7 @@ class Local extends Config { return {}; } - addTeam(props: TeamConfig): void { + addTeam(props: TeamType): void { props = whitelistKeys(props, KeysTeams); if (!this.has("teams")) { this.set("teams", []); @@ -691,7 +694,7 @@ class Local extends Config { getProject(): { projectId?: string; projectName?: string; - projectSettings?: ProjectSettings; + projectSettings?: SettingsType; } { if (!this.has("projectId")) { return {}; @@ -707,7 +710,7 @@ class Local extends Config { setProject( projectId: string, projectName: string = "", - projectSettings?: RawProjectSettings, + project?: Models.Project, ): void { this.set("projectId", projectId); @@ -715,51 +718,11 @@ class Local extends Config { this.set("projectName", projectName); } - if (projectSettings === undefined) { + if (project === undefined) { return; } - this.set("settings", this.createSettingsObject(projectSettings)); - } - - createSettingsObject(projectSettings: RawProjectSettings): ProjectSettings { - return { - services: { - account: projectSettings.serviceStatusForAccount, - avatars: projectSettings.serviceStatusForAvatars, - databases: projectSettings.serviceStatusForDatabases, - locale: projectSettings.serviceStatusForLocale, - health: projectSettings.serviceStatusForHealth, - storage: projectSettings.serviceStatusForStorage, - teams: projectSettings.serviceStatusForTeams, - users: projectSettings.serviceStatusForUsers, - sites: projectSettings.serviceStatusForSites, - functions: projectSettings.serviceStatusForFunctions, - graphql: projectSettings.serviceStatusForGraphql, - messaging: projectSettings.serviceStatusForMessaging, - }, - auth: { - methods: { - jwt: projectSettings.authJWT, - phone: projectSettings.authPhone, - invites: projectSettings.authInvites, - anonymous: projectSettings.authAnonymous, - "email-otp": projectSettings.authEmailOtp, - "magic-url": projectSettings.authUsersAuthMagicURL, - "email-password": projectSettings.authEmailPassword, - }, - security: { - duration: projectSettings.authDuration, - limit: projectSettings.authLimit, - sessionsLimit: projectSettings.authSessionsLimit, - passwordHistory: projectSettings.authPasswordHistory, - passwordDictionary: projectSettings.authPasswordDictionary, - personalDataCheck: projectSettings.authPersonalDataCheck, - sessionAlerts: projectSettings.authSessionAlerts, - mockNumbers: projectSettings.authMockNumbers, - }, - }, - }; + this.set("settings", createSettingsObject(project)); } } diff --git a/lib/emulation/docker.ts b/lib/emulation/docker.ts index cb6cf296..18047ae6 100644 --- a/lib/emulation/docker.ts +++ b/lib/emulation/docker.ts @@ -10,7 +10,7 @@ import fs from "fs"; import { log, error, success } from "../parser.js"; import { openRuntimesVersion, systemTools, Queue } from "./utils.js"; import { getAllFiles } from "../utils.js"; -import type { FunctionConfig } from "../types.js"; +import type { FunctionType } from "../commands/config.js"; export async function dockerStop(id: string): Promise { const stopProcess = childProcess.spawn("docker", ["rm", "--force", id], { @@ -26,7 +26,7 @@ export async function dockerStop(id: string): Promise { }); } -export async function dockerPull(func: FunctionConfig): Promise { +export async function dockerPull(func: FunctionType): Promise { const runtimeChunks = func.runtime.split("-"); const runtimeVersion = runtimeChunks.pop(); const runtimeName = runtimeChunks.join("-"); @@ -48,7 +48,7 @@ export async function dockerPull(func: FunctionConfig): Promise { } export async function dockerBuild( - func: FunctionConfig, + func: FunctionType, variables: Record, ): Promise { const runtimeChunks = func.runtime.split("-"); @@ -182,7 +182,7 @@ export async function dockerBuild( } export async function dockerStart( - func: FunctionConfig, + func: FunctionType, variables: Record, port: number, ): Promise { diff --git a/lib/parser.ts b/lib/parser.ts index 2e4717b0..d7b05bfb 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -128,7 +128,7 @@ export const parseError = (err: Error): void => { // Silently fail } - const version = "13.0.0-rc.2"; + const version = "13.0.0-rc.3"; const stepsToReproduce = `Running \`appwrite ${(cliConfig.reportData as any).data.args.join(" ")}\``; const yourEnvironment = `CLI version: ${version}\nOperation System: ${os.type()}\nAppwrite version: ${appwriteVersion}\nIs Cloud: ${isCloud()}`; diff --git a/lib/sdks.ts b/lib/sdks.ts index acc60223..781aaf70 100644 --- a/lib/sdks.ts +++ b/lib/sdks.ts @@ -20,8 +20,8 @@ export const sdkForConsole = async ( "x-sdk-name": "Command Line", "x-sdk-platform": "console", "x-sdk-language": "cli", - "x-sdk-version": "13.0.0-rc.2", - "user-agent": `AppwriteCLI/13.0.0-rc.2 (${os.type()} ${os.version()}; ${os.arch()})`, + "x-sdk-version": "13.0.0-rc.3", + "user-agent": `AppwriteCLI/13.0.0-rc.3 (${os.type()} ${os.version()}; ${os.arch()})`, }; client @@ -60,8 +60,8 @@ export const sdkForProject = async (): Promise => { "x-sdk-name": "Command Line", "x-sdk-platform": "console", "x-sdk-language": "cli", - "x-sdk-version": "13.0.0-rc.2", - "user-agent": `AppwriteCLI/13.0.0-rc.2 (${os.type()} ${os.version()}; ${os.arch()})`, + "x-sdk-version": "13.0.0-rc.3", + "user-agent": `AppwriteCLI/13.0.0-rc.3 (${os.type()} ${os.version()}; ${os.arch()})`, }; client diff --git a/lib/types.ts b/lib/types.ts index 4ef751a5..928b0ef8 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,6 +1,5 @@ import type { File } from "undici"; import type { ReadableStream } from "node:stream/web"; -import type { Models } from "@appwrite.io/console"; export type ResponseType = "json" | "arraybuffer"; @@ -74,231 +73,3 @@ export interface GlobalConfigData extends ConfigData { current: string; cookie?: string; } - -export interface ProjectSettings { - services?: { - account?: boolean; - avatars?: boolean; - databases?: boolean; - locale?: boolean; - health?: boolean; - storage?: boolean; - teams?: boolean; - users?: boolean; - sites?: boolean; - functions?: boolean; - graphql?: boolean; - messaging?: boolean; - }; - auth?: { - methods?: { - jwt?: boolean; - phone?: boolean; - invites?: boolean; - anonymous?: boolean; - "email-otp"?: boolean; - "magic-url"?: boolean; - "email-password"?: boolean; - }; - security?: { - duration?: number; - limit?: number; - sessionsLimit?: number; - passwordHistory?: number; - passwordDictionary?: boolean; - personalDataCheck?: boolean; - sessionAlerts?: boolean; - mockNumbers?: Models.MockNumber[]; - }; - }; -} - -export interface RawProjectSettings { - serviceStatusForAccount?: boolean; - serviceStatusForAvatars?: boolean; - serviceStatusForDatabases?: boolean; - serviceStatusForLocale?: boolean; - serviceStatusForHealth?: boolean; - serviceStatusForStorage?: boolean; - serviceStatusForTeams?: boolean; - serviceStatusForUsers?: boolean; - serviceStatusForSites?: boolean; - serviceStatusForFunctions?: boolean; - serviceStatusForGraphql?: boolean; - serviceStatusForMessaging?: boolean; - authJWT?: boolean; - authPhone?: boolean; - authInvites?: boolean; - authAnonymous?: boolean; - authEmailOtp?: boolean; - authUsersAuthMagicURL?: boolean; - authEmailPassword?: boolean; - authDuration?: number; - authLimit?: number; - authSessionsLimit?: number; - authPasswordHistory?: number; - authPasswordDictionary?: boolean; - authPersonalDataCheck?: boolean; - authSessionAlerts?: boolean; - authMockNumbers?: Models.MockNumber[]; -} - -export interface DatabaseConfig { - $id: string; - name: string; - enabled?: boolean; -} - -export interface AttributeConfig { - key: string; - type: string; - required?: boolean; - array?: boolean; - size?: number; - default?: unknown; - min?: number; - max?: number; - format?: string; - elements?: string[]; - relatedCollection?: string; - relationType?: string; - twoWay?: boolean; - twoWayKey?: string; - onDelete?: string; - side?: string; - encrypt?: boolean; -} - -export interface IndexConfig { - key: string; - type: string; - status?: string; - attributes?: string[]; - orders?: string[]; -} - -export interface CollectionConfig { - $id: string; - $permissions?: string[]; - databaseId: string; - name: string; - enabled?: boolean; - documentSecurity?: boolean; - attributes?: AttributeConfig[]; - indexes?: IndexConfig[]; -} - -export interface ColumnConfig { - key: string; - type: string; - required?: boolean; - array?: boolean; - size?: number; - default?: unknown; - min?: number; - max?: number; - format?: string; - elements?: string[]; - relatedTable?: string; - relationType?: string; - twoWay?: boolean; - twoWayKey?: string; - onDelete?: string; - side?: string; - encrypt?: boolean; -} - -export interface TableIndexConfig { - key: string; - type: string; - status?: string; - columns?: string[]; - orders?: string[]; -} - -export interface TableConfig { - $id: string; - $permissions?: string[]; - databaseId: string; - name: string; - enabled?: boolean; - rowSecurity?: boolean; - columns?: ColumnConfig[]; - indexes?: TableIndexConfig[]; -} - -export interface BucketConfig { - $id: string; - $permissions?: string[]; - name: string; - enabled?: boolean; - fileSecurity?: boolean; - maximumFileSize?: number; - allowedFileExtensions?: string[]; - compression?: string; - encryption?: boolean; - antivirus?: boolean; -} - -export interface FunctionConfig { - $id: string; - name: string; - runtime: string; - path: string; - entrypoint: string; - execute?: string[]; - enabled?: boolean; - logging?: boolean; - events?: string[]; - schedule?: string; - timeout?: number; - vars?: Record; - commands?: string; - scopes?: string[]; - specification?: string; - ignore?: string; -} - -export interface SiteConfig { - $id: string; - name: string; - path: string; - enabled?: boolean; - logging?: boolean; - timeout?: number; - framework: string; - buildRuntime?: string; - adapter?: string; - installCommand?: string; - buildCommand?: string; - outputDirectory?: string; - fallbackFile?: string; - specification?: string; - vars?: Record; - ignore?: string; -} - -export interface TeamConfig { - $id: string; - name: string; -} - -export interface TopicConfig { - $id: string; - name: string; - subscribe?: string[]; -} - -export interface ProjectConfigData extends ConfigData { - projectId?: string; - projectName?: string; - settings?: ProjectSettings; - functions?: FunctionConfig[]; - collections?: CollectionConfig[]; - databases?: DatabaseConfig[]; - buckets?: BucketConfig[]; - teams?: TeamConfig[]; - topics?: TopicConfig[]; - sites?: SiteConfig[]; - tables?: TableConfig[]; -} diff --git a/lib/utils.ts b/lib/utils.ts index 734c30ef..670e58c1 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,7 +4,49 @@ import net from "net"; import childProcess from "child_process"; import chalk from "chalk"; import { fetch } from "undici"; +import type { Models } from "@appwrite.io/console"; import { localConfig, globalConfig } from "./config.js"; +import type { SettingsType } from "./commands/config.js"; + +export const createSettingsObject = (project: Models.Project): SettingsType => { + return { + services: { + account: project.serviceStatusForAccount, + avatars: project.serviceStatusForAvatars, + databases: project.serviceStatusForDatabases, + locale: project.serviceStatusForLocale, + health: project.serviceStatusForHealth, + storage: project.serviceStatusForStorage, + teams: project.serviceStatusForTeams, + users: project.serviceStatusForUsers, + sites: project.serviceStatusForSites, + functions: project.serviceStatusForFunctions, + graphql: project.serviceStatusForGraphql, + messaging: project.serviceStatusForMessaging, + }, + auth: { + methods: { + jwt: project.authJWT, + phone: project.authPhone, + invites: project.authInvites, + anonymous: project.authAnonymous, + "email-otp": project.authEmailOtp, + "magic-url": project.authUsersAuthMagicURL, + "email-password": project.authEmailPassword, + }, + security: { + duration: project.authDuration, + limit: project.authLimit, + sessionsLimit: project.authSessionsLimit, + passwordHistory: project.authPasswordHistory, + passwordDictionary: project.authPasswordDictionary, + personalDataCheck: project.authPersonalDataCheck, + sessionAlerts: project.authSessionAlerts, + mockNumbers: project.authMockNumbers, + }, + }, + }; +}; /** * Get the latest version from npm registry diff --git a/package.json b/package.json index 5383b646..5fd1c039 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "type": "module", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API", - "version": "13.0.0-rc.2", + "version": "13.0.0-rc.3", "license": "BSD-3-Clause", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { - "appwrite": "dist/index.js" + "appwrite": "dist/cli.js" }, "repository": { "type": "git", @@ -20,12 +20,12 @@ "generate": "tsx scripts/generate-commands.ts", "prepublishOnly": "npm run build", "test": "echo \"Error: no test specified\" && exit 1", - "linux-x64": "bun run build && bun build ./dist/index.js --compile --sourcemap=inline --target=bun-linux-x64 --outfile build/appwrite-cli-linux-x64", - "linux-arm64": "bun run build && bun build ./dist/index.js --compile --sourcemap=inline --target=bun-linux-arm64 --outfile build/appwrite-cli-linux-arm64", - "mac-x64": "bun run build && bun build ./dist/index.js --compile --sourcemap=inline --target=bun-darwin-x64 --outfile build/appwrite-cli-darwin-x64", - "mac-arm64": "bun run build && bun build ./dist/index.js --compile --sourcemap=inline --target=bun-darwin-arm64 --outfile build/appwrite-cli-darwin-arm64", - "windows-x64": "bun run build && bun build ./dist/index.js --compile --sourcemap=inline --target=bun-windows-x64 --outfile build/appwrite-cli-win-x64.exe", - "windows-arm64": "bun run build && esbuild dist/index.js --bundle --platform=node --format=cjs --outfile=dist/bundle.cjs --external:@appwrite.io/console --external:fsevents && pkg dist/bundle.cjs -t node18-win-arm64 -o build/appwrite-cli-win-arm64.exe" + "linux-x64": "bun run build && bun build ./dist/cli.js --compile --sourcemap=inline --target=bun-linux-x64 --outfile build/appwrite-cli-linux-x64", + "linux-arm64": "bun run build && bun build ./dist/cli.js --compile --sourcemap=inline --target=bun-linux-arm64 --outfile build/appwrite-cli-linux-arm64", + "mac-x64": "bun run build && bun build ./dist/cli.js --compile --sourcemap=inline --target=bun-darwin-x64 --outfile build/appwrite-cli-darwin-x64", + "mac-arm64": "bun run build && bun build ./dist/cli.js --compile --sourcemap=inline --target=bun-darwin-arm64 --outfile build/appwrite-cli-darwin-arm64", + "windows-x64": "bun run build && bun build ./dist/cli.js --compile --sourcemap=inline --target=bun-windows-x64 --outfile build/appwrite-cli-win-x64.exe", + "windows-arm64": "bun run build && esbuild dist/cli.js --bundle --platform=node --format=cjs --outfile=dist/bundle.cjs --external:@appwrite.io/console --external:fsevents && pkg dist/bundle.cjs -t node18-win-arm64 -o build/appwrite-cli-win-arm64.exe" }, "dependencies": { "@appwrite.io/console": "^2.1.0", @@ -43,7 +43,8 @@ "json-bigint": "^1.0.0", "tail": "^2.2.6", "tar": "^6.1.11", - "undici": "^5.28.2" + "undici": "^5.28.2", + "zod": "^4.3.5" }, "devDependencies": { "@types/bun": "^1.3.5", @@ -60,7 +61,7 @@ }, "pkg": { "scripts": [ - "dist/index.js", + "dist/cli.js", "dist/lib/**/*.js" ] } diff --git a/scoop/appwrite.config.json b/scoop/appwrite.config.json index f5ae51f8..3f10a64a 100644 --- a/scoop/appwrite.config.json +++ b/scoop/appwrite.config.json @@ -1,16 +1,16 @@ { "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", - "version": "13.0.0-rc.2", + "version": "13.0.0-rc.3", "description": "The Appwrite CLI is a command-line application that allows you to interact with Appwrite and perform server-side tasks using your terminal.", "homepage": "https://github.com/appwrite/sdk-for-cli", "license": "BSD-3-Clause", "architecture": { "64bit": { - "url": "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.2/appwrite-cli-win-x64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.3/appwrite-cli-win-x64.exe", "bin": [["appwrite-cli-win-x64.exe", "appwrite"]] }, "arm64": { - "url": "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.2/appwrite-cli-win-arm64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/13.0.0-rc.3/appwrite-cli-win-arm64.exe", "bin": [["appwrite-cli-win-arm64.exe", "appwrite"]] } },