diff --git a/composer.lock b/composer.lock index 0ac5c1956..3a21db49a 100644 --- a/composer.lock +++ b/composer.lock @@ -199,7 +199,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -258,7 +258,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -278,19 +278,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -338,7 +339,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -354,11 +355,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -414,7 +415,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" }, "funding": [ { @@ -729,16 +730,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -777,7 +778,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -785,7 +786,7 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-04-29T12:36:36+00:00" }, { "name": "nikic/php-parser", @@ -1288,16 +1289,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.17", + "version": "11.5.21", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fd2e863a2995cdfd864fb514b5e0b28b09895b5c" + "reference": "d565e2cdc21a7db9dc6c399c1fc2083b8010f289" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fd2e863a2995cdfd864fb514b5e0b28b09895b5c", - "reference": "fd2e863a2995cdfd864fb514b5e0b28b09895b5c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d565e2cdc21a7db9dc6c399c1fc2083b8010f289", + "reference": "d565e2cdc21a7db9dc6c399c1fc2083b8010f289", "shasum": "" }, "require": { @@ -1307,7 +1308,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.0", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", @@ -1320,7 +1321,7 @@ "sebastian/code-unit": "^3.0.3", "sebastian/comparator": "^6.3.1", "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.0", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", @@ -1369,7 +1370,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.17" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.21" }, "funding": [ { @@ -1380,12 +1381,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2025-04-08T07:59:11+00:00" + "time": "2025-05-21T12:35:00+00:00" }, { "name": "psr/container", @@ -1817,23 +1826,23 @@ }, { "name": "sebastian/environment", - "version": "7.2.0", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -1869,15 +1878,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", @@ -2368,16 +2389,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.12.2", + "version": "3.13.0", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa" + "reference": "65ff2489553b83b4597e89c3b8b721487011d186" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa", - "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/65ff2489553b83b4597e89c3b8b721487011d186", + "reference": "65ff2489553b83b4597e89c3b8b721487011d186", "shasum": "" }, "require": { @@ -2448,7 +2469,7 @@ "type": "thanks_dev" } ], - "time": "2025-04-13T04:10:18+00:00" + "time": "2025-05-11T03:36:00+00:00" }, { "name": "staabm/side-effects-detector", @@ -2504,16 +2525,16 @@ }, { "name": "symfony/console", - "version": "v7.2.5", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88" + "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88", + "url": "https://api.github.com/repos/symfony/console/zipball/0e2e3f38c192e93e622e41ec37f4ca70cfedf218", + "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218", "shasum": "" }, "require": { @@ -2577,7 +2598,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.5" + "source": "https://github.com/symfony/console/tree/v7.2.6" }, "funding": [ { @@ -2593,11 +2614,11 @@ "type": "tidelift" } ], - "time": "2025-03-12T08:11:12+00:00" + "time": "2025-04-07T19:09:28+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -2655,7 +2676,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -2675,7 +2696,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -2736,7 +2757,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -2900,16 +2921,16 @@ }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931", + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931", "shasum": "" }, "require": { @@ -2967,7 +2988,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.2.6" }, "funding": [ { @@ -2983,7 +3004,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:18:16+00:00" }, { "name": "theseer/tokenizer", @@ -3038,7 +3059,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -3047,6 +3068,6 @@ "ext-mbstring": "*", "ext-json": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/example.php b/example.php index f9845b9a9..27014c19d 100644 --- a/example.php +++ b/example.php @@ -42,7 +42,7 @@ function getSSLPage($url) { $platform = 'console'; // $platform = 'server'; - $spec = getSSLPage("https://raw.githubusercontent.com/appwrite/appwrite/1.6.x/app/config/specs/swagger2-latest-{$platform}.json"); + $spec = getSSLPage("https://raw.githubusercontent.com/appwrite/appwrite/1.7.x/app/config/specs/swagger2-latest-{$platform}.json"); if(empty($spec)) { throw new Exception('Failed to fetch spec from Appwrite server'); diff --git a/templates/cli/lib/commands/init.js.twig b/templates/cli/lib/commands/init.js.twig index d1861d70f..3d435690b 100644 --- a/templates/cli/lib/commands/init.js.twig +++ b/templates/cli/lib/commands/init.js.twig @@ -15,6 +15,7 @@ const { localConfig, globalConfig } = require("../config"); const { questionsCreateFunction, questionsCreateFunctionSelectTemplate, + questionsCreateSite, questionsCreateBucket, questionsCreateMessagingTopic, questionsCreateCollection, @@ -25,11 +26,13 @@ const { } = require("../questions"); const { cliConfig, success, log, hint, error, actionRunner, commandDescriptions } = require("../parser"); const { accountGet } = require("./account"); +const { sitesListTemplates } = require("./sites"); const { sdkForConsole } = require("../sdks"); const initResources = async () => { const actions = { function: initFunction, + site: initSite, collection: initCollection, bucket: initBucket, team: initTeam, @@ -318,6 +321,188 @@ const initFunction = async () => { log("Next you can use 'appwrite run function' to develop a function locally. To deploy the function, use 'appwrite push function'"); } +const initSite = async () => { + process.chdir(localConfig.configDirectoryPath) + + const answers = await inquirer.prompt(questionsCreateSite); + const siteFolder = path.join(process.cwd(), 'sites'); + + if (!fs.existsSync(siteFolder)) { + fs.mkdirSync(siteFolder, { + recursive: true + }); + } + + const siteId = answers.id === 'unique()' ? ID.unique() : answers.id; + const siteName = answers.name; + const siteDir = path.join(siteFolder, siteName); + const templatesDir = path.join(siteFolder, `${siteId}-templates`); + + if (fs.existsSync(siteDir)) { + throw new Error(`( ${siteName} ) already exists in the current directory. Please choose another name.`); + } + + let templateDetails; + try { + const response = await sitesListTemplates({ + frameworks: [answers.framework.key], + useCases: ['starter'], + limit: 1, + parseOutput: false + }); + if (response.total == 0) { + throw new Error(`No starter template found for framework ${answers.framework.key}`); + } + templateDetails = response.templates[0]; + } catch (error) { + throw new Error(`Failed to fetch template for framework ${answers.framework.key}: ${error.message}`); + } + + fs.mkdirSync(siteDir, "777"); + fs.mkdirSync(templatesDir, "777"); + const repo = `https://github.com/${templateDetails.providerOwner}/${templateDetails.providerRepositoryId}`; + let selected = { template: templateDetails.frameworks[0].providerRootDirectory }; + + let gitCloneCommands = ''; + + const sparse = selected.template.startsWith('./') ? selected.template.substring(2) : selected.template; + + log('Fetching site code ...'); + + if(selected.template === './') { + gitCloneCommands = ` + mkdir -p . + cd . + git init + git remote add origin ${repo} + git config --global init.defaultBranch main + git fetch --depth=1 origin refs/tags/$(git ls-remote --tags origin "${templateDetails.providerVersion}" | tail -n 1 | awk -F '/' '{print $3}') + git checkout FETCH_HEAD + `.trim(); + } else { + gitCloneCommands = ` + mkdir -p . + cd . + git init + git remote add origin ${repo} + git config --global init.defaultBranch main + git config core.sparseCheckout true + echo "${sparse}" >> .git/info/sparse-checkout + git config --add remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' + git config remote.origin.tagopt --no-tags + git fetch --depth=1 origin refs/tags/$(git ls-remote --tags origin "${templateDetails.providerVersion}" | tail -n 1 | awk -F '/' '{print $3}') + git checkout FETCH_HEAD + `.trim(); + } + + /* Force use CMD as powershell does not support && */ + if (process.platform === 'win32') { + gitCloneCommands = 'cmd /c "' + gitCloneCommands + '"'; + } + + /* Execute the child process but do not print any std output */ + try { + childProcess.execSync(gitCloneCommands, { stdio: 'pipe', cwd: templatesDir }); + } catch (error) { + /* Specialised errors with recommended actions to take */ + if (error.message.includes('error: unknown option')) { + throw new Error(`${error.message} \n\nSuggestion: Try updating your git to the latest version, then trying to run this command again.`) + } else if (error.message.includes('is not recognized as an internal or external command,') || error.message.includes('command not found')) { + throw new Error(`${error.message} \n\nSuggestion: It appears that git is not installed, try installing git then trying to run this command again.`) + } else { + throw error; + } + } + + fs.rmSync(path.join(templatesDir, ".git"), { recursive: true }); + + const copyRecursiveSync = (src, dest) => { + let exists = fs.existsSync(src); + let stats = exists && fs.statSync(src); + let isDirectory = exists && stats.isDirectory(); + if (isDirectory) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest); + } + + fs.readdirSync(src).forEach(function (childItemName) { + copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName)); + }); + } else { + fs.copyFileSync(src, dest); + } + }; + copyRecursiveSync(selected.template === './' ? templatesDir : path.join(templatesDir, selected.template), siteDir); + + fs.rmSync(templatesDir, { recursive: true, force: true }); + + const readmePath = path.join(process.cwd(), 'sites', siteName, 'README.md'); + const readmeFile = fs.readFileSync(readmePath).toString(); + const newReadmeFile = readmeFile.split('\n'); + newReadmeFile[0] = `# ${answers.key}`; + newReadmeFile.splice(1, 2); + fs.writeFileSync(readmePath, newReadmeFile.join('\n')); + + let vars = (templateDetails.variables ?? []).map(variable => { + let value = variable.value; + const replacements = { + '{apiEndpoint}': globalConfig.getEndpoint(), + '{projectId}': localConfig.getProject().projectId, + '{projectName}': localConfig.getProject().projectName, + }; + + for (const placeholder in replacements) { + if (value?.includes(placeholder)) { + value = value.replace(placeholder, replacements[placeholder]); + } + } + + return { + key: variable.name, + value: value + }; + }); + + let data = { + $id: siteId, + name: answers.name, + framework: answers.framework.key, + adapter: templateDetails.frameworks[0].adapter || '', + buildRuntime: templateDetails.frameworks[0].buildRuntime || '', + installCommand: templateDetails.frameworks[0].installCommand || '', + buildCommand: templateDetails.frameworks[0].buildCommand || '', + outputDirectory: templateDetails.frameworks[0].outputDirectory || '', + fallbackFile: templateDetails.frameworks[0].fallbackFile || '', + specification: answers.specification, + enabled: true, + timeout: 30, + logging: true, + ignore: answers.framework.ignore || null, + path: `sites/${siteName}`, + vars: vars + }; + + if (!data.buildRuntime) { + log(`Build runtime for this framework not found. You will be asked to configure build runtime when you first push the site.`); + } + + if (!data.installCommand) { + log(`Installation command for this framework not found. You will be asked to configure the install command when you first push the site.`); + } + + if (!data.buildCommand) { + log(`Build command for this framework not found. You will be asked to configure the build command when you first push the site.`); + } + + if (!data.outputDirectory) { + log(`Output directory for this framework not found. You will be asked to configure the output directory when you first push the site.`); + } + + localConfig.addSite(data); + success("Initializing site"); + log("Next you can use 'appwrite push site' to deploy the changes."); +}; + const init = new Command("init") .description(commandDescriptions['init']) .action(actionRunner(initResources)); @@ -336,6 +521,12 @@ init .description("Init a new {{ spec.title|caseUcfirst }} function") .action(actionRunner(initFunction)); +init + .command("site") + .alias("sites") + .description("Init a new {{ spec.title|caseUcfirst }} site") + .action(actionRunner(initSite)); + init .command("bucket") .alias("buckets") diff --git a/templates/cli/lib/commands/pull.js.twig b/templates/cli/lib/commands/pull.js.twig index f9b6bf941..89d0beadc 100644 --- a/templates/cli/lib/commands/pull.js.twig +++ b/templates/cli/lib/commands/pull.js.twig @@ -7,17 +7,19 @@ const { messagingListTopics } = require("./messaging"); const { teamsList } = require("./teams"); const { projectsGet } = require("./projects"); const { functionsList, functionsGetDeploymentDownload, functionsListDeployments } = require("./functions"); +const { sitesList, sitesGetDeploymentDownload, sitesListDeployments } = require("./sites"); const { databasesGet, databasesListCollections, databasesList } = require("./databases"); const { storageListBuckets } = require("./storage"); const { localConfig } = require("../config"); const { paginate } = require("../paginate"); -const { questionsPullCollection, questionsPullFunctions, questionsPullFunctionsCode, questionsPullResources } = require("../questions"); +const { questionsPullCollection, questionsPullFunctions, questionsPullFunctionsCode, questionsPullSites, questionsPullSitesCode, questionsPullResources } = require("../questions"); const { cliConfig, success, log, warn, actionRunner, commandDescriptions } = require("../parser"); const pullResources = async () => { const actions = { settings: pullSettings, functions: pullFunctions, + sites: pullSites, collections: pullCollection, buckets: pullBucket, teams: pullTeam, @@ -169,6 +171,119 @@ const pullFunctions = async ({ code, withVariables }) => { success(`Successfully pulled ${chalk.bold(total)} functions.`); } +const pullSites = async ({ code, withVariables }) => { + process.chdir(localConfig.configDirectoryPath) + + log("Fetching sites ..."); + let total = 0; + + const fetchResponse = await sitesList({ + queries: [JSON.stringify({ method: 'limit', values: [1] })], + parseOutput: false + }); + if (fetchResponse["sites"].length <= 0) { + log("No sites found."); + success(`Successfully pulled ${chalk.bold(total)} sites.`); + return; + } + + const sites = cliConfig.all + ? (await paginate(sitesList, { parseOutput: false }, 100, 'sites')).sites + : (await inquirer.prompt(questionsPullSites)).sites; + + let allowCodePull = cliConfig.force === true ? true : null; + + for (let site of sites) { + total++; + log(`Pulling site ${chalk.bold(site['name'])} ...`); + + const localSite = localConfig.getSite(site.$id); + + 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); + + if (!fs.existsSync(site['path'])) { + fs.mkdirSync(site['path'], { recursive: true }); + } + + if (code === false) { + warn("Source code download skipped."); + continue; + } + + if (allowCodePull === null) { + const codeAnswer = await inquirer.prompt(questionsPullSitesCode); + allowCodePull = codeAnswer.override; + } + + if (!allowCodePull) { + continue; + } + + let deploymentId = null; + + try { + const fetchResponse = await sitesListDeployments({ + siteId: site['$id'], + queries: [ + JSON.stringify({ method: 'limit', values: [1] }), + JSON.stringify({ method: 'orderDesc', values: ['$id'] }) + ], + parseOutput: false + }); + + if (fetchResponse['total'] > 0) { + deploymentId = fetchResponse['deployments'][0]['$id']; + } + + } catch { + } + + if (deploymentId === null) { + log("Source code download skipped because site doesn't have any available deployment"); + continue; + } + + log("Pulling latest deployment code ..."); + + const compressedFileName = `${site['$id']}-${+new Date()}.tar.gz` + await sitesGetDeploymentDownload({ + siteId: site['$id'], + deploymentId, + destination: compressedFileName, + overrideForCli: true, + parseOutput: false + }); + + tar.extract({ + sync: true, + cwd: site['path'], + file: compressedFileName, + strict: false, + }); + + fs.rmSync(compressedFileName); + + if (withVariables) { + const envFileLocation = `${site['path']}/.env` + try { + fs.rmSync(envFileLocation); + } catch { + } + + fs.writeFileSync(envFileLocation, holdingVars.map(r => `${r.key}=${r.value}\n`).join('')) + } + } + + success(`Successfully pulled ${chalk.bold(total)} sites.`); +} + const pullCollection = async () => { log("Fetching collections ..."); let total = 0; @@ -321,6 +436,14 @@ pull .option("--with-variables", `Pull function variables. ${chalk.red('recommend for testing purposes only')}`) .action(actionRunner(pullFunctions)) +pull + .command("site") + .alias("sites") + .description("Pull your {{ spec.title|caseUcfirst }} site") + .option("--no-code", "Don't pull the site's code") + .option("--with-variables", `Pull site variables. ${chalk.red('recommend for testing purposes only')}`) + .action(actionRunner(pullSites)) + pull .command("collection") .alias("collections") diff --git a/templates/cli/lib/commands/push.js.twig b/templates/cli/lib/commands/push.js.twig index fd018fc10..f6c0d2d49 100644 --- a/templates/cli/lib/commands/push.js.twig +++ b/templates/cli/lib/commands/push.js.twig @@ -3,15 +3,16 @@ const inquirer = require("inquirer"); const JSONbig = require("json-bigint")({ storeAsString: false }); const { Command } = require("commander"); const ID = require("../id"); -const { localConfig, globalConfig, KeysAttributes, KeysFunction, whitelistKeys, KeysTopics, KeysStorage, KeysTeams, KeysCollection } = require("../config"); +const { localConfig, globalConfig, KeysAttributes, KeysFunction, KeysSite, whitelistKeys, KeysTopics, KeysStorage, KeysTeams, KeysCollection } = require("../config"); const { Spinner, SPINNER_ARC, SPINNER_DOTS } = require('../spinner'); const { paginate } = require('../paginate'); -const { questionsPushBuckets, questionsPushTeams, questionsPushFunctions, questionsGetEntrypoint, questionsPushCollections, questionPushChanges, questionPushChangesConfirmation, questionsPushMessagingTopics, questionsPushResources } = require("../questions"); +const { questionsPushBuckets, questionsPushTeams, questionsPushFunctions, questionsPushSites, questionsGetEntrypoint, questionsPushCollections, questionPushChanges, questionPushChangesConfirmation, questionsPushMessagingTopics, questionsPushResources } = require("../questions"); const { cliConfig, actionRunner, success, warn, log, hint, error, commandDescriptions, drawTable } = require("../parser"); -const { proxyCreateFunctionRule, proxyListRules } = require('./proxy'); +const { proxyCreateFunctionRule, proxyCreateSiteRule, proxyListRules } = require('./proxy'); const { consoleVariables } = require('./console'); const { sdkForConsole } = require('../sdks') const { functionsGet, functionsCreate, functionsUpdate, functionsCreateDeployment, functionsGetDeployment, functionsListVariables, functionsDeleteVariable, functionsCreateVariable } = require('./functions'); +const { sitesGet, sitesCreate, sitesUpdate, sitesCreateDeployment, sitesGetDeployment, sitesCreateVariable } = require('./sites'); const { databasesGet, databasesCreate, @@ -899,6 +900,7 @@ const pushResources = async () => { const actions = { settings: pushSettings, functions: pushFunction, + sites: pushSite, collections: pushCollection, buckets: pushBucket, teams: pushTeam, @@ -1009,6 +1011,303 @@ const pushSettings = async () => { } } +const pushSite = async({ siteId, async, code } = { returnOnZero: false }) => { + process.chdir(localConfig.configDirectoryPath) + + const siteIds = []; + + if(siteId) { + siteIds.push(siteId); + } else if (cliConfig.all) { + checkDeployConditions(localConfig); + const sites = localConfig.getSites(); + siteIds.push(...sites.map((site) => { + return site.$id; + })); + } + + if (siteIds.length <= 0) { + const answers = await inquirer.prompt(questionsPushSites[0]); + if (answers.sites) { + siteIds.push(...answers.sites); + } + } + + if (siteIds.length === 0) { + log("No sites found."); + hint("Use 'appwrite pull sites' to synchronize existing one, or use 'appwrite init site' to create a new one."); + return; + } + + let sites = siteIds.map((id) => { + const sites = localConfig.getSites(); + const site = sites.find((s) => s.$id === id); + + if (!site) { + throw new Error("Site '" + id + "' not found.") + } + + return site; + }); + + log('Validating sites ...'); + // Validation is done BEFORE pushing so the deployment process can be run in async with progress update + for (let site of sites) { + + if (!site.buildCommand) { + log(`Site ${site.name} is missing build command.`); + const answers = await inquirer.prompt(questionsGetBuildCommand) + site.buildCommand = answers.buildCommand; + localConfig.addSite(site); + } + } + + if (!(await approveChanges(sites, sitesGet, KeysSite, 'siteId', 'sites', ['vars']))) { + return; + } + + log('Pushing sites ...'); + + Spinner.start(false); + let successfullyPushed = 0; + let successfullyDeployed = 0; + const failedDeployments = []; + const errors = []; + + await Promise.all(sites.map(async (site) => { + let response = {}; + + const ignore = site.ignore ? 'appwrite.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); + + try { + response = await sitesGet({ + siteId: site['$id'], + parseOutput: false, + }); + 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.json` }) + return; + } + + updaterRow.update({ status: 'Updating' }).replaceSpinner(SPINNER_ARC); + + response = await sitesUpdate({ + siteId: site['$id'], + name: site.name, + framework: site.framework, + buildRuntime: site.buildRuntime, + specification: site.specification, + timeout: site.timeout, + enabled: site.enabled, + logging: site.logging, + adapter: site.adapter, + buildCommand: site.buildCommand, + installCommand: site.installCommand, + outputDirectory: site.outputDirectory, + fallbackFile: site.fallbackFile, + vars: JSON.stringify(response.vars), + parseOutput: false + }); + } catch (e) { + + 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 sitesCreate({ + siteId: site.$id, + name: site.name, + framework: site.framework, + specification: site.specification, + buildRuntime: site.buildRuntime, + buildCommand: site.buildCommand, + installCommand: site.installCommand, + outputDirectory: site.outputDirectory, + fallbackFile: site.fallbackFile, + adapter: site.adapter, + timeout: site.timeout, + enabled: site.enabled, + logging: site.logging, + vars: JSON.stringify(site.vars), + parseOutput: false + }); + + let domain = ''; + try { + const variables = await consoleVariables({ parseOutput: false, sdk: await sdkForConsole() }); + domain = ID.unique() + '.' + variables['_APP_DOMAIN_SITES']; + } catch (error) { + console.error('Error fetching console variables.'); + throw error; + } + + try { + const rule = await proxyCreateSiteRule( + { + domain: domain, + siteId: site.$id + } + ); + } catch (error) { + console.error('Error creating site rule.'); + throw error; + } + + updaterRow.update({ status: 'Created' }); + } catch (e) { + errors.push(e) + updaterRow.fail({ errorMessage: e.message ?? 'General error occurs please try again' }); + return; + } + } + + updaterRow.update({ status: 'Creating variables' }).replaceSpinner(SPINNER_ARC); + + await Promise.all((site['vars'] ?? []).map(async variable => { + await sitesCreateVariable({ + siteId: site['$id'], + key: variable['key'], + value: variable['value'], + parseOutput: false + }); + })); + + if (code === false) { + successfullyPushed++; + successfullyDeployed++; + updaterRow.update({ status: 'Pushed' }); + updaterRow.stopSpinner(); + return; + } + + try { + updaterRow.update({ status: 'Pushing' }).replaceSpinner(SPINNER_ARC); + response = await sitesCreateDeployment({ + siteId: site['$id'], + buildCommand: site.buildCommand, + installCommand: site.installCommand, + outputDirectory: site.outputDirectory, + fallbackFile: site.fallbackFile, + code: site.path, + activate: true, + parseOutput: false + }) + + updaterRow.update({ status: 'Pushed' }); + deploymentCreated = true; + successfullyPushed++; + } catch (e) { + 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 && !async) { + try { + const deploymentId = response['$id']; + updaterRow.update({ status: 'Deploying', end: 'Checking deployment status...' }) + let pollChecks = 0; + + while (true) { + response = await sitesGetDeployment({ + siteId: site['$id'], + deploymentId: deploymentId, + parseOutput: false + }); + + const status = response['status']; + if (status === 'ready') { + successfullyDeployed++; + + let url = ''; + const res = await proxyListRules({ + parseOutput: false, + queries: [ + 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) { + errors.push(e); + updaterRow.fail({ errorMessage: e.message ?? 'Unknown error occurred. Please try again' }) + } + } + + updaterRow.stopSpinner(); + })); + + Spinner.stop(); + + failedDeployments.forEach((failed) => { + const { name, deployment, $id } = failed; + const failUrl = `${globalConfig.getEndpoint().slice(0, -3)}/console/project-${localConfig.getProject().projectId}/sites/site-${$id}/deployments/deployment-${deployment}`; + + error(`Deployment of ${name} has failed. Check at ${failUrl} for more details\n`); + }); + + if (!async) { + if (successfullyPushed === 0) { + error('No sites were pushed.'); + } else if (successfullyDeployed !== successfullyPushed) { + warn(`Successfully pushed ${successfullyDeployed} of ${successfullyPushed} sites`) + } else { + success(`Successfully pushed ${successfullyPushed} sites.`); + } + } else { + success(`Successfully pushed ${successfullyPushed} sites.`); + } + + if (cliConfig.verbose) { + errors.forEach(e => { + console.error(e); + }) + } +} + const pushFunction = async ({ functionId, async, code, withVariables } = { returnOnZero: false }) => { process.chdir(localConfig.configDirectoryPath) @@ -1723,6 +2022,15 @@ push .option("--with-variables", `Push function variables.`) .action(actionRunner(pushFunction)); +push + .command("site") + .alias("sites") + .description("Push sites in the current directory.") + .option(`-f, --site-id `, `ID of site to run`) + .option(`-A, --async`, `Don't wait for sites deployments status`) + .option("--no-code", "Don't push the site's code") + .action(actionRunner(pushSite)); + push .command("collection") .alias("collections") diff --git a/templates/cli/lib/config.js.twig b/templates/cli/lib/config.js.twig index d39677a6d..daeff2342 100644 --- a/templates/cli/lib/config.js.twig +++ b/templates/cli/lib/config.js.twig @@ -5,7 +5,7 @@ const process = require("process"); const JSONbig = require("json-bigint")({ storeAsString: false }); const KeysVars = new Set(["key", "value"]); -const KeysSite = new Set(["path", "$id", "name", "enabled", "logging", "timeout", "framework", "buildRuntime", "adapter", "installCommand", "buildCommand", "outputDirectory", "fallbackFile", "specification"]); +const KeysSite = new Set(["path", "$id", "name", "enabled", "logging", "timeout", "framework", "buildRuntime", "adapter", "installCommand", "buildCommand", "outputDirectory", "fallbackFile", "specification", "vars"]); const KeysFunction = new Set(["path", "$id", "execute", "name", "enabled", "logging", "runtime", "specification", "scopes", "events", "schedule", "timeout", "entrypoint", "commands", "vars"]); const KeysDatabase = new Set(["$id", "name", "enabled"]); const KeysCollection = new Set(["$id", "$permissions", "databaseId", "name", "enabled", "documentSecurity", "attributes", "indexes"]); diff --git a/templates/cli/lib/parser.js.twig b/templates/cli/lib/parser.js.twig index 4dae785a1..bbbdebe9b 100644 --- a/templates/cli/lib/parser.js.twig +++ b/templates/cli/lib/parser.js.twig @@ -211,6 +211,7 @@ const commandDescriptions = { "health": `The health command allows you to both validate and monitor your {{ spec.title|caseUcfirst }} server's health.`, "pull": `The pull command helps you pull your {{ spec.title|caseUcfirst }} project, functions, collections, buckets, teams, and messaging-topics`, "locale": `The locale command allows you to customize your app based on your users' location.`, + "sites": `The sites command allows you to view, create and manage your Appwrite Sites.`, "storage": `The storage command allows you to manage your project files.`, "teams": `The teams command allows you to group users of your project to enable them to share read and write access to your project resources.`, "users": `The users command allows you to manage your project users.`, diff --git a/templates/cli/lib/questions.js.twig b/templates/cli/lib/questions.js.twig index 220d06426..7db4da275 100644 --- a/templates/cli/lib/questions.js.twig +++ b/templates/cli/lib/questions.js.twig @@ -13,6 +13,7 @@ const { isPortTaken } = require('./utils'); const { databasesList } = require('./commands/databases'); const { checkDeployConditions } = require('./utils'); const JSONbig = require("json-bigint")({ storeAsString: false }); +const { sitesListFrameworks, sitesListSpecifications, sitesList } = require('./commands/sites'); const whenOverride = (answers) => answers.override === undefined ? true : answers.override; @@ -269,6 +270,36 @@ const questionsPullFunctionsCode = [ }, ]; +const questionsPullSites = [ + { + type: "checkbox", + name: "sites", + message: "Which sites would you like to pull?", + validate: (value) => validateRequired('site', value), + choices: async () => { + const { sites } = await paginate(sitesList, { parseOutput: false }, 100, 'sites'); + + if (sites.length === 0) { + throw "We couldn't find any sites in your {{ spec.title|caseUcfirst }} project"; + } + return sites.map(site => { + return { + name: `${site.name} (${site.$id})`, + value: { ...site } + } + }); + } + } +]; + +const questionsPullSitesCode = [ + { + type: "confirm", + name: "override", + message: "Do you want to pull source code of the latest deployment?" + }, +]; + const questionsCreateFunction = [ { type: "input", @@ -627,6 +658,7 @@ const questionsInitResources = [ message: "Which resource would you create?", choices: [ { name: 'Function', value: 'function' }, + { name: 'Site', value: 'site' }, { name: 'Collection', value: 'collection' }, { name: 'Bucket', value: 'bucket' }, { name: 'Team', value: 'team' }, @@ -635,6 +667,27 @@ const questionsInitResources = [ } ]; +const questionsPushSites = [ + { + type: "checkbox", + name: "sites", + message: "Which sites would you like to push?", + validate: (value) => validateRequired('site', value), + when: () => localConfig.getSites().length > 0, + choices: () => { + let sites = localConfig.getSites(); + checkDeployConditions(localConfig) + let choices = sites.map((site, idx) => { + return { + name: `${site.name} (${site.$id})`, + value: site.$id + } + }) + return choices; + } + }, +]; + const questionsPushFunctions = [ { type: "checkbox", @@ -842,6 +895,58 @@ const questionsRunFunctions = [ } ]; +const questionsCreateSite = [ + { + type: "input", + name: "name", + message: "What would you like to name your site?", + default: "My Awesome Site" + }, + { + type: "input", + name: "id", + message: "What ID would you like to have for your site?", + default: "unique()" + }, + { + type: "list", + name: "framework", + message: "What framework would you like to use?", + choices: async () => { + let response = await sitesListFrameworks({ + parseOutput: false + }); + let frameworks = response["frameworks"]; + let choices = frameworks.map((framework) => { + return { + name: `${framework.name} (${framework.key})`, + value: framework, + } + }); + return choices; + }, + }, + { + type: "list", + name: "specification", + message: "What specification would you like to use?", + choices: async () => { + let response = await sitesListSpecifications({ + parseOutput: false + }); + let specifications = response["specifications"]; + let choices = specifications.map((spec) => { + return { + name: `${spec.cpus} CPU, ${spec.memory}MB RAM`, + value: spec.slug, + disabled: spec.enabled === false ? 'Upgrade to use' : false + } + }); + return choices; + }, + } +]; + module.exports = { questionsInitProject, questionsInitProjectAutopull, @@ -852,12 +957,15 @@ module.exports = { questionsCreateMessagingTopic, questionsPullFunctions, questionsPullFunctionsCode, + questionsPullSites, + questionsPullSitesCode, questionsLogin, questionsPullResources, questionsLogout, questionsPullCollection, questionsPushResources, questionsPushFunctions, + questionsPushSites, questionsPushCollections, questionsPushBuckets, questionsPushMessagingTopics, @@ -870,5 +978,6 @@ module.exports = { questionsInitResources, questionsCreateTeam, questionPushChanges, - questionPushChangesConfirmation + questionPushChangesConfirmation, + questionsCreateSite };