diff --git a/composer.lock b/composer.lock index ad6ab16c8..954fffab7 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "matthiasmullie/minify", - "version": "1.3.71", + "version": "1.3.73", "source": { "type": "git", "url": "https://github.com/matthiasmullie/minify.git", - "reference": "ae42a47d7fecc1fbb7277b2f2d84c37a33edc3b1" + "reference": "cb7a9297b4ab070909cefade30ee95054d4ae87a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/ae42a47d7fecc1fbb7277b2f2d84c37a33edc3b1", - "reference": "ae42a47d7fecc1fbb7277b2f2d84c37a33edc3b1", + "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/cb7a9297b4ab070909cefade30ee95054d4ae87a", + "reference": "cb7a9297b4ab070909cefade30ee95054d4ae87a", "shasum": "" }, "require": { @@ -67,7 +67,7 @@ ], "support": { "issues": "https://github.com/matthiasmullie/minify/issues", - "source": "https://github.com/matthiasmullie/minify/tree/1.3.71" + "source": "https://github.com/matthiasmullie/minify/tree/1.3.73" }, "funding": [ { @@ -75,7 +75,7 @@ "type": "github" } ], - "time": "2023-04-25T20:33:03+00:00" + "time": "2024-03-15T10:27:10+00:00" }, { "name": "matthiasmullie/path-converter", @@ -132,16 +132,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" + "reference": "0424dff1c58f028c451efff2045f5d92410bd540" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", + "reference": "0424dff1c58f028c451efff2045f5d92410bd540", "shasum": "" }, "require": { @@ -191,7 +191,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" }, "funding": [ { @@ -207,20 +207,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" + "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", "shasum": "" }, "require": { @@ -271,7 +271,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" }, "funding": [ { @@ -287,20 +287,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-06-19T12:30:46+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" + "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", + "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", "shasum": "" }, "require": { @@ -351,7 +351,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" }, "funding": [ { @@ -367,7 +367,7 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "twig/twig", @@ -445,16 +445,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "64fcfd0e28a6b8078a19dbf9127be2ee645b92ec" + "reference": "d4de825332842a7dee1ff350f0fd6caafa930d79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/64fcfd0e28a6b8078a19dbf9127be2ee645b92ec", - "reference": "64fcfd0e28a6b8078a19dbf9127be2ee645b92ec", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/d4de825332842a7dee1ff350f0fd6caafa930d79", + "reference": "d4de825332842a7dee1ff350f0fd6caafa930d79", "shasum": "" }, "require": { @@ -463,26 +463,26 @@ "ext-reflection": "*", "ext-simplexml": "*", "fidry/cpu-core-counter": "^1.1.0", - "jean85/pretty-package-versions": "^2.0.5", + "jean85/pretty-package-versions": "^2.0.6", "php": "~8.2.0 || ~8.3.0", - "phpunit/php-code-coverage": "^10.1.11 || ^11.0.0", + "phpunit/php-code-coverage": "^10.1.14 || ^11.0.3", "phpunit/php-file-iterator": "^4.1.0 || ^5.0.0", "phpunit/php-timer": "^6.0.0 || ^7.0.0", - "phpunit/phpunit": "^10.5.9 || ^11.0.3", - "sebastian/environment": "^6.0.1 || ^7.0.0", - "symfony/console": "^6.4.3 || ^7.0.3", - "symfony/process": "^6.4.3 || ^7.0.3" + "phpunit/phpunit": "^10.5.20 || ^11.1.3", + "sebastian/environment": "^6.1.0 || ^7.1.0", + "symfony/console": "^6.4.7 || ^7.1.0", + "symfony/process": "^6.4.7 || ^7.1.0" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^1.10.58", - "phpstan/phpstan-deprecation-rules": "^1.1.4", - "phpstan/phpstan-phpunit": "^1.3.15", - "phpstan/phpstan-strict-rules": "^1.5.2", - "squizlabs/php_codesniffer": "^3.9.0", - "symfony/filesystem": "^6.4.3 || ^7.0.3" + "phpstan/phpstan": "^1.11.2", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.0", + "squizlabs/php_codesniffer": "^3.10.1", + "symfony/filesystem": "^6.4.3 || ^7.1.0" }, "bin": [ "bin/paratest", @@ -523,7 +523,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.4.3" + "source": "https://github.com/paratestphp/paratest/tree/v7.4.5" }, "funding": [ { @@ -535,7 +535,7 @@ "type": "paypal" } ], - "time": "2024-02-20T07:24:02+00:00" + "time": "2024-05-31T13:59:20+00:00" }, { "name": "fidry/cpu-core-counter", @@ -659,16 +659,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.11.1", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", "shasum": "" }, "require": { @@ -676,11 +676,12 @@ }, "conflict": { "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", @@ -706,7 +707,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" }, "funding": [ { @@ -714,7 +715,7 @@ "type": "tidelift" } ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2024-06-12T14:39:25+00:00" }, { "name": "nikic/php-parser", @@ -894,16 +895,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "10.1.13", + "version": "10.1.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "d51c3aec14896d5e80b354fad58e998d1980f8f8" + "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d51c3aec14896d5e80b354fad58e998d1980f8f8", - "reference": "d51c3aec14896d5e80b354fad58e998d1980f8f8", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", + "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", "shasum": "" }, "require": { @@ -960,7 +961,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.13" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.14" }, "funding": [ { @@ -968,7 +969,7 @@ "type": "github" } ], - "time": "2024-03-09T16:54:15+00:00" + "time": "2024-03-12T15:33:41+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1215,16 +1216,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.12", + "version": "10.5.24", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "41a9886b85ac7bf3929853baf96b95361cd69d2b" + "reference": "5f124e3e3e561006047b532fd0431bf5bb6b9015" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/41a9886b85ac7bf3929853baf96b95361cd69d2b", - "reference": "41a9886b85ac7bf3929853baf96b95361cd69d2b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f124e3e3e561006047b532fd0431bf5bb6b9015", + "reference": "5f124e3e3e561006047b532fd0431bf5bb6b9015", "shasum": "" }, "require": { @@ -1296,7 +1297,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.12" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.24" }, "funding": [ { @@ -1312,7 +1313,7 @@ "type": "tidelift" } ], - "time": "2024-03-09T12:04:07+00:00" + "time": "2024-06-20T13:09:54+00:00" }, { "name": "psr/container", @@ -1739,16 +1740,16 @@ }, { "name": "sebastian/environment", - "version": "6.0.1", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951" + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/43c751b41d74f96cbbd4e07b7aec9675651e2951", - "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", "shasum": "" }, "require": { @@ -1763,7 +1764,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -1791,7 +1792,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" }, "funding": [ { @@ -1799,7 +1800,7 @@ "type": "github" } ], - "time": "2023-04-11T05:39:26+00:00" + "time": "2024-03-23T08:47:14+00:00" }, { "name": "sebastian/exporter", @@ -2285,16 +2286,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.9.0", + "version": "3.9.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "d63cee4890a8afaf86a22e51ad4d97c91dd4579b" + "reference": "aac1f6f347a5c5ac6bc98ad395007df00990f480" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/d63cee4890a8afaf86a22e51ad4d97c91dd4579b", - "reference": "d63cee4890a8afaf86a22e51ad4d97c91dd4579b", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/aac1f6f347a5c5ac6bc98ad395007df00990f480", + "reference": "aac1f6f347a5c5ac6bc98ad395007df00990f480", "shasum": "" }, "require": { @@ -2361,20 +2362,20 @@ "type": "open_collective" } ], - "time": "2024-02-16T15:06:51+00:00" + "time": "2024-04-23T20:25:34+00:00" }, { "name": "symfony/console", - "version": "v7.0.4", + "version": "v7.1.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f" + "reference": "9b008f2d7b21c74ef4d0c3de6077a642bc55ece3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6b099f3306f7c9c2d2786ed736d0026b2903205f", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f", + "url": "https://api.github.com/repos/symfony/console/zipball/9b008f2d7b21c74ef4d0c3de6077a642bc55ece3", + "reference": "9b008f2d7b21c74ef4d0c3de6077a642bc55ece3", "shasum": "" }, "require": { @@ -2438,7 +2439,74 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.0.4" + "source": "https://github.com/symfony/console/tree/v7.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-05-31T14:57:53+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.5.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" }, "funding": [ { @@ -2454,20 +2522,20 @@ "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f" + "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/32a9da87d7b3245e09ac426c83d334ae9f06f80f", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/64647a7c30b2283f5d49b874d84a18fc22054b7a", + "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a", "shasum": "" }, "require": { @@ -2516,7 +2584,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.30.0" }, "funding": [ { @@ -2532,20 +2600,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d" + "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/bc45c394692b948b4d383a08d7753968bed9a83d", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/a95281b0be0d9ab48050ebd988b967875cdb9fdb", + "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb", "shasum": "" }, "require": { @@ -2597,7 +2665,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.30.0" }, "funding": [ { @@ -2613,20 +2681,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/process", - "version": "v7.0.4", + "version": "v7.1.1", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0e7727191c3b71ebec6d529fa0e50a01ca5679e9" + "reference": "febf90124323a093c7ee06fdb30e765ca3c20028" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0e7727191c3b71ebec6d529fa0e50a01ca5679e9", - "reference": "0e7727191c3b71ebec6d529fa0e50a01ca5679e9", + "url": "https://api.github.com/repos/symfony/process/zipball/febf90124323a093c7ee06fdb30e765ca3c20028", + "reference": "febf90124323a093c7ee06fdb30e765ca3c20028", "shasum": "" }, "require": { @@ -2658,7 +2726,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.0.4" + "source": "https://github.com/symfony/process/tree/v7.1.1" }, "funding": [ { @@ -2674,25 +2742,26 @@ "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2024-05-31T14:57:53+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.4.1", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0" + "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/fe07cbc8d837f60caf7018068e350cc5163681a0", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", "shasum": "" }, "require": { "php": ">=8.1", - "psr/container": "^1.1|^2.0" + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { "ext-psr": "<1.1|>=2" @@ -2700,7 +2769,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -2740,7 +2809,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.4.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" }, "funding": [ { @@ -2756,20 +2825,20 @@ "type": "tidelift" } ], - "time": "2023-12-26T14:02:43+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/string", - "version": "v7.0.4", + "version": "v7.1.1", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b" + "reference": "60bc311c74e0af215101235aa6f471bcbc032df2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f5832521b998b0bec40bee688ad5de98d4cf111b", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b", + "url": "https://api.github.com/repos/symfony/string/zipball/60bc311c74e0af215101235aa6f471bcbc032df2", + "reference": "60bc311c74e0af215101235aa6f471bcbc032df2", "shasum": "" }, "require": { @@ -2783,6 +2852,7 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { + "symfony/emoji": "^7.1", "symfony/error-handler": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", "symfony/intl": "^6.4|^7.0", @@ -2826,7 +2896,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.0.4" + "source": "https://github.com/symfony/string/tree/v7.1.1" }, "funding": [ { @@ -2842,7 +2912,7 @@ "type": "tidelift" } ], - "time": "2024-02-01T13:17:36+00:00" + "time": "2024-06-04T06:40:14+00:00" }, { "name": "theseer/tokenizer", diff --git a/example.php b/example.php index 760657ee3..a9e158e73 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.5.x/app/config/specs/swagger2-latest-{$platform}.json"); + $spec = getSSLPage("https://raw.githubusercontent.com/appwrite/appwrite/1.6.x/app/config/specs/swagger2-latest-{$platform}.json"); if(empty($spec)) { throw new Exception('Failed to fetch spec from Appwrite server'); @@ -186,7 +186,7 @@ function getSSLPage($url) { ->setTwitter('appwrite_io') ->setDiscord('564160730845151244', 'https://appwrite.io/discord') ->setDefaultHeaders([ - 'X-Appwrite-Response-Format' => '0.15.0', + 'X-Appwrite-Response-Format' => '1.5.0', ]) ; @@ -393,7 +393,7 @@ function getSSLPage($url) { ; $sdk->generate(__DIR__ . '/examples/apple'); - + // DotNet $sdk = new SDK(new DotNet(), new Swagger2($spec)); @@ -442,7 +442,7 @@ function getSSLPage($url) { // Android $sdk = new SDK(new Android(), new Swagger2($spec)); - + $sdk ->setName('Android') ->setNamespace('io appwrite') @@ -466,7 +466,7 @@ function getSSLPage($url) { // Kotlin $sdk = new SDK(new Kotlin(), new Swagger2($spec)); - + $sdk ->setName('Kotlin') ->setNamespace('io appwrite') diff --git a/src/SDK/Language.php b/src/SDK/Language.php index 0e806b497..496c8ac2b 100644 --- a/src/SDK/Language.php +++ b/src/SDK/Language.php @@ -84,6 +84,15 @@ public function getFilters(): array return []; } + /** + * Language specific functions. + * @return array + */ + public function getFunctions(): array + { + return []; + } + protected function toPascalCase(string $value): string { return \ucfirst($this->toCamelCase($value)); diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 3bd0d77a4..bfab5f4f6 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -2,8 +2,66 @@ namespace Appwrite\SDK\Language; +use Twig\TwigFunction; + class CLI extends Node { + /** + * List of functions to ignore for console preview. + * @var array + */ + private $consoleIgnoreFunctions = [ + 'listidentities', + 'listmfafactors', + 'getprefs', + 'getsession', + 'getattribute', + 'listdocumentlogs', + 'getindex', + 'listcollectionlogs', + 'getcollectionusage', + 'listlogs', + 'listruntimes', + 'getusage', + 'getusage', + 'listvariables', + 'getvariable', + 'listproviderlogs', + 'listsubscriberlogs', + 'getsubscriber', + 'listtopiclogs', + 'getemailtemplate', + 'getsmstemplate', + 'getfiledownload', + 'getfilepreview', + 'getfileview', + 'getusage', + 'listlogs', + 'getprefs', + 'getusage', + 'listlogs', + 'getmembership', + 'listmemberships', + 'listmfafactors', + 'getmfarecoverycodes', + 'getprefs', + 'listtargets', + 'gettarget', + ]; + + /** + * List of SDK services to ignore for console preview. + * @var array + */ + private $consoleIgnoreServices = [ + 'health', + 'migrations', + 'locale', + 'avatars', + 'project', + 'proxy', + 'vcs' + ]; /** * @var array */ @@ -127,6 +185,16 @@ public function getFiles(): array 'destination' => 'lib/questions.js', 'template' => 'cli/lib/questions.js.twig', ], + [ + 'scope' => 'default', + 'destination' => 'lib/validations.js', + 'template' => 'cli/lib/validations.js.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'lib/spinner.js', + 'template' => 'cli/lib/spinner.js.twig', + ], [ 'scope' => 'default', 'destination' => 'lib/parser.js', @@ -152,6 +220,11 @@ public function getFiles(): array 'destination' => 'lib/client.js', 'template' => 'cli/lib/client.js.twig', ], + [ + 'scope' => 'default', + 'destination' => 'lib/id.js', + 'template' => 'cli/lib/id.js.twig', + ], [ 'scope' => 'default', 'destination' => 'lib/utils.js', @@ -164,8 +237,28 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => 'lib/commands/deploy.js', - 'template' => 'cli/lib/commands/deploy.js.twig', + 'destination' => 'lib/commands/pull.js', + 'template' => 'cli/lib/commands/pull.js.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'lib/commands/push.js', + 'template' => 'cli/lib/commands/push.js.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'lib/commands/run.js', + 'template' => 'cli/lib/commands/run.js.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'lib/emulation/docker.js', + 'template' => 'cli/lib/emulation/docker.js.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'lib/emulation/utils.js', + 'template' => 'cli/lib/emulation/utils.js.twig', ], [ 'scope' => 'service', @@ -270,4 +363,18 @@ public function getParamExample(array $param): string return $output; } + + /** + * Language specific filters. + * @return array + */ + public function getFunctions(): array + { + return [ + /** Return true if the entered service->method is enabled for a console preview link */ + new TwigFunction('hasConsolePreview', fn($method, $service) => preg_match('/^([Gg]et|[Ll]ist)/', $method) + && !in_array(strtolower($method), $this->consoleIgnoreFunctions) + && !in_array($service, $this->consoleIgnoreServices)), + ]; + } } diff --git a/src/SDK/SDK.php b/src/SDK/SDK.php index 39d4f4251..e88994b95 100644 --- a/src/SDK/SDK.php +++ b/src/SDK/SDK.php @@ -85,6 +85,13 @@ public function __construct(Language $language, Spec $spec) 'debug' => true ]); + /** + * Add language-specific functions + */ + foreach ($this->language->getFunctions() as $function) { + $this->twig->addFunction($function); + } + /** * Add language specific filters */ diff --git a/templates/cli/base/params.twig b/templates/cli/base/params.twig index e36ae2f9c..dd810eee1 100644 --- a/templates/cli/base/params.twig +++ b/templates/cli/base/params.twig @@ -11,19 +11,19 @@ if (!fs.lstatSync(folderPath).isDirectory()) { throw new Error('The path is not a directory.'); } - + const ignorer = ignore(); const func = localConfig.getFunction(functionId); + ignorer.add('.appwrite'); + if (func.ignore) { ignorer.add(func.ignore); - log('Ignoring files using configuration from appwrite.json'); } else if (fs.existsSync(pathLib.join({{ parameter.name | caseCamel | escapeKeyword }}, '.gitignore'))) { ignorer.add(fs.readFileSync(pathLib.join({{ parameter.name | caseCamel | escapeKeyword }}, '.gitignore')).toString()); - log('Ignoring files in .gitignore'); } - + const files = getAllFiles({{ parameter.name | caseCamel | escapeKeyword }}).map((file) => pathLib.relative({{ parameter.name | caseCamel | escapeKeyword }}, file)).filter((file) => !ignorer.ignores(file)); await tar @@ -77,8 +77,10 @@ {% endif %} {% endfor %} {% if method.type == 'location' %} - payload['project'] = localConfig.getProject().projectId - payload['key'] = globalConfig.getKey(); - const queryParams = new URLSearchParams(payload); - apiPath = `${globalConfig.getEndpoint()}${apiPath}?${queryParams.toString()}`; -{% endif %} \ No newline at end of file + if (!overrideForCli) { + payload['project'] = localConfig.getProject().projectId + payload['key'] = globalConfig.getKey(); + const queryParams = new URLSearchParams(payload); + apiPath = `${globalConfig.getEndpoint()}${apiPath}?${queryParams.toString()}`; + } +{% endif %} diff --git a/templates/cli/base/requests/api.twig b/templates/cli/base/requests/api.twig index c8e97c064..e228765f8 100644 --- a/templates/cli/base/requests/api.twig +++ b/templates/cli/base/requests/api.twig @@ -9,13 +9,27 @@ {% endfor %} }, payload{% if method.type == 'location' %}, 'arraybuffer'{% endif %}); -{% if method.type == 'location' %} - fs.writeFileSync(destination, response); + {%~ if method.type == 'location' %} + if (overrideForCli) { + response = Buffer.from(response); + } -{% endif %} + fs.writeFileSync(destination, response); + {%~ endif %} if (parseOutput) { + {%~ if hasConsolePreview(method.name,service.name) %} + if(console) { + showConsoleLink('{{service.name}}', '{{ method.name }}' + {%- for parameter in method.parameters.path -%}{%- set param = (parameter.name | caseCamel | escapeKeyword) -%}{%- if param ends with 'Id' -%}, {{ param }} {%- endif -%}{%- endfor -%} + ); + } else { + parse(response) + success() + } + {%~ else %} parse(response) success() + {%~ endif %} } - - return response; \ No newline at end of file + + return response; diff --git a/templates/cli/base/requests/file.twig b/templates/cli/base/requests/file.twig index 6765eed3c..64b07d891 100644 --- a/templates/cli/base/requests/file.twig +++ b/templates/cli/base/requests/file.twig @@ -1,7 +1,7 @@ {% for parameter in method.parameters.all %} {% if parameter.type == 'file' %} const size = {{ parameter.name | caseCamel | escapeKeyword }}.size; - + const apiHeaders = { {% for parameter in method.parameters.header %} '{{ parameter.name }}': ${{ parameter.name | caseCamel | escapeKeyword }}, @@ -45,7 +45,7 @@ } let uploadableChunkTrimmed; - + if(currentPosition + 1 >= client.CHUNK_SIZE) { uploadableChunkTrimmed = uploadableChunk; } else { @@ -99,12 +99,12 @@ } {% if method.packaging %} - fs.unlinkSync(filePath); + await fs.unlink(filePath,()=>{}); {% endif %} {% if method.type == 'location' %} fs.writeFileSync(destination, response); {% endif %} - + if (parseOutput) { parse(response) success() @@ -112,4 +112,4 @@ return response; {% endif %} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/cli/index.js.twig b/templates/cli/index.js.twig index d4415db72..596d1b864 100644 --- a/templates/cli/index.js.twig +++ b/templates/cli/index.js.twig @@ -10,15 +10,22 @@ const chalk = require("chalk"); const { version } = require("./package.json"); const { commandDescriptions, cliConfig } = require("./lib/parser"); const { client } = require("./lib/commands/generic"); +const inquirer = require("inquirer"); {% if sdk.test != "true" %} -const { login, logout } = require("./lib/commands/generic"); +const { login, logout, whoami, migrate, register } = require("./lib/commands/generic"); const { init } = require("./lib/commands/init"); -const { deploy } = require("./lib/commands/deploy"); +const { pull } = require("./lib/commands/pull"); +const { run } = require("./lib/commands/run"); +const { push } = require("./lib/commands/push"); +{% else %} +const { migrate } = require("./lib/commands/generic"); {% endif %} {% for service in spec.services %} const { {{ service.name | caseLower }} } = require("./lib/commands/{{ service.name | caseLower }}"); {% endfor %} +inquirer.registerPrompt('search-list', require('inquirer-search-list')); + program .description(commandDescriptions['main']) .configureHelp({ @@ -26,19 +33,41 @@ program sortSubcommands: true }) .version(version, "-v, --version") - .option("--verbose", "Show complete error log") - .option("--json", "Output in JSON format") + .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 list of ids for a giving 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.opts().id; + }) .showSuggestionAfterError() {% if sdk.test != "true" %} + .addCommand(whoami) + .addCommand(register) .addCommand(login) .addCommand(init) - .addCommand(deploy) + .addCommand(pull) + .addCommand(push) + .addCommand(run) .addCommand(logout) {% endif %} {% for service in spec.services %} @@ -46,5 +75,5 @@ program {% endfor %} .addCommand(client) .parse(process.argv); - -process.stdout.columns = oldWidth; \ No newline at end of file + +process.stdout.columns = oldWidth; diff --git a/templates/cli/install.ps1.twig b/templates/cli/install.ps1.twig index 7491a4f16..b3dffba93 100644 --- a/templates/cli/install.ps1.twig +++ b/templates/cli/install.ps1.twig @@ -79,8 +79,8 @@ function CleanUp { function InstallCompleted { Write-Host "[4/4] Finishing Installation ... " cleanup - Write-Host "🤘 May the force be with you." Write-Host "To get started with {{ spec.title | caseUcfirst }} CLI, please visit {{ sdk.url }}/docs/command-line" + Write-Host "As first step, you can login to your {{ spec.title | caseUcfirst }} account using 'appwrite login'" } diff --git a/templates/cli/install.sh.twig b/templates/cli/install.sh.twig index af0f26330..7faa92a6a 100644 --- a/templates/cli/install.sh.twig +++ b/templates/cli/install.sh.twig @@ -139,8 +139,8 @@ cleanup() { installCompleted() { echo "[4/4] Wrapping up installation ... " cleanup - printf "🤘 May the force be with you. \n" echo "🚀 To get started with {{ spec.title | caseUcfirst }} CLI, please visit {{ sdk.url }}/docs/command-line" + echo "As first step, you can login to your {{ spec.title | caseUcfirst }} account using 'appwrite login'" } # Installation Starts here diff --git a/templates/cli/lib/client.js.twig b/templates/cli/lib/client.js.twig index 9dca3ccde..ee482380a 100644 --- a/templates/cli/lib/client.js.twig +++ b/templates/cli/lib/client.js.twig @@ -4,10 +4,11 @@ const { fetch, FormData, Agent } = require("undici"); const JSONbig = require("json-bigint")({ storeAsString: false }); const {{spec.title | caseUcfirst}}Exception = require("./exception.js"); const { globalConfig } = require("./config.js"); +const chalk = require("chalk"); class Client { CHUNK_SIZE = 5*1024*1024; // 5MB - + constructor() { this.endpoint = '{{spec.endpoint}}'; this.headers = { @@ -144,6 +145,14 @@ class Client { } catch (error) { throw new {{spec.title | caseUcfirst}}Exception(text, response.status, "", text); } + + if (path !== '/account' && json.code === 401 && json.type === 'user_more_factors_required') { + console.log(`${chalk.cyan.bold("ℹ Info")} ${chalk.cyan("Unusable account found, removing...")}`); + + const current = globalConfig.getCurrentSession(); + globalConfig.setCurrentSession(''); + globalConfig.removeSession(current); + } throw new {{spec.title | caseUcfirst}}Exception(json.message, json.code, json.type, json); } diff --git a/templates/cli/lib/commands/command.js.twig b/templates/cli/lib/commands/command.js.twig index 3607fb3d3..6dcdf7842 100644 --- a/templates/cli/lib/commands/command.js.twig +++ b/templates/cli/lib/commands/command.js.twig @@ -4,7 +4,7 @@ const tar = require("tar"); const ignore = require("ignore"); const { promisify } = require('util'); const libClient = require('../client.js'); -const { getAllFiles } = require('../utils.js'); +const { getAllFiles, showConsoleLink } = require('../utils.js'); const { Command } = require('commander'); const { sdkForProject, sdkForConsole } = require('../sdks') const { parse, actionRunner, parseInteger, parseBool, commandDescriptions, success, log } = require('../parser') @@ -45,6 +45,7 @@ const {{ service.name | caseLower }} = new Command("{{ service.name | caseLower {% for parameter in method.parameters.all %} * @property {{ "{" }}{{ parameter | typeName }}{{ "}" }} {{ parameter.name | caseCamel | escapeKeyword }} {{ parameter.description | replace({'`':'\''}) | replace({'\n':' '}) | replace({'\n \n':' '}) }} {% endfor %} + * @property {boolean} overrideForCli * @property {boolean} parseOutput * @property {libClient | undefined} sdk {% if 'multipart/form-data' in method.consumes %} @@ -58,8 +59,22 @@ const {{ service.name | caseLower }} = new Command("{{ service.name | caseLower /** * @param {{ "{" }}{{ service.name | caseUcfirst }}{{ method.name | caseUcfirst }}RequestParams{{ "}" }} params */ -const {{ service.name | caseLower }}{{ method.name | caseUcfirst }} = async ({ {% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}, {% endfor %}parseOutput = true, sdk = undefined{% if 'multipart/form-data' in method.consumes %}, onProgress = () => {}{% endif %}{% if method.type == 'location' %}, destination{% endif %}}) => { - let client = !sdk ? await {% if service.name == "projects" %}sdkForConsole(){% else %}sdkForProject(){% endif %} : sdk; +{%~ block decleration -%} +const {{ service.name | caseLower }}{{ method.name | caseUcfirst }} = async ({ + {%- for parameter in method.parameters.all -%} + {{ parameter.name | caseCamel | escapeKeyword }}, + {%- endfor -%} + + {%- block baseParams -%}parseOutput = true, overrideForCli = false, sdk = undefined {%- endblock -%} + + {%- if 'multipart/form-data' in method.consumes -%},onProgress = () => {}{%- endif -%} + + {%- if method.type == 'location' -%}, destination{%- endif -%} + {% if hasConsolePreview(method.name,service.name) %}, console{%- endif -%} +}) => { +{%~ endblock %} + let client = !sdk ? await {% if service.name == "projects" %}sdkForConsole(){% else %}sdkForProject(){% endif %} : + sdk; let apiPath = '{{ method.path }}'{% for parameter in method.parameters.path %}.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', {{ parameter.name | caseCamel | escapeKeyword }}){% endfor %}; {{ include ('cli/base/params.twig') }} {% if 'multipart/form-data' in method.consumes %} @@ -80,6 +95,9 @@ const {{ service.name | caseLower }}{{ method.name | caseUcfirst }} = async ({ { {% if method.type == 'location' %} .requiredOption(`--destination `, `output file path.`) {% endif %} +{% if hasConsolePreview(method.name,service.name) %} + .option(`--console`, `Get the resource console url`) +{% endif %} {% endautoescape %} .action(actionRunner({{ service.name | caseLower }}{{ method.name | caseUcfirst }})) @@ -90,4 +108,4 @@ module.exports = { {{ service.name | caseLower }}{{ method.name | caseUcfirst }}{% if not loop.last %},{% endif %} {% endfor %} -}; \ No newline at end of file +}; diff --git a/templates/cli/lib/commands/deploy.js.twig b/templates/cli/lib/commands/deploy.js.twig deleted file mode 100644 index 417de16d6..000000000 --- a/templates/cli/lib/commands/deploy.js.twig +++ /dev/null @@ -1,940 +0,0 @@ -const inquirer = require("inquirer"); -const JSONbig = require("json-bigint")({ storeAsString: false }); -const { Command } = require("commander"); -const { localConfig } = require("../config"); -const { paginate } = require('../paginate'); -const { questionsDeployBuckets, questionsDeployTeams, questionsDeployFunctions, questionsGetEntrypoint, questionsDeployCollections, questionsConfirmDeployCollections } = require("../questions"); -const { actionRunner, success, log, error, commandDescriptions } = require("../parser"); -const { functionsGet, functionsCreate, functionsUpdate, functionsCreateDeployment, functionsUpdateDeployment, functionsListVariables, functionsDeleteVariable, functionsCreateVariable } = require('./functions'); -const { - databasesGet, - databasesCreate, - databasesUpdate, - databasesCreateBooleanAttribute, - databasesGetCollection, - databasesCreateCollection, - databasesCreateStringAttribute, - databasesCreateIntegerAttribute, - databasesCreateFloatAttribute, - databasesCreateEmailAttribute, - databasesCreateDatetimeAttribute, - databasesCreateIndex, - databasesCreateUrlAttribute, - databasesCreateIpAttribute, - databasesCreateEnumAttribute, - databasesCreateRelationshipAttribute, - databasesDeleteAttribute, - databasesListAttributes, - databasesListIndexes, - databasesDeleteIndex, - databasesUpdateCollection -} = require("./databases"); -const { - storageGetBucket, storageUpdateBucket, storageCreateBucket -} = require("./storage"); -const { - teamsGet, - teamsUpdateName, - teamsCreate -} = require("./teams"); -const { checkDeployConditions } = require('../utils'); - -const STEP_SIZE = 100; // Resources -const POOL_DEBOUNCE = 2000; // Milliseconds - -let poolMaxDebounces = 30; - -const awaitPools = { - wipeAttributes: async (databaseId, collectionId, iteration = 1) => { - if (iteration > poolMaxDebounces) { - return false; - } - - const { total } = await databasesListAttributes({ - databaseId, - collectionId, - queries: [JSON.stringify({ method: 'limit', values: [1] })], - parseOutput: false - }); - - if (total === 0) { - return true; - } - - let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; - - log('Found a large number of attributes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') - } - - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); - - return await awaitPools.wipeAttributes( - databaseId, - collectionId, - iteration + 1 - ); - }, - wipeIndexes: async (databaseId, collectionId, iteration = 1) => { - if (iteration > poolMaxDebounces) { - return false; - } - - const { total } = await databasesListIndexes({ - databaseId, - collectionId, - queries: [JSON.stringify({ method: 'limit', values: [1] })], - parseOutput: false - }); - - if (total === 0) { - return true; - } - - let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; - - log('Found a large number of indexes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') - } - - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); - - return await awaitPools.wipeIndexes( - databaseId, - collectionId, - iteration + 1 - ); - }, - wipeVariables: async (functionId, iteration = 1) => { - if (iteration > poolMaxDebounces) { - return false; - } - - const { total } = await functionsListVariables({ - functionId, - queries: ['limit(1)'], - parseOutput: false - }); - - if (total === 0) { - return true; - } - - let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; - - log('Found a large number of variables, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') - } - - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); - - return await awaitPools.wipeVariables( - functionId, - iteration + 1 - ); - }, - expectAttributes: async (databaseId, collectionId, attributeKeys, iteration = 1) => { - if (iteration > poolMaxDebounces) { - return false; - } - - let steps = Math.max(1, Math.ceil(attributeKeys.length / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; - - log('Creating a large number of attributes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') - } - - const { attributes } = await paginate(databasesListAttributes, { - databaseId, - collectionId, - parseOutput: false - }, 100, 'attributes'); - - const ready = attributes - .filter(attribute => { - 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 => attribute.key); - - if (ready.length === attributeKeys.length) { - return true; - } - - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); - - return await awaitPools.expectAttributes( - databaseId, - collectionId, - attributeKeys, - iteration + 1 - ); - }, - expectIndexes: async (databaseId, collectionId, indexKeys, iteration = 1) => { - if (iteration > poolMaxDebounces) { - return false; - } - - let steps = Math.max(1, Math.ceil(indexKeys.length / STEP_SIZE)); - if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; - - log('Creating a large number of indexes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') - } - - const { indexes } = await paginate(databasesListIndexes, { - databaseId, - collectionId, - parseOutput: false - }, 100, 'indexes'); - - const ready = indexes - .filter((index) => { - 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 => index.key); - - if (ready.length >= indexKeys.length) { - return true; - } - - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); - - return await awaitPools.expectIndexes( - databaseId, - collectionId, - indexKeys, - iteration + 1 - ); - }, -} - -const deploy = new Command("deploy") - .description(commandDescriptions['deploy']) - .configureHelp({ - helpWidth: process.stdout.columns || 80 - }) - .action(actionRunner(async (_options, command) => { - command.help() - })); - -const deployFunction = async ({ functionId, all, yes } = {}) => { - let response = {}; - - const functionIds = []; - - if (functionId) { - functionIds.push(functionId); - } else if (all) { - const functions = localConfig.getFunctions(); - checkDeployConditions(localConfig); - if (functions.length === 0) { - throw new Error("No functions found in the current directory."); - } - functionIds.push(...functions.map((func, idx) => { - return func.$id; - })); - } - - if (functionIds.length <= 0) { - const answers = await inquirer.prompt(questionsDeployFunctions[0]); - functionIds.push(...answers.functions); - } - - let functions = functionIds.map((id) => { - const functions = localConfig.getFunctions(); - const func = functions.find((f) => f.$id === id); - - if (!func) { - throw new Error("Function '" + id + "' not found.") - } - - return func; - }); - - for (let func of functions) { - log(`Deploying function ${func.name} ( ${func['$id']} )`) - - try { - response = await functionsGet({ - functionId: func['$id'], - parseOutput: false, - }); - - if (response.runtime !== func.runtime) { - throw new Error(`Runtime missmatch! (local=${func.runtime},remote=${response.runtime}) Please delete remote function or update your appwrite.json`); - } - - response = await functionsUpdate({ - functionId: func['$id'], - name: func.name, - execute: func.execute, - events: func.events, - schedule: func.schedule, - timeout: func.timeout, - enabled: func.enabled, - logging: func.logging, - entrypoint: func.entrypoint, - commands: func.commands, - vars: JSON.stringify(response.vars), - parseOutput: false - }); - } catch (e) { - if (e.code == 404) { - log(`Function ${func.name} ( ${func['$id']} ) does not exist in the project. Creating ... `); - response = await functionsCreate({ - functionId: func.$id || 'unique()', - 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, - vars: JSON.stringify(func.vars), - parseOutput: false - }); - - localConfig.updateFunction(func['$id'], { - "$id": response['$id'], - }); - - func["$id"] = response['$id']; - log(`Function ${func.name} created.`); - } else { - throw e; - } - } - - if (func.variables) { - // Delete existing variables - - const { total } = await functionsListVariables({ - functionId: func['$id'], - queries: [JSON.stringify({ method: 'limit', values: [1] })], - parseOutput: false - }); - - let deployVariables = yes; - - if (total === 0) { - deployVariables = true; - } else if (total > 0 && !yes) { - const variableAnswers = await inquirer.prompt(questionsDeployFunctions[1]) - deployVariables = variableAnswers.override.toLowerCase() === "yes"; - } - - if (!deployVariables) { - log(`Skipping variables for ${func.name} ( ${func['$id']} )`); - } else { - log(`Deploying variables for ${func.name} ( ${func['$id']} )`); - - const { variables } = await paginate(functionsListVariables, { - functionId: func['$id'], - parseOutput: false - }, 100, 'variables'); - - await Promise.all(variables.map(async variable => { - await functionsDeleteVariable({ - functionId: func['$id'], - variableId: variable['$id'], - parseOutput: false - }); - })); - - let result = await awaitPools.wipeVariables(func['$id']); - if (!result) { - throw new Error("Variable deletion timed out."); - } - - // Deploy local variables - await Promise.all(Object.keys(func.variables).map(async localVariableKey => { - await functionsCreateVariable({ - functionId: func['$id'], - key: localVariableKey, - value: func.variables[localVariableKey], - parseOutput: false - }); - })); - } - } - - // Create tag - if (!func.entrypoint) { - const answers = await inquirer.prompt(questionsGetEntrypoint) - func.entrypoint = answers.entrypoint; - localConfig.updateFunction(func['$id'], func); - } - - try { - response = await functionsCreateDeployment({ - functionId: func['$id'], - entrypoint: func.entrypoint, - commands: func.commands, - code: func.path, - activate: true, - parseOutput: false - }) - - success(`Deployed ${func.name} ( ${func['$id']} )`); - - } catch (e) { - switch (e.code) { - case 'ENOENT': - error(`Function ${func.name} ( ${func['$id']} ) not found in the current directory. Skipping ...`); - break; - default: - throw e; - } - } - } - - success(`Deployed ${functions.length} functions`); -} - -const createAttribute = async (databaseId, collectionId, attribute) => { - switch (attribute.type) { - case 'string': - switch (attribute.format) { - case 'email': - return await databasesCreateEmailAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - case 'url': - return await databasesCreateUrlAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - case 'ip': - return await databasesCreateIpAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - case 'enum': - return await databasesCreateEnumAttribute({ - databaseId, - collectionId, - key: attribute.key, - elements: attribute.elements, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - default: - return await databasesCreateStringAttribute({ - databaseId, - collectionId, - key: attribute.key, - size: attribute.size, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - - } - case 'integer': - return await databasesCreateIntegerAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - min: attribute.min, - max: attribute.max, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - case 'double': - return databasesCreateFloatAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - min: attribute.min, - max: attribute.max, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - case 'boolean': - return databasesCreateBooleanAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - case 'datetime': - return databasesCreateDatetimeAttribute({ - databaseId, - collectionId, - key: attribute.key, - required: attribute.required, - xdefault: attribute.default, - array: attribute.array, - parseOutput: false - }) - case 'relationship': - return databasesCreateRelationshipAttribute({ - databaseId, - collectionId, - relatedCollectionId: attribute.relatedCollection, - type: attribute.relationType, - twoWay: attribute.twoWay, - key: attribute.key, - twoWayKey: attribute.twoWayKey, - onDelete: attribute.onDelete, - parseOutput: false - }) - } -} - -const deployCollection = async ({ all, yes } = {}) => { - let response = {}; - - const collections = []; - - if (all) { - checkDeployConditions(localConfig); - if (localConfig.getCollections().length === 0) { - throw new Error("No collections found in the current directory. Run `{{ language.params.executableName }} init collection` to fetch all your collections."); - } - collections.push(...localConfig.getCollections()); - } else { - const answers = await inquirer.prompt(questionsDeployCollections[0]) - const configCollections = new Map(); - localConfig.getCollections().forEach((c) => { - configCollections.set(`${c['databaseId']}|${c['$id']}`, c); - }); - answers.collections.forEach((a) => { - const collection = configCollections.get(a); - collections.push(collection); - }) - } - - for (let collection of collections) { - log(`Deploying collection ${collection.name} ( ${collection['databaseId']} - ${collection['$id']} )`) - - let databaseId; - - const localDatabase = localConfig.getDatabase(collection.databaseId); - - try { - const database = await databasesGet({ - databaseId: collection.databaseId, - parseOutput: false, - }); - - databaseId = database.$id; - - if (database.name !== (localDatabase.name ?? collection.databaseId)) { - await databasesUpdate({ - databaseId: collection.databaseId, - name: localDatabase.name ?? collection.databaseId, - parseOutput: false - }) - - success(`Updated ${localDatabase.name} ( ${collection.databaseId} )`); - } - } catch (err) { - log(`Database ${collection.databaseId} not found. Creating it now...`); - - const database = await databasesCreate({ - databaseId: collection.databaseId, - name: localDatabase.name ?? collection.databaseId, - parseOutput: false, - }); - - databaseId = database.$id; - } - - try { - response = await databasesGetCollection({ - databaseId, - collectionId: collection['$id'], - parseOutput: false, - }) - - log(`Collection ${collection.name} ( ${collection['$id']} ) already exists.`); - - if (!yes) { - const answers = await inquirer.prompt(questionsDeployCollections[1]) - if (answers.override.toLowerCase() !== "yes") { - log(`Received "${answers.override}". Skipping ${collection.name} ( ${collection['$id']} )`); - continue; - } - } - - log(`Deleting indexes and attributes ... `); - - const { indexes } = await paginate(databasesListIndexes, { - databaseId, - collectionId: collection['$id'], - parseOutput: false - }, 100, 'indexes'); - - await Promise.all(indexes.map(async index => { - await databasesDeleteIndex({ - databaseId, - collectionId: collection['$id'], - key: index.key, - parseOutput: false - }); - })); - - let result = await awaitPools.wipeIndexes(databaseId, collection['$id']); - if (!result) { - throw new Error("Index deletion timed out."); - } - - const { attributes } = await paginate(databasesListAttributes, { - databaseId, - collectionId: collection['$id'], - parseOutput: false - }, 100, 'attributes'); - - await Promise.all(attributes.map(async attribute => { - await databasesDeleteAttribute({ - databaseId, - collectionId: collection['$id'], - key: attribute.key, - parseOutput: false - }); - })); - - const deleteAttributesPoolStatus = await awaitPools.wipeAttributes(databaseId, collection['$id']); - if (!deleteAttributesPoolStatus) { - throw new Error("Attribute deletion timed out."); - } - - await databasesUpdateCollection({ - databaseId, - collectionId: collection['$id'], - name: collection.name, - documentSecurity: collection.documentSecurity, - permissions: collection['$permissions'], - enabled: collection.enabled, - parseOutput: false - }) - } catch (e) { - if (e.code == 404) { - log(`Collection ${collection.name} does not exist in the project. Creating ... `); - response = await databasesCreateCollection({ - databaseId, - collectionId: collection['$id'], - name: collection.name, - documentSecurity: collection.documentSecurity, - permissions: collection['$permissions'], - parseOutput: false - }) - - } else { - throw e; - } - } - - // Create all non-relationship attributes first - const attributes = collection.attributes.filter(attribute => attribute.type !== 'relationship'); - - await Promise.all(attributes.map(attribute => { - return createAttribute(databaseId, collection['$id'], attribute); - })); - - let result = await awaitPools.expectAttributes( - databaseId, - collection['$id'], - attributes.map(attribute => attribute.key) - ); - - if (!result) { - throw new Error("Attribute creation timed out."); - } - - success(`Created ${attributes.length} non-relationship attributes`); - - log(`Creating indexes ...`) - - await Promise.all(collection.indexes.map(async index => { - await databasesCreateIndex({ - databaseId, - collectionId: collection['$id'], - key: index.key, - type: index.type, - attributes: index.attributes, - orders: index.orders, - parseOutput: false - }); - })); - - result = await awaitPools.expectIndexes( - databaseId, - collection['$id'], - collection.indexes.map(attribute => attribute.key) - ); - - if (!result) { - throw new Error("Index creation timed out."); - } - - success(`Created ${collection.indexes.length} indexes`); - - success(`Deployed ${collection.name} ( ${collection['$id']} )`); - } - - // Create the relationship attributes - for (let collection of collections) { - const relationships = collection.attributes.filter(attribute => - attribute.type === 'relationship' && attribute.side === 'parent' - ); - - if (relationships.length === 0) { - continue; - } - - log(`Deploying relationships for collection ${collection.name} ( ${collection['$id']} )`); - - await Promise.all(relationships.map(attribute => { - return createAttribute(collection['databaseId'], collection['$id'], attribute); - })); - - let result = await awaitPools.expectAttributes( - collection['databaseId'], - collection['$id'], - relationships.map(attribute => attribute.key) - ); - - if (!result) { - throw new Error("Attribute creation timed out."); - } - - success(`Created ${relationships.length} relationship attributes`); - } -} - -const deployBucket = async ({ all, yes } = {}) => { - let response = {}; - - let bucketIds = []; - const configBuckets = localConfig.getBuckets(); - - if (all) { - checkDeployConditions(localConfig); - bucketIds.push(...configBuckets.map((b) => b.$id)); - } - - if (bucketIds.length === 0) { - const answers = await inquirer.prompt(questionsDeployBuckets[0]) - bucketIds.push(...answers.buckets); - } - - let buckets = []; - - for (const bucketId of bucketIds) { - const idBuckets = configBuckets.filter((b) => b.$id === bucketId); - buckets.push(...idBuckets); - } - - for (let bucket of buckets) { - log(`Deploying bucket ${bucket.name} ( ${bucket['$id']} )`) - - try { - response = await storageGetBucket({ - bucketId: bucket['$id'], - parseOutput: false, - }) - log(`Bucket ${bucket.name} ( ${bucket['$id']} ) already exists.`); - - if (!yes) { - const answers = await inquirer.prompt(questionsDeployBuckets[1]) - if (answers.override.toLowerCase() !== "yes") { - log(`Received "${answers.override}". Skipping ${bucket.name} ( ${bucket['$id']} )`); - continue; - } - } - - log(`Updating bucket ...`) - - await storageUpdateBucket({ - 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, - compression: bucket.compression, - parseOutput: false - }); - - success(`Deployed ${bucket.name} ( ${bucket['$id']} )`); - } catch (e) { - if (e.code == 404) { - log(`Bucket ${bucket.name} does not exist in the project. Creating ... `); - - response = await storageCreateBucket({ - 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, - parseOutput: false - }) - - success(`Deployed ${bucket.name} ( ${bucket['$id']} )`); - } else { - throw e; - } - } - } -} - -const deployTeam = async ({ all, yes } = {}) => { - let response = {}; - - let teamIds = []; - const configTeams = localConfig.getTeams(); - - if (all) { - checkDeployConditions(localConfig); - teamIds.push(...configTeams.map((t) => t.$id)); - } - - if (teamIds.length === 0) { - const answers = await inquirer.prompt(questionsDeployTeams[0]) - teamIds.push(...answers.teams); - } - - let teams = []; - - for (const teamId of teamIds) { - const idTeams = configTeams.filter((t) => t.$id === teamId); - teams.push(...idTeams); - } - - for (let team of teams) { - log(`Deploying team ${team.name} ( ${team['$id']} )`) - - try { - response = await teamsGet({ - teamId: team['$id'], - parseOutput: false, - }) - log(`Team ${team.name} ( ${team['$id']} ) already exists.`); - - if (!yes) { - const answers = await inquirer.prompt(questionsDeployTeams[1]) - if (answers.override.toLowerCase() !== "yes") { - log(`Received "${answers.override}". Skipping ${team.name} ( ${team['$id']} )`); - continue; - } - } - - log(`Updating team ...`) - - await teamsUpdateName({ - teamId: team['$id'], - name: team.name, - parseOutput: false - }); - - success(`Deployed ${team.name} ( ${team['$id']} )`); - } catch (e) { - if (e.code == 404) { - log(`Team ${team.name} does not exist in the project. Creating ... `); - - response = await teamsCreate({ - teamId: team['$id'], - name: team.name, - parseOutput: false - }) - - success(`Deployed ${team.name} ( ${team['$id']} )`); - } else { - throw e; - } - } - } -} - -deploy - .command("function") - .description("Deploy functions in the current directory.") - .option(`--functionId `, `Function ID`) - .option(`--all`, `Flag to deploy all functions`) - .option(`--yes`, `Flag to confirm all warnings`) - .action(actionRunner(deployFunction)); - -deploy - .command("collection") - .description("Deploy collections in the current project.") - .option(`--all`, `Flag to deploy all collections`) - .option(`--yes`, `Flag to confirm all warnings`) - .action(actionRunner(deployCollection)); - -deploy - .command("bucket") - .description("Deploy buckets in the current project.") - .option(`--all`, `Flag to deploy all buckets`) - .option(`--yes`, `Flag to confirm all warnings`) - .action(actionRunner(deployBucket)); - -deploy - .command("team") - .description("Deploy teams in the current project.") - .option(`--all`, `Flag to deploy all teams`) - .option(`--yes`, `Flag to confirm all warnings`) - .action(actionRunner(deployTeam)); - -module.exports = { - deploy -} diff --git a/templates/cli/lib/commands/generic.js.twig b/templates/cli/lib/commands/generic.js.twig index f66a36ea3..f4445ea64 100644 --- a/templates/cli/lib/commands/generic.js.twig +++ b/templates/cli/lib/commands/generic.js.twig @@ -3,21 +3,45 @@ const { Command } = require("commander"); const Client = require("../client"); const { sdkForConsole } = require("../sdks"); const { globalConfig, localConfig } = require("../config"); -const { actionRunner, success, parseBool, commandDescriptions, log, parse } = require("../parser"); +const { actionRunner, success, parseBool, commandDescriptions, error, parse, log, drawTable, cliConfig } = require("../parser"); +const ID = require("../id"); {% if sdk.test != "true" %} -const { questionsLogin, questionsListFactors, questionsMfaChallenge } = require("../questions"); -const { accountUpdateMfaChallenge, accountCreateMfaChallenge, accountGet, accountCreateEmailPasswordSession, accountDeleteSession } = require("./account"); +const { questionsLogin, questionsLogout, questionsListFactors, questionsMfaChallenge } = require("../questions"); +const { accountUpdateMfaChallenge, accountCreateMfaChallenge, accountGet, accountCreateEmailPasswordSession, accountDeleteSession } = require("./account"); -const login = new Command("login") - .description(commandDescriptions['login']) - .configureHelp({ - helpWidth: process.stdout.columns || 80 - }) - .action(actionRunner(async () => { - const answers = await inquirer.prompt(questionsLogin) +const DEFAULT_ENDPOINT = 'https://cloud.appwrite.io/v1'; - let client = await sdkForConsole(false); +const loginCommand = async ({ email, password, endpoint, mfa, code }) => { + const oldCurrent = globalConfig.getCurrentSession(); + let configEndpoint = endpoint ?? DEFAULT_ENDPOINT; + + const answers = email && password ? { email, password } : await inquirer.prompt(questionsLogin); + + if (answers.method === 'select') { + const accountId = answers.accountId; + + if (!globalConfig.getSessionIds().includes(accountId)) { + throw Error('Session ID not found'); + } + + globalConfig.setCurrentSession(accountId); + success(`Current account is ${accountId}`); + + return; + } + + const id = ID.unique(); + + globalConfig.addSession(id, {}); + globalConfig.setCurrentSession(id); + globalConfig.setEndpoint(configEndpoint); + globalConfig.setEmail(answers.email); + let client = await sdkForConsole(false); + + let account; + + try { await accountCreateEmailPasswordSession({ email: answers.email, password: answers.password, @@ -27,6 +51,57 @@ const login = new Command("login") client.setCookie(globalConfig.getCookie()); + account = await accountGet({ + sdk: client, + parseOutput: false + }); + } catch (error) { + if (error.response === 'user_more_factors_required') { + const { factor } = mfa ? { factor: mfa } : await inquirer.prompt(questionsListFactors); + + const challenge = await accountCreateMfaChallenge({ + factor, + parseOutput: false, + sdk: client + }); + + const { otp } = code ? { otp: code } : await inquirer.prompt(questionsMfaChallenge); + + await accountUpdateMfaChallenge({ + challengeId: challenge.$id, + otp, + parseOutput: false, + sdk: client + }); + + account = await accountGet({ + sdk: client, + parseOutput: false + }); + } else { + globalConfig.removeSession(id); + globalConfig.setCurrentSession(oldCurrent); + if(endpoint !== DEFAULT_ENDPOINT && error.response === 'user_invalid_credentials'){ + log('Use the --endpoint option for self-hosted instances') + } + throw error; + } + } + + success("Signed in as user with ID: " + account.$id); + log("Next you can create or link to your project using 'appwrite init project'"); +}; + +const whoami = new Command("whoami") + .description(commandDescriptions['whoami']) + .action(actionRunner(async () => { + if (globalConfig.getEndpoint() === '' || globalConfig.getCookie() === '') { + error("No user is signed in. To sign in, run: appwrite login "); + return; + } + + let client = await sdkForConsole(false); + let account; try { @@ -34,43 +109,49 @@ const login = new Command("login") sdk: client, parseOutput: false }); - } catch(error) { - if (error.response === 'user_more_factors_required') { - const { factor } = await inquirer.prompt(questionsListFactors); - - const challenge = await accountCreateMfaChallenge({ - factor, - parseOutput: false, - sdk: client - }); - - const { otp } = await inquirer.prompt(questionsMfaChallenge); - - await accountUpdateMfaChallenge({ - challengeId: challenge.$id, - otp, - parseOutput: false, - sdk: client - }); - - account = await accountGet({ - sdk: client, - parseOutput: false - }); - } else { - throw error; + } catch (error) { + error("No user is signed in. To sign in, run: appwrite login"); + return; + } + + const data = [ + { + 'ID': account.$id, + 'Name': account.name, + 'Email': account.email, + 'MFA enabled': account.mfa ? 'Yes' : 'No', + 'Endpoint': globalConfig.getEndpoint() } + ]; + + if(cliConfig.json) { + console.log(data); + return; } - success("Signed in as user with ID: " + account.$id); + drawTable(data) })); -const logout = new Command("logout") - .description(commandDescriptions['logout']) +const register = new Command("register") + .description(commandDescriptions['register']) + .action(actionRunner(async () => { + log('Visit https://cloud.appwrite.io/register to create an account') + })); + +const login = new Command("login") + .description(commandDescriptions['login']) + .option(`--email [email]`, `User email`) + .option(`--password [password]`, `User password`) + .option(`--endpoint [endpoint]`, `Appwrite endpoint for self hosted instances`) + .option(`--mfa [factor]`, `Multi-factor authentication login factor: totp, email, phone or recoveryCode`) + .option(`--code [code]`, `Multi-factor code`) .configureHelp({ helpWidth: process.stdout.columns || 80 }) - .action(actionRunner(async () => { + .action(actionRunner(loginCommand)); + +const deleteSession = async (accountId) => { + try { let client = await sdkForConsole(); await accountDeleteSession({ @@ -79,8 +160,51 @@ const logout = new Command("logout") sdk: client }) - globalConfig.setCookie(""); - success() + globalConfig.removeSession(accountId); + } catch (e) { + error('Unable to log out, removing locally saved session information') + } + globalConfig.removeSession(accountId); +} + +const logout = new Command("logout") + .description(commandDescriptions['logout']) + .configureHelp({ + helpWidth: process.stdout.columns || 80 + }) + .action(actionRunner(async () => { + const sessions = globalConfig.getSessions(); + const current = globalConfig.getCurrentSession(); + + if (current === '') { + return; + } + if (sessions.length === 1) { + await deleteSession(current); + success(); + + return; + } + + const answers = await inquirer.prompt(questionsLogout); + + if (answers.accounts) { + for (let accountId of answers.accounts) { + globalConfig.setCurrentSession(accountId); + await deleteSession(accountId); + } + } + + const remainingSessions = globalConfig.getSessions(); + + if (remainingSessions .length > 0 && remainingSessions .filter(session => session.id === current).length !== 1) { + const accountId = remainingSessions [0].id; + globalConfig.setCurrentSession(accountId); + + success(`Current account is ${accountId}`); + } + + success(); })); {% endif %} @@ -89,12 +213,12 @@ const client = new Command("client") .configureHelp({ helpWidth: process.stdout.columns || 80 }) - .option("--selfSigned ", "Configure the CLI to use a self-signed certificate ( true or false )", parseBool) - .option("--endpoint ", "Set your Appwrite server endpoint") - .option("--projectId ", "Set your Appwrite project ID") - .option("--key ", "Set your Appwrite server's API key") - .option("--debug", "Print CLI debug information") - .option("--reset", "Reset the CLI configuration") + .option("-ss, --selfSigned ", "Configure the CLI to use a self-signed certificate ( true or false )", parseBool) + .option("-e, --endpoint ", "Set your Appwrite server endpoint") + .option("-p, --projectId ", "Set your Appwrite project ID") + .option("-k, --key ", "Set your Appwrite server's API key") + .option("-d, --debug", "Print CLI debug information") + .option("-r, --reset", "Reset the CLI configuration") .action(actionRunner(async ({ selfSigned, endpoint, projectId, key, debug, reset }, command) => { if (selfSigned == undefined && endpoint == undefined && projectId == undefined && key == undefined && debug == undefined && reset == undefined) { command.help() @@ -113,6 +237,7 @@ const client = new Command("client") if (endpoint !== undefined) { try { + const id = ID.unique(); let url = new URL(endpoint); if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(); @@ -127,7 +252,8 @@ const client = new Command("client") if (!response.version) { throw new Error(); } - + globalConfig.setCurrentSession(id); + globalConfig.addSession(id, {}); globalConfig.setEndpoint(endpoint); } catch (_) { throw new Error("Invalid endpoint or your Appwrite server is not running as expected."); @@ -147,20 +273,46 @@ const client = new Command("client") } if (reset !== undefined) { - globalConfig.setEndpoint(""); - globalConfig.setKey(""); - globalConfig.setCookie(""); - globalConfig.setSelfSigned(""); - localConfig.setProject("", ""); + const sessions = globalConfig.getSessions(); + + for (let accountId of sessions.map(session => session.id)) { + globalConfig.setCurrentSession(accountId); + await deleteSession(accountId); + } } success() })); +const migrate = async () => { + if (!globalConfig.has('endpoint') || !globalConfig.has('cookie')) { + return; + } + + const endpoint = globalConfig.get('endpoint'); + const cookie = globalConfig.get('cookie'); + + const id = ID.unique(); + const data = { + endpoint, + cookie, + email: 'legacy' + }; + + globalConfig.addSession(id, data); + globalConfig.setCurrentSession(id); + globalConfig.delete('endpoint'); + globalConfig.delete('cookie'); + +} module.exports = { -{% if sdk.test != "true" %} + {% if sdk.test != "true" %} + loginCommand, + whoami, + register, login, logout, -{% endif %} + {% endif %} + migrate, client }; diff --git a/templates/cli/lib/commands/init.js.twig b/templates/cli/lib/commands/init.js.twig index 4e4f1a39f..c3f24cfb8 100644 --- a/templates/cli/lib/commands/init.js.twig +++ b/templates/cli/lib/commands/init.js.twig @@ -3,58 +3,188 @@ const path = require("path"); const childProcess = require('child_process'); const { Command } = require("commander"); const inquirer = require("inquirer"); -const { teamsCreate, teamsList } = require("./teams"); -const { projectsCreate } = require("./projects"); +const { fetch } = require("undici"); +const { projectsCreate, projectsGet } = require("./projects"); +const { storageCreateBucket } = require("./storage"); +const { messagingCreateTopic } = require("./messaging"); const { functionsCreate } = require("./functions"); -const { databasesGet, databasesListCollections, databasesList } = require("./databases"); -const { storageListBuckets } = require("./storage"); +const { databasesCreateCollection } = require("./databases"); +const ID = require("../id"); +const { localConfig, globalConfig } = require("../config"); +const { + questionsCreateFunction, + questionsCreateFunctionSelectTemplate, + questionsCreateBucket, + questionsCreateMessagingTopic, + questionsCreateCollection, + questionsInitProject, + questionsInitResources, + questionsCreateTeam +} = require("../questions"); +const { success, log, error, actionRunner, commandDescriptions } = require("../parser"); +const { accountGet } = require("./account"); const { sdkForConsole } = require("../sdks"); -const { localConfig } = require("../config"); -const { paginate } = require("../paginate"); -const { questionsInitProject, questionsInitFunction, questionsInitCollection } = require("../questions"); -const { success, log, actionRunner, commandDescriptions } = require("../parser"); -const init = new Command("init") - .description(commandDescriptions['init']) - .configureHelp({ - helpWidth: process.stdout.columns || 80 - }) - .action(actionRunner(async (_options, command) => { - command.help(); - })); - -const initProject = async () => { - let response = {} - const answers = await inquirer.prompt(questionsInitProject) - if (!answers.project) process.exit(1) - - let sdk = await sdkForConsole(); - if (answers.start === "new") { - response = await teamsCreate({ - teamId: 'unique()', - name: answers.project, - sdk, - parseOutput: false - }) +const initResources = async () => { + const actions = { + function: initFunction, + collection: initCollection, + bucket: initBucket, + team: initTeam, + message: initTopic + } + + const answers = await inquirer.prompt(questionsInitResources[0]); + + const action = actions[answers.resource]; + if (action !== undefined) { + await action({ returnOnZero: true }); + } +}; + +const initProject = async ({ organizationId, projectId, projectName } = {}) => { + let response = {}; + + try { + if (globalConfig.getEndpoint() === '' || globalConfig.getCookie() === '') { + throw ''; + } + const client = await sdkForConsole(); + + await accountGet({ + parseOutput: false, + sdk: client + }); + } catch (e) { + error('Error Session not found. Please run `appwrite login` to create a session'); + process.exit(1); + } + + let answers = {}; + + if (!organizationId && !projectId && !projectName) { + answers = await inquirer.prompt(questionsInitProject) + if (answers.override === false) { + process.exit(1) + } + } else { + answers.start = 'existing'; + answers.project = {}; + answers.organization = {}; + + answers.organization = organizationId ?? (await inquirer.prompt(questionsInitProject[2])).organization; + answers.project.name = projectName ?? (await inquirer.prompt(questionsInitProject[3])).project; + answers.project = projectId ?? (await inquirer.prompt(questionsInitProject[4])).id; + + try { + await projectsGet({ projectId, parseOutput: false }); + } catch (e) { + if (e.code === 404) { + answers.start = 'new'; + answers.id = answers.project; + answers.project = answers.project.name; + } else { + throw e; + } + } + } - let teamId = response['$id']; + if (answers.start === 'new') { response = await projectsCreate({ projectId: answers.id, name: answers.project, - teamId, + teamId: answers.organization, parseOutput: false }) - localConfig.setProject(response['$id'], response.name); + localConfig.setProject(response['$id']); } else { - localConfig.setProject(answers.project.id, answers.project.name); + localConfig.setProject(answers.project); + } + + success(`Project successfully ${answers.start === 'existing' ? 'linked' : 'created'}. Details are now stored in appwrite.json file.`); + + log("Next you can use 'appwrite init' to create resources in your project, or use 'appwrite pull' and 'appwite push' to synchronize your project.") + + if(answers.start === 'existing') { + log("Since you connected to an existing project, we highly recommend to run 'appwrite pull all' to synchronize all of your existing resources."); } - success(); } +const initBucket = async () => { + const answers = await inquirer.prompt(questionsCreateBucket) + + localConfig.addBucket({ + $id: answers.id === 'unique()' ? ID.unique() : answers.id, + name: answers.bucket, + fileSecurity: answers.fileSecurity.toLowerCase() === 'yes', + enabled: true, + }); + success(); + log("Next you can use 'appwrite push bucket' to deploy the changes."); +}; + +const initTeam = async () => { + const answers = await inquirer.prompt(questionsCreateTeam) + + localConfig.addTeam({ + $id: answers.id === 'unique()' ? ID.unique() : answers.id, + name: answers.bucket, + }); + + success(); + log("Next you can use 'appwrite push team' to deploy the changes."); +}; + +const initCollection = async () => { + const answers = await inquirer.prompt(questionsCreateCollection) + const newDatabase = (answers.method ?? '').toLowerCase() !== 'existing'; + + if (!newDatabase) { + answers.databaseId = answers.database; + answers.databaseName = localConfig.getDatabase(answers.database).name; + } + + const databaseId = answers.databaseId === 'unique()' ? ID.unique() : answers.databaseId; + + if (newDatabase || !localConfig.getDatabase(answers.databaseId)) { + localConfig.addDatabase({ + $id: databaseId, + name: answers.databaseName, + enabled: true + }); + } + + localConfig.addCollection({ + $id: answers.id === 'unique()' ? ID.unique() : answers.id, + databaseId: databaseId, + name: answers.collection, + documentSecurity: answers.documentSecurity.toLowerCase() === 'yes', + attributes: [], + indexes: [], + enabled: true, + }); + + success(); + log("Next you can use 'appwrite push collection' to deploy the changes."); +}; + +const initTopic = async () => { + const answers = await inquirer.prompt(questionsCreateMessagingTopic) + + localConfig.addMessagingTopic({ + $id: answers.id === 'unique()' ? ID.unique() : answers.id, + name: answers.topic, + + }); + + success(); + log("Next you can use 'appwrite push topic' to deploy the changes."); +}; + const initFunction = async () => { // TODO: Add CI/CD support (ID, name, runtime) - const answers = await inquirer.prompt(questionsInitFunction) + const answers = await inquirer.prompt(questionsCreateFunction) const functionFolder = path.join(process.cwd(), 'functions'); if (!fs.existsSync(functionFolder)) { @@ -63,34 +193,46 @@ const initFunction = async () => { }); } - const functionDir = path.join(functionFolder, answers.name); + const functionId = answers.id === 'unique()' ? ID.unique() : answers.id; + const functionDir = path.join(functionFolder, functionId); + const templatesDir = path.join(functionFolder, `${functionId}-templates`); + const runtimeDir = path.join(templatesDir, answers.runtime.name); if (fs.existsSync(functionDir)) { - throw new Error(`( ${answers.name} ) already exists in the current directory. Please choose another name.`); + throw new Error(`( ${functionId} ) already exists in the current directory. Please choose another name.`); } if (!answers.runtime.entrypoint) { - log(`Entrypoint for this runtime not found. You will be asked to configure entrypoint when you first deploy the function.`); + log(`Entrypoint for this runtime not found. You will be asked to configure entrypoint when you first push the function.`); } if (!answers.runtime.commands) { - log(`Installation command for this runtime not found. You will be asked to configure the install command when you first deploy the function.`); + log(`Installation command for this runtime not found. You will be asked to configure the install command when you first push the function.`); } - let response = await functionsCreate({ - functionId: answers.id, - name: answers.name, - runtime: answers.runtime.id, - entrypoint: answers.runtime.entrypoint || '', - commands: answers.runtime.commands || '', - parseOutput: false - }) fs.mkdirSync(functionDir, "777"); + fs.mkdirSync(templatesDir, "777"); + const repo = "https://github.com/{{ sdk.gitUserName }}/templates"; + const api = `https://api.github.com/repos/{{ sdk.gitUserName }}/templates/contents/${answers.runtime.name}` + const templates = ['starter']; + let selected = undefined; + + try { + const res = await fetch(api); + templates.push(...(await res.json()).map((template) => template.name)); - let gitInitCommands = "git clone -b v3 --single-branch --depth 1 --sparse https://github.com/{{ sdk.gitUserName }}/functions-starter ."; // depth prevents fetching older commits reducing the amount fetched + selected = await inquirer.prompt(questionsCreateFunctionSelectTemplate(templates)) + } catch { + // Not a problem will go with directory pulling + log('Loading templates...'); + } + + const sparse = (selected ? `${answers.runtime.name}/${selected.template}` : answers.runtime.name).toLowerCase(); - let gitPullCommands = `git sparse-checkout add ${answers.runtime.id}`; + let gitInitCommands = `git clone --single-branch --depth 1 --sparse ${repo} .`; // depth prevents fetching older commits reducing the amount fetched + + let gitPullCommands = `git sparse-checkout add ${sparse}`; /* Force use CMD as powershell does not support && */ if (process.platform === 'win32') { @@ -100,8 +242,8 @@ const initFunction = async () => { /* Execute the child process but do not print any std output */ try { - childProcess.execSync(gitInitCommands, { stdio: 'pipe', cwd: functionDir }); - childProcess.execSync(gitPullCommands, { stdio: 'pipe', cwd: functionDir }); + childProcess.execSync(gitInitCommands, { stdio: 'pipe', cwd: templatesDir }); + childProcess.execSync(gitPullCommands, { stdio: 'pipe', cwd: templatesDir }); } catch (error) { /* Specialised errors with recommended actions to take */ if (error.message.includes('error: unknown option')) { @@ -113,7 +255,18 @@ const initFunction = async () => { } } - fs.rmSync(path.join(functionDir, ".git"), { recursive: true }); + fs.rmSync(path.join(templatesDir, ".git"), { recursive: true }); + if (!selected) { + templates.push(...fs.readdirSync(runtimeDir, { withFileTypes: true }) + .filter(item => item.isDirectory() && item.name !== 'starter') + .map(dirent => dirent.name)); + selected = { template: 'starter' }; + + if (templates.length > 1) { + selected = await inquirer.prompt(questionsCreateFunctionSelectTemplate(templates)) + } + } + const copyRecursiveSync = (src, dest) => { let exists = fs.existsSync(src); let stats = exists && fs.statSync(src); @@ -130,11 +283,11 @@ const initFunction = async () => { fs.copyFileSync(src, dest); } }; - copyRecursiveSync(path.join(functionDir, answers.runtime.id), functionDir); + copyRecursiveSync(path.join(runtimeDir, selected.template), functionDir); - fs.rmSync(`${functionDir}/${answers.runtime.id}`, { recursive: true, force: true }); + fs.rmSync(templatesDir, { recursive: true, force: true }); - const readmePath = path.join(process.cwd(), 'functions', answers.name, 'README.md'); + const readmePath = path.join(process.cwd(), 'functions', functionId, 'README.md'); const readmeFile = fs.readFileSync(readmePath).toString(); const newReadmeFile = readmeFile.split('\n'); newReadmeFile[0] = `# ${answers.name}`; @@ -142,125 +295,67 @@ const initFunction = async () => { fs.writeFileSync(readmePath, newReadmeFile.join('\n')); let data = { - $id: response['$id'], - name: response.name, - runtime: response.runtime, - execute: response.execute, - events: response.events, - schedule: response.schedule, - timeout: response.timeout, - enabled: response.enabled, - logging: response.logging, - entrypoint: response.entrypoint, - commands: response.commands, + $id: functionId, + name: answers.name, + runtime: answers.runtime.id, + execute: [], + events: [], + schedule: "", + timeout: 15, + enabled: true, + logging: true, + entrypoint: answers.runtime.entrypoint || '', + commands: answers.runtime.commands || '', ignore: answers.runtime.ignore || null, - path: `functions/${answers.name}`, + path: `functions/${functionId}`, }; localConfig.addFunction(data); success(); + log("Next you can use 'appwrite run function' to develop a function locally. To deploy the function, use 'appwrite push function'"); } -const initCollection = async ({ all, databaseId } = {}) => { - const databaseIds = []; - - if (databaseId) { - databaseIds.push(databaseId); - } else if (all) { - let allDatabases = await databasesList({ - parseOutput: false - }) - - databaseIds.push(...allDatabases.databases.map((d) => d.$id)); - } - - if (databaseIds.length <= 0) { - let answers = await inquirer.prompt(questionsInitCollection) - if (!answers.databases) process.exit(1) - databaseIds.push(...answers.databases); - } - - for (const databaseId of databaseIds) { - const database = await databasesGet({ - databaseId, - parseOutput: false - }); - - localConfig.addDatabase(database); - - const { collections, total } = await paginate(databasesListCollections, { - databaseId, - parseOutput: false - }, 100, 'collections'); - - log(`Found ${total} collections`); - - collections.forEach(async collection => { - log(`Fetching ${collection.name} ...`); - localConfig.addCollection({ - ...collection, - '$createdAt': undefined, - '$updatedAt': undefined, - }); - }); - } - - success(); -} - -const initBucket = async () => { - const { buckets } = await paginate(storageListBuckets, { parseOutput: false }, 100, 'buckets'); - - log(`Found ${buckets.length} buckets`); - - buckets.forEach(async bucket => { - log(`Fetching ${bucket.name} ...`); - localConfig.addBucket(bucket); - }); - - success(); -} - -const initTeam = async () => { - const { teams } = await paginate(teamsList, { parseOutput: false }, 100, 'teams'); - - log(`Found ${teams.length} teams`); - - teams.forEach(async team => { - log(`Fetching ${team.name} ...`); - const { total, $updatedAt, $createdAt, prefs, ...rest } = team; - localConfig.addTeam(rest); - }); - - success(); -} +const init = new Command("init") + .description(commandDescriptions['init']) + .action(actionRunner(initResources)); init .command("project") - .description("Initialise your {{ spec.title|caseUcfirst }} project") + .description("Init a new {{ spec.title|caseUcfirst }} project") + .option("--organizationId ", "{{ spec.title|caseUcfirst }} organization ID") + .option("--projectId ", "{{ spec.title|caseUcfirst }} project ID") + .option("--projectName ", "{{ spec.title|caseUcfirst }} project ID") .action(actionRunner(initProject)); init .command("function") - .description("Initialise your {{ spec.title|caseUcfirst }} cloud function") - .action(actionRunner(initFunction)) - -init - .command("collection") - .description("Initialise your {{ spec.title|caseUcfirst }} collections") - .option(`--databaseId `, `Database ID`) - .option(`--all`, `Flag to initialize all databases`) - .action(actionRunner(initCollection)) + .alias("functions") + .description("Init a new {{ spec.title|caseUcfirst }} function") + .action(actionRunner(initFunction)); init .command("bucket") - .description("Initialise your Appwrite buckets") - .action(actionRunner(initBucket)) + .alias("buckets") + .description("Init a new {{ spec.title|caseUcfirst }} bucket") + .action(actionRunner(initBucket)); init .command("team") - .description("Initialise your Appwrite teams") - .action(actionRunner(initTeam)) + .alias("teams") + .description("Init a new {{ spec.title|caseUcfirst }} team") + .action(actionRunner(initTeam)); + +init + .command("collection") + .alias("collections") + .description("Init a new {{ spec.title|caseUcfirst }} collection") + .action(actionRunner(initCollection)); + +init + .command("topic") + .alias("topics") + .description("Init a new {{ spec.title|caseUcfirst }} topic") + .action(actionRunner(initTopic)); module.exports = { init, diff --git a/templates/cli/lib/commands/pull.js.twig b/templates/cli/lib/commands/pull.js.twig new file mode 100644 index 000000000..0b56d0d45 --- /dev/null +++ b/templates/cli/lib/commands/pull.js.twig @@ -0,0 +1,231 @@ +const fs = require("fs"); +const tar = require("tar"); +const { Command } = require("commander"); +const inquirer = require("inquirer"); +const { messagingListTopics } = require("./messaging"); +const { teamsList } = require("./teams"); +const { projectsGet } = require("./projects"); +const { functionsList, functionsDownloadDeployment } = require("./functions"); +const { databasesGet, databasesListCollections, databasesList } = require("./databases"); +const { storageListBuckets } = require("./storage"); +const { localConfig } = require("../config"); +const { paginate } = require("../paginate"); +const { questionsPullCollection, questionsPullFunctions, questionsPullResources } = require("../questions"); +const { cliConfig, success, log, actionRunner, commandDescriptions } = require("../parser"); + +const pullResources = async () => { + const actions = { + project: pullProject, + functions: pullFunctions, + collections: pullCollection, + buckets: pullBucket, + teams: pullTeam, + messages: pullMessagingTopic + } + + if (cliConfig.all) { + for (let action of Object.values(actions)) { + await action(); + } + } else { + const answers = await inquirer.prompt(questionsPullResources[0]); + + const action = actions[answers.resource]; + if (action !== undefined) { + await action({ returnOnZero: true }); + } + } +}; + +const pullProject = async () => { + try { + let response = await projectsGet({ + parseOutput: false, + projectId: localConfig.getProject().projectId + + }) + + localConfig.setProject(response.$id, response.name, response); + + success(); + } catch (e) { + throw e; + } +} + +const pullFunctions = async () => { + const localFunctions = localConfig.getFunctions(); + + const functions = cliConfig.all + ? (await paginate(functionsList, { parseOutput: false }, 100, 'functions')).functions + : (await inquirer.prompt(questionsPullFunctions)).functions; + + log(`Pulling ${functions.length} functions`); + + for (let func of functions) { + const functionExistLocally = localFunctions.find((localFunc) => localFunc['$id'] === func['$id']) !== undefined; + + if (functionExistLocally) { + localConfig.updateFunction(func['$id'], func); + } else { + func['path'] = `functions/${func['$id']}`; + localConfig.addFunction(func); + localFunctions.push(func); + } + + const localFunction = localFunctions.find((localFunc) => localFunc['$id'] === func['$id']); + + if (localFunction['deployment'] === '') { + continue + } + + const compressedFileName = `${func['$id']}-${+new Date()}.tar.gz` + + await functionsDownloadDeployment({ + functionId: func['$id'], + deploymentId: func['deployment'], + destination: compressedFileName, + overrideForCli: true, + parseOutput: false + }) + + if (!fs.existsSync(localFunction['path'])) { + fs.mkdirSync(localFunction['path'], { recursive: true }); + } + + tar.extract({ + sync: true, + cwd: localFunction['path'], + file: compressedFileName, + strict: false, + }); + + fs.rmSync(compressedFileName); + success(`Pulled ${func['name']} code and settings`) + } +} + +const pullCollection = async () => { + let databases = cliConfig.ids; + + if (databases.length === 0) { + if (cliConfig.all) { + databases = (await paginate(databasesList, { parseOutput: false }, 100, 'databases')).databases.map(database => database.$id); + } else { + databases = (await inquirer.prompt(questionsPullCollection)).databases; + } + } + + for (const databaseId of databases) { + const database = await databasesGet({ + databaseId, + parseOutput: false + }); + + localConfig.addDatabase(database); + + const { collections, total } = await paginate(databasesListCollections, { + databaseId, + parseOutput: false + }, 100, 'collections'); + + log(`Found ${total} collections`); + + collections.map(async collection => { + log(`Fetching ${collection.name} ...`); + localConfig.addCollection({ + ...collection, + '$createdAt': undefined, + '$updatedAt': undefined + }); + }); + } + + success(); +} + +const pullBucket = async () => { + const { buckets } = await paginate(storageListBuckets, { parseOutput: false }, 100, 'buckets'); + + log(`Found ${buckets.length} buckets`); + + buckets.forEach(bucket => localConfig.addBucket(bucket)); + + success(); +} + +const pullTeam = async () => { + const { teams } = await paginate(teamsList, { parseOutput: false }, 100, 'teams'); + + log(`Found ${teams.length} teams`); + + teams.forEach(team => { + const { total, $updatedAt, $createdAt, prefs, ...rest } = team; + localConfig.addTeam(rest); + }); + + success(); +} + +const pullMessagingTopic = async () => { + const { topics } = await paginate(messagingListTopics, { parseOutput: false }, 100, 'topics'); + + log(`Found ${topics.length} topics`); + + topics.forEach(topic => { + localConfig.addMessagingTopic(topic); + }); + + success(); +} + +const pull = new Command("pull") + .description(commandDescriptions['pull']) + .action(actionRunner(pullResources)); + +pull + .command("all") + .description("Pull all resource.") + .action(actionRunner(() => { + cliConfig.all = true; + return pullResources(); + })); + +pull + .command("project") + .description("Pull your {{ spec.title|caseUcfirst }} project name, services and auth settings") + .action(actionRunner(pullProject)); + +pull + .command("function") + .alias("functions") + .description("Pulling your {{ spec.title|caseUcfirst }} cloud function") + .action(actionRunner(pullFunctions)) + +pull + .command("collection") + .alias("collections") + .description("Pulling your {{ spec.title|caseUcfirst }} collections") + .action(actionRunner(pullCollection)) + +pull + .command("bucket") + .alias("buckets") + .description("Pulling your Appwrite buckets") + .action(actionRunner(pullBucket)) + +pull + .command("team") + .alias("teams") + .description("Pulling your Appwrite teams") + .action(actionRunner(pullTeam)) + +pull + .command("topic") + .alias("topics") + .description("Initialise your Appwrite messaging topics") + .action(actionRunner(pullMessagingTopic)) + +module.exports = { + pull, +}; diff --git a/templates/cli/lib/commands/push.js.twig b/templates/cli/lib/commands/push.js.twig new file mode 100644 index 000000000..1349b73e1 --- /dev/null +++ b/templates/cli/lib/commands/push.js.twig @@ -0,0 +1,1518 @@ +const chalk = require('chalk'); +const inquirer = require("inquirer"); +const JSONbig = require("json-bigint")({ storeAsString: false }); +const { Command } = require("commander"); +const { localConfig, globalConfig } = require("../config"); +const { Spinner, SPINNER_ARC, SPINNER_DOTS } = require('../spinner'); +const { paginate } = require('../paginate'); +const { questionsPushBuckets, questionsPushTeams, questionsPushFunctions, questionsGetEntrypoint, questionsPushCollections, questionsConfirmPushCollections, questionsPushMessagingTopics, questionsPushResources } = require("../questions"); +const { cliConfig, actionRunner, success, log, error, commandDescriptions, drawTable } = require("../parser"); +const { proxyListRules } = require('./proxy'); +const { functionsGet, functionsCreate, functionsUpdate, functionsCreateDeployment, functionsUpdateDeployment, functionsGetDeployment, functionsListVariables, functionsDeleteVariable, functionsCreateVariable } = require('./functions'); +const { + databasesGet, + databasesCreate, + databasesUpdate, + databasesCreateBooleanAttribute, + databasesGetCollection, + databasesCreateCollection, + databasesCreateStringAttribute, + databasesCreateIntegerAttribute, + databasesCreateFloatAttribute, + databasesCreateEmailAttribute, + databasesCreateDatetimeAttribute, + databasesCreateIndex, + databasesCreateUrlAttribute, + databasesCreateIpAttribute, + databasesCreateEnumAttribute, + databasesUpdateBooleanAttribute, + databasesUpdateStringAttribute, + databasesUpdateIntegerAttribute, + databasesUpdateFloatAttribute, + databasesUpdateEmailAttribute, + databasesUpdateDatetimeAttribute, + databasesUpdateUrlAttribute, + databasesUpdateIpAttribute, + databasesUpdateEnumAttribute, + databasesUpdateRelationshipAttribute, + databasesCreateRelationshipAttribute, + databasesDeleteAttribute, + databasesListAttributes, + databasesListIndexes, + databasesUpdateCollection +} = require("./databases"); +const { + storageGetBucket, storageUpdateBucket, storageCreateBucket +} = require("./storage"); +const { + messagingGetTopic, messagingUpdateTopic, messagingCreateTopic +} = require("./messaging"); +const { + teamsGet, + teamsUpdateName, + teamsCreate +} = require("./teams"); +const { + projectsUpdate, + projectsUpdateServiceStatus, + projectsUpdateAuthStatus, + projectsUpdateAuthDuration, + projectsUpdateAuthLimit, + projectsUpdateAuthSessionsLimit, + projectsUpdateAuthPasswordDictionary, + projectsUpdateAuthPasswordHistory, + projectsUpdatePersonalDataCheck, +} = require("./projects"); +const { checkDeployConditions } = require('../utils'); + +const STEP_SIZE = 100; // Resources +const POLL_DEBOUNCE = 2000; // Milliseconds +const POLL_MAX_DEBOUNCE = 30; // Times + +let pollMaxDebounces = 30; + +const changeableKeys = ['status', 'required', 'xdefault', 'elements', 'min', 'max', 'default', 'error']; + +const awaitPools = { + wipeAttributes: async (databaseId, collectionId, iteration = 1) => { + if (iteration > pollMaxDebounces) { + return false; + } + + const { total } = await databasesListAttributes({ + databaseId, + collectionId, + queries: [JSON.stringify({ method: 'limit', values: [1] })], + parseOutput: false + }); + + if (total === 0) { + return true; + } + + 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') + } + + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + + return await awaitPools.wipeAttributes( + databaseId, + collectionId, + iteration + 1 + ); + }, + wipeIndexes: async (databaseId, collectionId, iteration = 1) => { + if (iteration > pollMaxDebounces) { + return false; + } + + const { total } = await databasesListIndexes({ + databaseId, + collectionId, + queries: [JSON.stringify({ method: 'limit', values: [1] })], + parseOutput: false + }); + + if (total === 0) { + return true; + } + + 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') + } + + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + + return await awaitPools.wipeIndexes( + databaseId, + collectionId, + iteration + 1 + ); + }, + wipeVariables: async (functionId, iteration = 1) => { + if (iteration > pollMaxDebounces) { + return false; + } + + const { total } = await functionsListVariables({ + functionId, + queries: ['limit(1)'], + parseOutput: false + }); + + if (total === 0) { + return true; + } + + let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); + if (steps > 1 && iteration === 1) { + pollMaxDebounces *= steps; + + log('Found a large number of variables, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') + } + + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + + return await awaitPools.wipeVariables( + functionId, + iteration + 1 + ); + }, + deleteAttributes: async (databaseId, collectionId, attributeKeys, iteration = 1) => { + if (iteration > pollMaxDebounces) { + return false; + } + + let steps = Math.max(1, Math.ceil(attributeKeys.length / STEP_SIZE)); + if (steps > 1 && iteration === 1) { + pollMaxDebounces *= steps; + + log('Found a large number of attributes to be deleted. Increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') + } + + const { attributes } = await paginate(databasesListAttributes, { + databaseId, + collectionId, + parseOutput: false + }, 100, 'attributes'); + + const ready = attributeKeys.filter(attribute => attributes.includes(attribute.key)); + + if (ready.length === 0) { + return true; + } + + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + + return await awaitPools.expectAttributes( + databaseId, + collectionId, + attributeKeys, + iteration + 1 + ); + }, + expectAttributes: async (databaseId, collectionId, attributeKeys, iteration = 1) => { + if (iteration > pollMaxDebounces) { + return false; + } + + let steps = Math.max(1, Math.ceil(attributeKeys.length / STEP_SIZE)); + if (steps > 1 && iteration === 1) { + pollMaxDebounces *= steps; + + log('Creating a large number of attributes, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') + } + + const { attributes } = await paginate(databasesListAttributes, { + databaseId, + collectionId, + parseOutput: false + }, 100, 'attributes'); + + const ready = attributes + .filter(attribute => { + 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 => attribute.key); + + if (ready.length === attributeKeys.length) { + return true; + } + + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + + return await awaitPools.expectAttributes( + databaseId, + collectionId, + attributeKeys, + iteration + 1 + ); + }, + expectIndexes: async (databaseId, collectionId, indexKeys, iteration = 1) => { + if (iteration > pollMaxDebounces) { + return false; + } + + let steps = Math.max(1, Math.ceil(indexKeys.length / STEP_SIZE)); + if (steps > 1 && iteration === 1) { + pollMaxDebounces *= steps; + + log('Creating a large number of indexes, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') + } + + const { indexes } = await paginate(databasesListIndexes, { + databaseId, + collectionId, + parseOutput: false + }, 100, 'indexes'); + + const ready = indexes + .filter((index) => { + 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 => index.key); + + if (ready.length >= indexKeys.length) { + return true; + } + + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + + return await awaitPools.expectIndexes( + databaseId, + collectionId, + indexKeys, + iteration + 1 + ); + }, +} + +const createAttribute = async (databaseId, collectionId, attribute) => { + switch (attribute.type) { + case 'string': + switch (attribute.format) { + case 'email': + return await databasesCreateEmailAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'url': + return await databasesCreateUrlAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'ip': + return await databasesCreateIpAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'enum': + return await databasesCreateEnumAttribute({ + databaseId, + collectionId, + key: attribute.key, + elements: attribute.elements, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + default: + return await databasesCreateStringAttribute({ + databaseId, + collectionId, + key: attribute.key, + size: attribute.size, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + + } + case 'integer': + return await databasesCreateIntegerAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: parseInt(attribute.min.toString()), + max: parseInt(attribute.max.toString()), + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'double': + return databasesCreateFloatAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: parseFloat(attribute.min.toString()), + max: parseFloat(attribute.max.toString()), + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'boolean': + return databasesCreateBooleanAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'datetime': + return databasesCreateDatetimeAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'relationship': + return databasesCreateRelationshipAttribute({ + databaseId, + collectionId, + relatedCollectionId: attribute.relatedCollection, + type: attribute.relationType, + twoWay: attribute.twoWay, + key: attribute.key, + twoWayKey: attribute.twoWayKey, + onDelete: attribute.onDelete, + parseOutput: false + }) + } +} + +const updateAttribute = async (databaseId, collectionId, attribute) => { + switch (attribute.type) { + case 'string': + switch (attribute.format) { + case 'email': + return await databasesUpdateEmailAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'url': + return await databasesUpdateUrlAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'ip': + return await databasesUpdateIpAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'enum': + return await databasesUpdateEnumAttribute({ + databaseId, + collectionId, + key: attribute.key, + elements: attribute.elements, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + default: + return await databasesUpdateStringAttribute({ + databaseId, + collectionId, + key: attribute.key, + size: attribute.size, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + + } + case 'integer': + return await databasesUpdateIntegerAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: parseInt(attribute.min.toString()), + max: parseInt(attribute.max.toString()), + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'double': + return databasesUpdateFloatAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + min: parseFloat(attribute.min.toString()), + max: parseFloat(attribute.max.toString()), + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'boolean': + return databasesUpdateBooleanAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'datetime': + return databasesUpdateDatetimeAttribute({ + databaseId, + collectionId, + key: attribute.key, + required: attribute.required, + xdefault: attribute.default, + array: attribute.array, + parseOutput: false + }) + case 'relationship': + return databasesUpdateRelationshipAttribute({ + databaseId, + collectionId, + relatedCollectionId: attribute.relatedCollection, + type: attribute.relationType, + twoWay: attribute.twoWay, + key: attribute.key, + twoWayKey: attribute.twoWayKey, + onDelete: attribute.onDelete, + parseOutput: false + }) + } +} +const deleteAttribute = async (collection, attribute) => { + log(`Deleting attribute ${attribute.key} of ${collection.name} ( ${collection['$id']} )`); + + await databasesDeleteAttribute({ + databaseId: collection['databaseId'], + collectionId: collection['$id'], + key: attribute.key, + parseOutput: false + }); +} + + +/** + * Check if attribute non-changeable fields has been changed + * If so return the differences as an object. + * @param remote + * @param local + * @param collection + * @param recraeting when true will check only non-changeable keys + * @returns {undefined|{reason: string, action: *, attribute, key: string}} + */ +const checkAttributeChanges = (remote, local, collection, recraeting = true) => { + if (local === undefined) { + return undefined; + } + + const keyName = `${chalk.yellow(local.key)} in ${collection.name} (${collection['$id']})`; + const action = chalk.cyan(recraeting ? 'recreating' : 'changing'); + let reason = ''; + let attribute = remote; + + for (let key of Object.keys(remote)) { + if (changeableKeys.includes(key)) { + if (!recraeting) { + if (remote[key] !== local[key]) { + const bol = reason === '' ? '' : '\n'; + reason += `${bol}${key} changed from ${chalk.red(remote[key])} to ${chalk.green(local[key])}`; + attribute = local; + } + } + continue; + } + + if (!recraeting) { + continue; + } + + if (remote[key] !== local[key]) { + const bol = reason === '' ? '' : '\n'; + reason += `${bol}${key} changed from ${chalk.red(remote[key])} to ${chalk.green(local[key])}`; + } + } + + return reason === '' ? undefined : { key: keyName, attribute, reason, action }; +} + +/** + * Check if attributes contain the given attribute + * @param attribute + * @param attributes + * @returns {*} + */ +const attributesContains = (attribute, attributes) => attributes.find((attr) => attr.key === attribute.key); +const generateChangesObject = (attribute, collection, isAdding) => { + return { + key: `${chalk.yellow(attribute.key)} in ${collection.name} (${collection['$id']})`, + attribute: attribute, + reason: isAdding ? 'Field doesn\'t exist on the remote server' : 'Field doesn\'t exist in appwrite.json file', + action: isAdding ? chalk.green('adding') : chalk.red('deleting') + }; + +}; + +/** + * Filter deleted and recreated attributes, + * return list of attributes to create + * @param remoteAttributes + * @param localAttributes + * @param collection + * @returns {Promise<*|*[]>} + */ +const attributesToCreate = async (remoteAttributes, localAttributes, collection) => { + + 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); + 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); + + let changedAttributes = []; + 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) { + log(`Attribute deletion will cause ${chalk.red('loss of data')}`); + } + if (conflicts.length > 0) { + log(`Attribute recreation will cause ${chalk.red('loss of data')}`); + } + + const answers = await inquirer.prompt(questionsPushCollections[1]); + + if (answers.changes.toLowerCase() !== 'yes') { + return changedAttributes; + } + } + + if (conflicts.length > 0) { + changedAttributes = conflicts.map((change) => change.attribute); + await Promise.all(changedAttributes.map((changed) => deleteAttribute(collection, changed))); + remoteAttributes = remoteAttributes.filter((attribute) => !attributesContains(attribute, changedAttributes)) + } + + if (changes.length > 0) { + changedAttributes = changes.map((change) => change.attribute); + await Promise.all(changedAttributes.map((changed) => updateAttribute(collection['databaseId'],collection['$id'], changed))); + } + + const deletingAttributes = deleting.map((change) => change.attribute); + await Promise.all(deletingAttributes.map((attribute) => deleteAttribute(collection, attribute))); + const attributeKeys = [...remoteAttributes.map(attribute => attribute.key), ...deletingAttributes.map(attribute => attribute.key)] + + if (attributeKeys.length) { + const deleteAttributesPoolStatus = await awaitPools.deleteAttributes(collection['databaseId'], collection['$id'], attributeKeys); + + if (!deleteAttributesPoolStatus) { + throw new Error("Attribute deletion timed out."); + } + } + + return localAttributes.filter((attribute) => !attributesContains(attribute, remoteAttributes)); +} +const createIndexes = async (indexes, collection) => { + log(`Creating indexes ...`) + + for (let index of indexes) { + await databasesCreateIndex({ + databaseId: collection['databaseId'], + collectionId: collection['$id'], + key: index.key, + type: index.type, + attributes: index.attributes, + orders: index.orders, + parseOutput: false + }); + } + + const result = await awaitPools.expectIndexes( + collection['databaseId'], + collection['$id'], + indexes.map(index => index.key) + ); + + if (!result) { + throw new Error("Index creation timed out."); + } + + success(`Created ${indexes.length} indexes`); +} +const createAttributes = async (attributes, collection) => { + for (let attribute of attributes) { + if (attribute.side !== 'child') { + await createAttribute(collection['databaseId'], collection['$id'], attribute); + } + } + + const result = await awaitPools.expectAttributes( + collection['databaseId'], + collection['$id'], + collection.attributes.map(attribute => attribute.key) + ); + + if (!result) { + throw new Error(`Attribute creation timed out.`); + } + + success(`Created ${attributes.length} attributes`); +} + +const pushResources = async () => { + const actions = { + project: pushProject, + functions: pushFunction, + collections: pushCollection, + buckets: pushBucket, + teams: pushTeam, + messages: pushMessagingTopic + } + + if (cliConfig.all) { + for (let action of Object.values(actions)) { + await action({ returnOnZero: true }); + } + } else { + const answers = await inquirer.prompt(questionsPushResources[0]); + + const action = actions[answers.resource]; + if (action !== undefined) { + await action({ returnOnZero: true }); + } + } +}; + +const pushProject = async () => { + try { + const projectId = localConfig.getProject().projectId; + const projectName = localConfig.getProject().projectName; + const settings = localConfig.getProject().projectSettings ?? {}; + + log(`Updating project ${projectId}`); + + if (projectName) { + await projectsUpdate({ + projectId, + name: projectName, + parseOutput: false + }); + } + + if (settings.services) { + log('Updating service statuses'); + for (let [service, status] of Object.entries(settings.services)) { + await projectsUpdateServiceStatus({ + projectId, + service, + status, + parseOutput: false + }); + } + } + + if (settings.auth) { + if (settings.auth.security) { + log('Updating auth security settings'); + await projectsUpdateAuthDuration({ projectId, duration: settings.auth.security.duration, parseOutput: false }); + await projectsUpdateAuthLimit({ projectId, limit: settings.auth.security.limit, parseOutput: false }); + await projectsUpdateAuthSessionsLimit({ projectId, limit: settings.auth.security.sessionsLimit, parseOutput: false }); + await projectsUpdateAuthPasswordDictionary({ projectId, enabled: settings.auth.security.passwordDictionary, parseOutput: false }); + await projectsUpdateAuthPasswordHistory({ projectId, limit: settings.auth.security.passwordHistory, parseOutput: false }); + await projectsUpdatePersonalDataCheck({ projectId, enabled: settings.auth.security.personalDataCheck, parseOutput: false }); + } + + if (settings.auth.methods) { + log('Updating auth login methods'); + + for (let [method, status] of Object.entries(settings.auth.methods)) { + await projectsUpdateAuthStatus({ + projectId, + method, + status, + parseOutput: false + }); + } + } + } + + success("Project configuration updated."); + } catch (e) { + throw e; + } +} + +const pushFunction = async ({ functionId, async, returnOnZero } = { returnOnZero: false }) => { + let response = {}; + + const functionIds = []; + + if (functionId) { + functionIds.push(functionId); + } else if (cliConfig.all) { + checkDeployConditions(localConfig); + const functions = localConfig.getFunctions(); + if (functions.length === 0) { + if (returnOnZero) { + log('No functions found, skipping'); + return; + } + throw new Error("No functions found in the current directory. Use 'appwrite pull functions' to synchronize existing one, or use 'appwrite init function' to create a new one."); + } + functionIds.push(...functions.map((func) => { + return func.$id; + })); + } + + if (functionIds.length <= 0) { + const answers = await inquirer.prompt(questionsPushFunctions[0]); + functionIds.push(...answers.functions); + } + + let functions = functionIds.map((id) => { + const functions = localConfig.getFunctions(); + const func = functions.find((f) => 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} does not have an endpoint`); + const answers = await inquirer.prompt(questionsGetEntrypoint) + func.entrypoint = answers.entrypoint; + localConfig.updateFunction(func['$id'], func); + } + + if (func.variables) { + func.pushVariables = cliConfig.force; + + try { + const { total } = await functionsListVariables({ + functionId: func['$id'], + queries: [JSON.stringify({ method: 'limit', values: [1] })], + parseOutput: false + }); + + if (total === 0) { + func.pushVariables = true; + } else if (total > 0 && !func.pushVariables) { + log(`The function ${func.name} has remote variables setup`); + const variableAnswers = await inquirer.prompt(questionsPushFunctions[1]) + func.pushVariables = variableAnswers.override.toLowerCase() === "yes"; + } + } catch (e) { + if (e.code != 404) { + throw e.message; + } + } + } + } + + + log('All functions are validated'); + log('Pushing functions\n'); + + Spinner.start(false); + let successfullyPushed = 0; + let successfullyDeployed = 0; + const failedDeployments = []; + + await Promise.all(functions.map(async (func) => { + const ignore = func.ignore ? 'appwrite.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); + + try { + response = await functionsGet({ + functionId: func['$id'], + parseOutput: false, + }); + 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.json` }) + return; + } + + updaterRow.update({ status: 'Updating' }).replaceSpinner(SPINNER_ARC); + + response = await functionsUpdate({ + functionId: func['$id'], + name: func.name, + execute: func.execute, + events: func.events, + schedule: func.schedule, + timeout: func.timeout, + enabled: func.enabled, + logging: func.logging, + entrypoint: func.entrypoint, + commands: func.commands, + providerRepositoryId: func.providerRepositoryId ?? "", + installationId: func.installationId ?? '', + providerBranch: func.providerBranch ?? '', + providerRootDirectory: func.providerRootDirectory ?? '', + providerSilentMode: func.providerSilentMode ?? false, + vars: JSON.stringify(response.vars), + parseOutput: false + }); + } catch (e) { + + if (Number(e.code) === 404) { + functionExists = false; + } else { + updaterRow.fail({ errorMessage: e.message ?? 'General error occurs please try again' }); + return; + } + } + + if (!functionExists) { + updaterRow.update({ status: 'Creating' }).replaceSpinner(SPINNER_DOTS); + + try { + response = await functionsCreate({ + functionId: func.$id || 'unique()', + 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, + vars: JSON.stringify(func.vars), + parseOutput: false + }); + + localConfig.updateFunction(func['$id'], { + "$id": response['$id'], + }); + func["$id"] = response['$id']; + updaterRow.update({ status: 'Created' }); + } catch (e) { + updaterRow.fail({ errorMessage: e.message ?? 'General error occurs please try again' }); + return; + } + } + + if (func.variables) { + if (!func.pushVariables) { + updaterRow.update({ end: 'Skipping variables' }); + } else { + updaterRow.update({ end: 'Pushing variables' }); + + const { variables } = await paginate(functionsListVariables, { + functionId: func['$id'], + parseOutput: false + }, 100, 'variables'); + + await Promise.all(variables.map(async variable => { + await functionsDeleteVariable({ + functionId: func['$id'], + variableId: variable['$id'], + parseOutput: false + }); + })); + + let result = await awaitPools.wipeVariables(func['$id']); + if (!result) { + updaterRow.fail({ errorMessage: 'Variable deletion timed out' }) + return; + } + + // Push local variables + await Promise.all(Object.keys(func.variables).map(async localVariableKey => { + await functionsCreateVariable({ + functionId: func['$id'], + key: localVariableKey, + value: func.variables[localVariableKey], + parseOutput: false + }); + })); + } + } + + try { + updaterRow.update({ status: 'Pushing' }).replaceSpinner(SPINNER_ARC); + response = await functionsCreateDeployment({ + functionId: func['$id'], + entrypoint: func.entrypoint, + commands: func.commands, + code: func.path, + activate: true, + parseOutput: false + }) + + updaterRow.update({ status: 'Pushed' }); + deploymentCreated = true; + successfullyPushed++; + } catch (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) { + if (pollChecks >= POLL_MAX_DEBOUNCE) { + updaterRow.update({ end: 'Deployment is taking too long. Please check the console for more details.' }) + break; + } + + response = await functionsGetDeployment({ + functionId: func['$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": "resourceType", "values": ["function"] }), + JSON.stringify({ method: 'equal', "attribute": "resourceId", "values": [func['$id']] }) + ], + }); + + if (Number(res.total) === 1) { + url = 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}` }) + } + + pollChecks++; + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + } + } catch (e) { + updaterRow.fail({ errorMessage: e.message ?? 'Unknown error occurred. Please try again' }) + } + } + + updaterRow.stopSpinner(); + })); + + Spinner.stop(); + console.log('\n'); + + failedDeployments.forEach((failed) => { + const { name, deployment, $id } = failed; + const failUrl = `${globalConfig.getEndpoint().replace('/v1', '')}/console/project-${localConfig.getProject().projectId}/functions/function-${$id}/deployment-${deployment}`; + + error(`Deployment of ${name} has failed. Check at ${failUrl} for more details\n`); + }) + + let message = chalk.green(`Pushed and deployed ${successfullyPushed} functions`); + + if (!async) { + if (successfullyDeployed < successfullyPushed) { + message = `${chalk.green(`Pushed and deployed ${successfullyPushed} functions.`)} ${chalk.red(`${successfullyPushed - successfullyDeployed} failed to deploy`)}`; + } else { + if (successfullyPushed === 0) { + message = chalk.red(`Error pushing ${functions.length} functions`) + } + } + } + log(message); +} + +const pushCollection = async ({ returnOnZero } = { returnOnZero: false }) => { + const collections = []; + + if (cliConfig.all) { + checkDeployConditions(localConfig); + if (localConfig.getCollections().length === 0) { + if (returnOnZero) { + log('No collections found, skipping'); + return; + } + + throw new Error("No collections found in the current directory. Use 'appwrite pull collections' to synchronize existing one, or use 'appwrite init collection' to create a new one."); + } + collections.push(...localConfig.getCollections()); + } else { + const answers = await inquirer.prompt(questionsPushCollections[0]) + const configCollections = new Map(); + localConfig.getCollections().forEach((c) => { + configCollections.set(`${c['databaseId']}|${c['$id']}`, c); + }); + answers.collections.forEach((a) => { + const collection = configCollections.get(a); + collections.push(collection); + }) + } + const databases = Array.from(new Set(collections.map(collection => collection['databaseId']))); + log('Checking for databases and collection changes'); + + // Parallel db actions + await Promise.all(databases.map(async (databaseId) => { + const localDatabase = localConfig.getDatabase(databaseId); + + try { + const database = await databasesGet({ + databaseId: databaseId, + parseOutput: false, + }); + + if (database.name !== (localDatabase.name ?? databaseId)) { + await databasesUpdate({ + databaseId: databaseId, + name: localDatabase.name ?? databaseId, + parseOutput: false + }) + + success(`Updated ${localDatabase.name} ( ${databaseId} ) name`); + } + } catch (err) { + log(`Database ${databaseId} not found. Creating it now...`); + + await databasesCreate({ + databaseId: databaseId, + name: localDatabase.name ?? databaseId, + parseOutput: false, + }); + } + })); + + // Parallel collection actions + await Promise.all(collections.map(async (collection) => { + try { + const remoteCollection = await databasesGetCollection({ + databaseId: collection['databaseId'], + collectionId: collection['$id'], + parseOutput: false, + }); + + if (remoteCollection.name !== collection.name) { + await databasesUpdateCollection({ + databaseId: collection['databaseId'], + collectionId: collection['$id'], + name: collection.name, + name: collection.name, + parseOutput: false + }) + + success(`Updated ${collection.name} ( ${collection['$id']} ) name`); + } + collection.remoteVersion = remoteCollection; + + collection.isExisted = true; + } catch + (e) { + if (Number(e.code) === 404) { + log(`Collection ${collection.name} does not exist in the project. Creating ... `); + await databasesCreateCollection({ + databaseId: collection['databaseId'], + collectionId: collection['$id'], + name: collection.name, + documentSecurity: collection.documentSecurity, + permissions: collection['$permissions'], + parseOutput: false + }) + } else { + throw e; + } + } + })) + + // Serialize attribute actions + for (let collection of collections) { + let attributes = collection.attributes; + + if (collection.isExisted) { + attributes = await attributesToCreate(collection.remoteVersion.attributes, collection.attributes, collection); + + if (Array.isArray(attributes) && attributes.length <= 0) { + continue; + } + } + + log(`Pushing collection ${collection.name} ( ${collection['databaseId']} - ${collection['$id']} ) attributes`) + + try { + await createAttributes(attributes, collection) + } catch (e) { + throw e; + } + + try { + await createIndexes(collection.indexes, collection); + } catch (e) { + throw e; + } + + success(`Pushed ${collection.name} ( ${collection['$id']} )`); + } +} + +const pushBucket = async ({ returnOnZero } = { returnOnZero: false }) => { + let response = {}; + + let bucketIds = []; + const configBuckets = localConfig.getBuckets(); + + if (cliConfig.all) { + checkDeployConditions(localConfig); + if (configBuckets.length === 0) { + if (returnOnZero) { + log('No buckets found, skipping'); + return; + } + throw new Error("No buckets found in the current directory. Use 'appwrite pull buckets' to synchronize existing one, or use 'appwrite init bucket' to create a new one."); + } + bucketIds.push(...configBuckets.map((b) => b.$id)); + } + + if (bucketIds.length === 0) { + const answers = await inquirer.prompt(questionsPushBuckets[0]) + bucketIds.push(...answers.buckets); + } + + let buckets = []; + + for (const bucketId of bucketIds) { + const idBuckets = configBuckets.filter((b) => b.$id === bucketId); + buckets.push(...idBuckets); + } + + for (let bucket of buckets) { + log(`Pushing bucket ${bucket.name} ( ${bucket['$id']} )`) + + try { + response = await storageGetBucket({ + bucketId: bucket['$id'], + parseOutput: false, + }) + + log(`Updating bucket ...`) + + await storageUpdateBucket({ + 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, + parseOutput: false + }); + + success(`Pushed ${bucket.name} ( ${bucket['$id']} )`); + } catch (e) { + if (Number(e.code) === 404) { + log(`Bucket ${bucket.name} does not exist in the project. Creating ... `); + + response = await storageCreateBucket({ + 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, + parseOutput: false + }) + + success(`Pushed ${bucket.name} ( ${bucket['$id']} )`); + } else { + throw e; + } + } + } +} + +const pushTeam = async ({ returnOnZero } = { returnOnZero: false }) => { + let response = {}; + + let teamIds = []; + const configTeams = localConfig.getTeams(); + + if (cliConfig.all) { + checkDeployConditions(localConfig); + if (configTeams.length === 0) { + if (returnOnZero) { + log('No teams found, skipping'); + return; + } + throw new Error("No teams found in the current directory. Use 'appwrite pull teams' to synchronize existing one, or use 'appwrite init team' to create a new one."); + } + teamIds.push(...configTeams.map((t) => t.$id)); + } + + if (teamIds.length === 0) { + const answers = await inquirer.prompt(questionsPushTeams[0]) + teamIds.push(...answers.teams); + } + + let teams = []; + + for (const teamId of teamIds) { + const idTeams = configTeams.filter((t) => t.$id === teamId); + teams.push(...idTeams); + } + + for (let team of teams) { + log(`Pushing team ${team.name} ( ${team['$id']} )`) + + try { + response = await teamsGet({ + teamId: team['$id'], + parseOutput: false, + }) + + log(`Updating team ...`) + + await teamsUpdateName({ + teamId: team['$id'], + name: team.name, + parseOutput: false + }); + + success(`Pushed ${team.name} ( ${team['$id']} )`); + } catch (e) { + if (Number(e.code) === 404) { + log(`Team ${team.name} does not exist in the project. Creating ... `); + + response = await teamsCreate({ + teamId: team['$id'], + name: team.name, + parseOutput: false + }) + + success(`Pushed ${team.name} ( ${team['$id']} )`); + } else { + throw e; + } + } + } +} + +const pushMessagingTopic = async ({ returnOnZero } = { returnOnZero: false }) => { + let response = {}; + + let topicsIds = []; + const configTopics = localConfig.getMessagingTopics(); + let overrideExisting = cliConfig.force; + + if (cliConfig.all) { + checkDeployConditions(localConfig); + if (configTopics.length === 0) { + if (returnOnZero) { + log('No topics found, skipping'); + return; + } + throw new Error("No topics found in the current directory. Use 'appwrite pull topics' to synchronize existing one, or use 'appwrite init topic' to create a new one."); + } + topicsIds.push(...configTopics.map((b) => b.$id)); + } + + if (topicsIds.length === 0) { + const answers = await inquirer.prompt(questionsPushMessagingTopics[0]) + topicsIds.push(...answers.topics); + } + + let topics = []; + + for (const topicId of topicsIds) { + const idTopic = configTopics.filter((b) => b.$id === topicId); + topics.push(...idTopic); + } + + if (!cliConfig.force) { + const answers = await inquirer.prompt(questionsPushMessagingTopics[1]) + if (answers.override.toLowerCase() === "yes") { + overrideExisting = true; + } + } + + for (let topic of topics) { + log(`Pushing topic ${topic.name} ( ${topic['$id']} )`) + + try { + response = await messagingGetTopic({ + topicId: topic['$id'], + parseOutput: false + }) + log(`Topic ${topic.name} ( ${topic['$id']} ) already exists.`); + + if (!overrideExisting) { + log(`Skipping ${topic.name} ( ${topic['$id']} )`); + continue; + } + + log(`Updating Topic ...`) + + await messagingUpdateTopic({ + topicId: topic['$id'], + name: topic.name, + subscribe: topic.subscribe, + parseOutput: false + }); + + success(`Pushed ${topic.name} ( ${topic['$id']} )`); + } catch (e) { + if (Number(e.code) === 404) { + log(`Topic ${topic.name} does not exist in the project. Creating ... `); + + response = await messagingCreateTopic({ + topicId: topic['$id'], + name: topic.name, + subscribe: topic.subscribe, + parseOutput: false + }) + + success(`Created ${topic.name} ( ${topic['$id']} )`); + } else { + throw e; + } + } + } +} + +const push = new Command("push") + .description(commandDescriptions['push']) + .action(actionRunner(pushResources)); + +push + .command("all") + .description("Push all resource.") + .action(actionRunner(() => { + cliConfig.all = true; + return pushResources(); + })); + +push + .command("project") + .description("Push project name, services and auth settings") + .action(actionRunner(pushProject)); + +push + .command("function") + .alias("functions") + .description("Push functions in the current directory.") + .option(`-f, --functionId `, `Function ID`) + .option(`-A, --async`, `Don't wait for functions deployments status`) + .action(actionRunner(pushFunction)); + +push + .command("collection") + .alias("collections") + .description("Push collections in the current project.") + .action(actionRunner(pushCollection)); + +push + .command("bucket") + .alias("buckets") + .description("Push buckets in the current project.") + .action(actionRunner(pushBucket)); + +push + .command("team") + .alias("teams") + .description("Push teams in the current project.") + .action(actionRunner(pushTeam)); + +push + .command("topic") + .alias("topics") + .description("Push messaging topics in the current project.") + .action(actionRunner(pushMessagingTopic)); + +module.exports = { + push +} diff --git a/templates/cli/lib/commands/run.js.twig b/templates/cli/lib/commands/run.js.twig new file mode 100644 index 000000000..5d05e21cc --- /dev/null +++ b/templates/cli/lib/commands/run.js.twig @@ -0,0 +1,282 @@ +const Tail = require('tail').Tail; +const EventEmitter = require('node:events'); +const ignore = require("ignore"); +const tar = require("tar"); +const fs = require("fs"); +const ID = require("../id"); +const childProcess = require('child_process'); +const chokidar = require('chokidar'); +const inquirer = require("inquirer"); +const path = require("path"); +const { Command } = require("commander"); +const { localConfig, globalConfig } = require("../config"); +const { paginate } = require('../paginate'); +const { functionsListVariables } = require('./functions'); +const { usersGet, usersCreateJWT } = require('./users'); +const { projectsCreateJWT } = require('./projects'); +const { questionsRunFunctions } = require("../questions"); +const { actionRunner, success, log, error, commandDescriptions, drawTable } = require("../parser"); +const { systemHasCommand, isPortTaken, getAllFiles } = require('../utils'); +const { openRuntimesVersion, runtimeNames, systemTools, JwtManager, Queue } = require('../emulation/utils'); +const { dockerStop, dockerCleanup, dockerStart, dockerBuild, dockerPull, dockerStopActive } = require('../emulation/docker'); + +const runFunction = async ({ port, functionId, noVariables, noReload, userId } = {}) => { + // Selection + if(!functionId) { + const answers = await inquirer.prompt(questionsRunFunctions[0]); + functionId = answers.function; + } + + const functions = localConfig.getFunctions(); + const func = functions.find((f) => f.$id === functionId); + if (!func) { + throw new Error("Function '" + functionId + "' not found.") + } + + const runtimeName = func.runtime.split("-").slice(0, -1).join("-"); + const tool = systemTools[runtimeName]; + + // Configuration: Port + if(port) { + port = +port; + } + + if(isNaN(port)) { + port = null; + } + + if(port) { + const taken = await isPortTaken(port); + + if(taken) { + error(`Port ${port} is already in use by another process.`); + return; + } + } + + if(!port) { + let portFound = false; + port = 3000; + while(port < 3100) { + const taken = await isPortTaken(port); + if(!taken) { + portFound = true; + break; + } + + port++; + } + + if(!portFound) { + error('Could not find an available port. Please select a port with `appwrite run --port YOUR_PORT` command.'); + return; + } + } + + // Configuration: Engine + if(!systemHasCommand('docker')) { + return error("Docker Engine is required for local development. Please install Docker using: https://docs.docker.com/engine/install/"); + } + + // Settings + const settings = { + runtime: func.runtime, + entrypoint: func.entrypoint, + path: func.path, + commands: func.commands, + }; + + log("Local function configuration:"); + drawTable([settings]); + log('If you wish to change your local settings, update the appwrite.json file and rerun the `appwrite run` command.'); + + await dockerCleanup(); + + process.on('SIGINT', async () => { + log('Cleaning up ...'); + await dockerCleanup(); + success(); + process.exit(); + }); + + const logsPath = path.join(process.cwd(), func.path, '.appwrite/logs.txt'); + const errorsPath = path.join(process.cwd(), func.path, '.appwrite/errors.txt'); + + if(!fs.existsSync(path.dirname(logsPath))) { + fs.mkdirSync(path.dirname(logsPath), { recursive: true }); + } + + if (!fs.existsSync(logsPath)) { + fs.writeFileSync(logsPath, ''); + } + + if (!fs.existsSync(errorsPath)) { + fs.writeFileSync(errorsPath, ''); + } + + const variables = {}; + if(!noVariables) { + if (globalConfig.getEndpoint() === '' || globalConfig.getCookie() === '') { + error("No user is signed in. To sign in, run: appwrite login. Function will run locally, but will not have your function's environment variables set."); + } else { + try { + const { variables: remoteVariables } = await paginate(functionsListVariables, { + functionId: func['$id'], + parseOutput: false + }, 100, 'variables'); + + remoteVariables.forEach((v) => { + variables[v.key] = v.value; + }); + } catch(err) { + log("Could not fetch remote variables: " + err.message); + log("Function will run locally, but will not have your function's environment variables set."); + } + } + } + + variables['APPWRITE_FUNCTION_API_ENDPOINT'] = globalConfig.getFrom('endpoint'); + variables['APPWRITE_FUNCTION_ID'] = func.$id; + variables['APPWRITE_FUNCTION_NAME'] = func.name; + variables['APPWRITE_FUNCTION_DEPLOYMENT'] = ''; // TODO: Implement when relevant + variables['APPWRITE_FUNCTION_PROJECT_ID'] = localConfig.getProject().projectId; + variables['APPWRITE_FUNCTION_RUNTIME_NAME'] = runtimeNames[runtimeName] ?? ''; + variables['APPWRITE_FUNCTION_RUNTIME_VERSION'] = func.runtime; + + await JwtManager.setup(userId); + + const headers = {}; + headers['x-appwrite-key'] = JwtManager.functionJwt ?? ''; + headers['x-appwrite-trigger'] = 'http'; + headers['x-appwrite-event'] = ''; + headers['x-appwrite-user-id'] = userId ?? ''; + headers['x-appwrite-user-jwt'] = JwtManager.userJwt ?? ''; + variables['OPEN_RUNTIMES_HEADERS'] = JSON.stringify(headers); + + await dockerPull(func); + await dockerBuild(func, variables); + await dockerStart(func, variables, port); + + new Tail(logsPath).on("line", function(data) { + console.log(data); + }); + new Tail(errorsPath).on("line", function(data) { + console.log(data); + }); + + if(!noReload) { + chokidar.watch('.', { + cwd: path.join(process.cwd(), func.path), + ignoreInitial: true, + ignored: [ ...(func.ignore ?? []), 'code.tar.gz', '.appwrite', '.appwrite/', '.appwrite/*', '.appwrite/**', '.appwrite/*.*', '.appwrite/**/*.*' ] + }).on('all', async (_event, filePath) => { + Queue.push(filePath); + }); + } + + Queue.events.on('reload', async ({ files }) => { + Queue.lock(); + + log('Live-reloading due to file changes: '); + for(const file of files) { + log(`- ${file}`); + } + + try { + log('Stopping the function ...'); + + await dockerStopActive(); + + const dependencyFile = files.find((filePath) => tool.dependencyFiles.includes(filePath)); + if(tool.isCompiled || dependencyFile) { + log(`Rebuilding the function due to cange in ${dependencyFile} ...`); + await dockerBuild(func, variables); + await dockerStart(func, variables, port); + } else { + log('Hot-swapping function files ...'); + + const functionPath = path.join(process.cwd(), func.path); + const hotSwapPath = path.join(functionPath, '.appwrite/hot-swap'); + const buildPath = path.join(functionPath, '.appwrite/build.tar.gz'); + + // Prepare temp folder + if (!fs.existsSync(hotSwapPath)) { + fs.mkdirSync(hotSwapPath, { recursive: true }); + } else { + fs.rmSync(hotSwapPath, { recursive: true, force: true }); + fs.mkdirSync(hotSwapPath, { recursive: true }); + } + + await tar + .extract({ + gzip: true, + sync: true, + cwd: hotSwapPath, + file: buildPath + }); + + const ignorer = ignore(); + ignorer.add('.appwrite'); + if (func.ignore) { + ignorer.add(func.ignore); + } + + const filesToCopy = getAllFiles(functionPath).map((file) => path.relative(functionPath, file)).filter((file) => !ignorer.ignores(file)); + for(const f of filesToCopy) { + const filePath = path.join(hotSwapPath, f); + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } + + const fileDir = path.dirname(filePath); + if (!fs.existsSync(fileDir)) { + fs.mkdirSync(fileDir, { recursive: true }); + } + + const sourcePath = path.join(functionPath, f); + fs.copyFileSync(sourcePath, filePath); + } + + await tar + .create({ + gzip: true, + sync: true, + cwd: hotSwapPath, + file: buildPath + }, ['.']); + + fs.rmSync(hotSwapPath, { recursive: true, force: true }); + + await dockerStart(func, variables, port); + } + } catch(err) { + console.error(err); + } finally { + Queue.unlock(); + } + }); +} + +const run = new Command("run") + .description(commandDescriptions['run']) + .configureHelp({ + helpWidth: process.stdout.columns || 80 + }) + .action(actionRunner(async (_options, command) => { + command.help(); + })); + +run + .command("function") + .alias("functions") + .description("Run functions in the current directory.") + .option(`--functionId `, `Function ID`) + .option(`--port `, `Local port`) + .option(`--userId `, `ID of user to impersonate`) + .option(`--noVariables`, `Prevent pulling variables from function settings`) + .option(`--noReload`, `Prevent live reloading of server when changes are made to function files`) + .action(actionRunner(runFunction)); + +module.exports = { + run +} diff --git a/templates/cli/lib/config.js.twig b/templates/cli/lib/config.js.twig index 916865f44..f4f152a15 100644 --- a/templates/cli/lib/config.js.twig +++ b/templates/cli/lib/config.js.twig @@ -204,6 +204,45 @@ class Local extends Config { this.set("buckets", buckets); } + getMessagingTopics() { + if (!this.has("topics")) { + return []; + } + return this.get("topics"); + } + + getMessagingTopic($id) { + if (!this.has("topics")) { + return {}; + } + + let topic = this.get("topics"); + for (let i = 0; i < topic.length; i++) { + if (topic[i]['$id'] == $id) { + return topic[i]; + } + } + + return {}; + } + + addMessagingTopic(props) { + if (!this.has("topics")) { + this.set("topics", []); + } + + let topics = this.get("topics"); + for (let i = 0; i < topics.length; i++) { + if (topics[i]['$id'] === props['$id']) { + topics[i] = props; + this.set("topics", topics); + return; + } + } + topics.push(props); + this.set("topics", topics); + } + getDatabases() { if (!this.has("databases")) { return []; @@ -283,26 +322,75 @@ class Local extends Config { } getProject() { - if (!this.has("projectId") || !this.has("projectName")) { + if (!this.has("projectId")) { return {}; } return { projectId: this.get("projectId"), projectName: this.get("projectName"), + projectSettings: this.get('projectSettings') }; } - setProject(projectId, projectName) { + setProject(projectId, projectName = '', projectSettings = undefined) { this.set("projectId", projectId); - this.set("projectName", projectName); + + if (projectName !== '') { + this.set("projectName", projectName); + } + + if (projectSettings === undefined) { + return; + } + + const settings = { + 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, + 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 + } + } + }; + + this.set('projectSettings', settings) } + } class Global extends Config { static CONFIG_FILE_PATH = ".{{ spec.title|caseLower }}/prefs.json"; + static PREFERENCE_CURRENT = "current"; static PREFERENCE_ENDPOINT = "endpoint"; + static PREFERENCE_EMAIL = "email"; static PREFERENCE_SELF_SIGNED = "selfSigned"; static PREFERENCE_COOKIE = "cookie"; static PREFERENCE_PROJECT = "project"; @@ -310,6 +398,8 @@ class Global extends Config { static PREFERENCE_LOCALE = "locale"; static PREFERENCE_MODE = "mode"; + static IGNORE_ATTRIBUTES = [Global.PREFERENCE_CURRENT, Global.PREFERENCE_SELF_SIGNED, Global.PREFERENCE_ENDPOINT, Global.PREFERENCE_COOKIE, Global.PREFERENCE_PROJECT, Global.PREFERENCE_KEY, Global.PREFERENCE_LOCALE, Global.PREFERENCE_MODE]; + static MODE_ADMIN = "admin"; static MODE_DEFAULT = "default"; @@ -320,59 +410,141 @@ class Global extends Config { super(`${homeDir}/${path}`); } + getCurrentSession() { + if (!this.has(Global.PREFERENCE_CURRENT)) { + return ""; + } + return this.get(Global.PREFERENCE_CURRENT); + } + + setCurrentSession(session) { + if (session !== undefined) { + this.set(Global.PREFERENCE_CURRENT, session); + } + } + + getSessionIds() { + return Object.keys(this.data).filter((key) => !Global.IGNORE_ATTRIBUTES.includes(key)); + } + + getSessions() { + const sessions = Object.keys(this.data).filter((key) => !Global.IGNORE_ATTRIBUTES.includes(key)) + + return sessions.map((session) => { + + return { + id: session, + endpoint: this.data[session][Global.PREFERENCE_ENDPOINT], + email: this.data[session][Global.PREFERENCE_EMAIL] + } + }) + } + + addSession(session, data) { + this.set(session, data); + } + + removeSession(session) { + this.delete(session); + } + + getEmail() { + if (!this.hasFrom(Global.PREFERENCE_EMAIL)) { + return ""; + } + + return this.getFrom(Global.PREFERENCE_EMAIL); + } + + setEmail(email) { + this.setTo(Global.PREFERENCE_EMAIL, email); + } + getEndpoint() { - if (!this.has(Global.PREFERENCE_ENDPOINT)) { + if (!this.hasFrom(Global.PREFERENCE_ENDPOINT)) { return ""; } - return this.get(Global.PREFERENCE_ENDPOINT); + + return this.getFrom(Global.PREFERENCE_ENDPOINT); } setEndpoint(endpoint) { - this.set(Global.PREFERENCE_ENDPOINT, endpoint); + this.setTo(Global.PREFERENCE_ENDPOINT, endpoint); } getSelfSigned() { - if (!this.has(Global.PREFERENCE_SELF_SIGNED)) { + if (!this.hasFrom(Global.PREFERENCE_SELF_SIGNED)) { return false; } - return this.get(Global.PREFERENCE_SELF_SIGNED); + return this.getFrom(Global.PREFERENCE_SELF_SIGNED); } setSelfSigned(selfSigned) { - this.set(Global.PREFERENCE_SELF_SIGNED, selfSigned); + this.setTo(Global.PREFERENCE_SELF_SIGNED, selfSigned); } getCookie() { - if (!this.has(Global.PREFERENCE_COOKIE)) { + if (!this.hasFrom(Global.PREFERENCE_COOKIE)) { return ""; } - return this.get(Global.PREFERENCE_COOKIE); + return this.getFrom(Global.PREFERENCE_COOKIE); } setCookie(cookie) { - this.set(Global.PREFERENCE_COOKIE, cookie); + this.setTo(Global.PREFERENCE_COOKIE, cookie); } getProject() { - if (!this.has(Global.PREFERENCE_PROJECT)) { + if (!this.hasFrom(Global.PREFERENCE_PROJECT)) { return ""; } - return this.get(Global.PREFERENCE_PROJECT); + return this.getFrom(Global.PREFERENCE_PROJECT); } setProject(project) { - this.set(Global.PREFERENCE_PROJECT, project); + this.setTo(Global.PREFERENCE_PROJECT, project); } getKey() { - if (!this.has(Global.PREFERENCE_KEY)) { + if (!this.hasFrom(Global.PREFERENCE_KEY)) { return ""; } - return this.get(Global.PREFERENCE_KEY); + return this.getFrom(Global.PREFERENCE_KEY); } setKey(key) { - this.set(Global.PREFERENCE_KEY, key); + this.setTo(Global.PREFERENCE_KEY, key); + } + + hasFrom(key) { + const current = this.getCurrentSession(); + + if (current) { + const config = this.get(current); + + return config[key] !== undefined; + } + } + + getFrom(key) { + const current = this.getCurrentSession(); + + if (current) { + const config = this.get(current); + + return config[key]; + } + } + + setTo(key, value) { + const current = this.getCurrentSession(); + + if (current) { + const config = this.get(current); + + config[key] = value; + this.write(); + } } } diff --git a/templates/cli/lib/emulation/docker.js.twig b/templates/cli/lib/emulation/docker.js.twig new file mode 100644 index 000000000..fe5033955 --- /dev/null +++ b/templates/cli/lib/emulation/docker.js.twig @@ -0,0 +1,187 @@ +const childProcess = require('child_process'); +const { localConfig } = require("../config"); +const path = require('path'); +const fs = require('fs'); +const { log,success } = require("../parser"); +const { openRuntimesVersion, systemTools } = require("./utils"); +const ID = require("../id"); + +const activeDockerIds = {}; + +async function dockerStop(id) { + delete activeDockerIds[id]; + const stopProcess = childProcess.spawn('docker', ['rm', '--force', id], { + stdio: 'pipe', + }); + + await new Promise((res) => { stopProcess.on('close', res) }); +} + +async function dockerPull(func) { + log('Pulling Docker image of function runtime ...'); + + const runtimeChunks = func.runtime.split("-"); + const runtimeVersion = runtimeChunks.pop(); + const runtimeName = runtimeChunks.join("-"); + const imageName = `openruntimes/${runtimeName}:${openRuntimesVersion}-${runtimeVersion}`; + + const pullProcess = childProcess.spawn('docker', ['pull', imageName], { + stdio: 'pipe', + pwd: path.join(process.cwd(), func.path) + }); + + pullProcess.stderr.on('data', (data) => { + process.stderr.write(`\n${data}$ `); + }); + + await new Promise((res) => { pullProcess.on('close', res) }); +} + +async function dockerBuild(func, variables) { + log('Building function using Docker ...'); + + const runtimeChunks = func.runtime.split("-"); + const runtimeVersion = runtimeChunks.pop(); + const runtimeName = runtimeChunks.join("-"); + const imageName = `openruntimes/${runtimeName}:${openRuntimesVersion}-${runtimeVersion}`; + + const functionDir = path.join(process.cwd(), func.path); + + const id = ID.unique(); + + const params = [ 'run' ]; + params.push('--name', id); + params.push('-v', `${functionDir}/:/mnt/code:rw`); + params.push('-e', 'APPWRITE_ENV=development'); + params.push('-e', 'OPEN_RUNTIMES_ENV=development'); + params.push('-e', 'OPEN_RUNTIMES_SECRET='); + params.push('-e', `OPEN_RUNTIMES_ENTRYPOINT=${func.entrypoint}`); + + for(const k of Object.keys(variables)) { + params.push('-e', `${k}=${variables[k]}`); + } + + params.push(imageName, 'sh', '-c', `helpers/build.sh "${func.commands}"`); + + const buildProcess = childProcess.spawn('docker', params, { + stdio: 'pipe', + pwd: functionDir + }); + + buildProcess.stdout.on('data', (data) => { + process.stdout.write(`\n${data}`); + }); + + buildProcess.stderr.on('data', (data) => { + process.stderr.write(`\n${data}`); + }); + + await new Promise((res) => { buildProcess.on('close', res) }); + + const copyPath = path.join(process.cwd(), func.path, '.appwrite', 'build.tar.gz'); + const copyDir = path.dirname(copyPath); + if (!fs.existsSync(copyDir)) { + fs.mkdirSync(copyDir, { recursive: true }); + } + + const copyProcess = childProcess.spawn('docker', ['cp', `${id}:/mnt/code/code.tar.gz`, copyPath], { + stdio: 'pipe', + pwd: functionDir + }); + + await new Promise((res) => { copyProcess.on('close', res) }); + + const cleanupProcess = childProcess.spawn('docker', ['rm', '--force', id], { + stdio: 'pipe', + pwd: functionDir + }); + + await new Promise((res) => { cleanupProcess.on('close', res) }); + + delete activeDockerIds[id]; + + const tempPath = path.join(process.cwd(), func.path, 'code.tar.gz'); + if (fs.existsSync(tempPath)) { + fs.rmSync(tempPath, { force: true }); + } +} + +async function dockerStart(func, variables, port) { + log('Starting function using Docker ...'); + + log("Permissions, events, CRON and timeouts dont apply when running locally."); + + log('💡 Hint: Function automatically restarts when you edit your code.'); + + success(`Visit http://localhost:${port}/ to execute your function.`); + + + const runtimeChunks = func.runtime.split("-"); + const runtimeVersion = runtimeChunks.pop(); + const runtimeName = runtimeChunks.join("-"); + const imageName = `openruntimes/${runtimeName}:${openRuntimesVersion}-${runtimeVersion}`; + + const tool = systemTools[runtimeName]; + + const functionDir = path.join(process.cwd(), func.path); + + const id = ID.unique(); + + const params = [ 'run' ]; + params.push('--rm'); + params.push('-d'); + params.push('--name', id); + params.push('-p', `${port}:3000`); + params.push('-e', 'APPWRITE_ENV=development'); + params.push('-e', 'OPEN_RUNTIMES_ENV=development'); + params.push('-e', 'OPEN_RUNTIMES_SECRET='); + + for(const k of Object.keys(variables)) { + params.push('-e', `${k}=${variables[k]}`); + } + + params.push('-v', `${functionDir}/.appwrite/logs.txt:/mnt/logs/dev_logs.log:rw`); + params.push('-v', `${functionDir}/.appwrite/errors.txt:/mnt/logs/dev_errors.log:rw`); + params.push('-v', `${functionDir}/.appwrite/build.tar.gz:/mnt/code/code.tar.gz:ro`); + params.push(imageName, 'sh', '-c', `helpers/start.sh "${tool.startCommand}"`); + + childProcess.spawn('docker', params, { + stdio: 'pipe', + pwd: functionDir + }); + + activeDockerIds[id] = true; +} + +async function dockerCleanup() { + await dockerStop(); + + const functions = localConfig.getFunctions(); + for(const func of functions) { + const appwritePath = path.join(process.cwd(), func.path, '.appwrite'); + if (fs.existsSync(appwritePath)) { + fs.rmSync(appwritePath, { recursive: true, force: true }); + } + + const tempPath = path.join(process.cwd(), func.path, 'code.tar.gz'); + if (fs.existsSync(tempPath)) { + fs.rmSync(tempPath, { force: true }); + } + } +} + +async function dockerStopActive() { + const ids = Object.keys(activeDockerIds); + for await (const id of ids) { + await dockerStop(id); + } +} + +module.exports = { + dockerStop, + dockerPull, + dockerBuild, + dockerStart, + dockerCleanup, + dockerStopActive, +} diff --git a/templates/cli/lib/emulation/utils.js.twig b/templates/cli/lib/emulation/utils.js.twig new file mode 100644 index 000000000..d36d5cda0 --- /dev/null +++ b/templates/cli/lib/emulation/utils.js.twig @@ -0,0 +1,177 @@ +const EventEmitter = require('node:events'); +const { projectsCreateJWT } = require('../commands/projects'); +const { localConfig } = require("../config"); + + +const openRuntimesVersion = 'v3'; + +const runtimeNames = { + 'node': 'Node.js', + 'php': 'PHP', + 'ruby': 'Ruby', + 'python': 'Python', + 'python-ml': 'Python (ML)', + 'deno': 'Deno', + 'dart': 'Dart', + 'dotnet': '.NET', + 'java': 'Java', + 'swift': 'Swift', + 'kotlin': 'Kotlin', + 'bun': 'Bun' +}; + +const systemTools = { + 'node': { + isCompiled: false, + startCommand: "node src/server.js", + dependencyFiles: [ "package.json", "package-lock.json" ] + }, + 'php': { + isCompiled: false, + startCommand: "php src/server.php", + dependencyFiles: [ "composer.json", "composer.lock" ] + }, + 'ruby': { + isCompiled: false, + startCommand: "bundle exec puma -b tcp://0.0.0.0:3000 -e production", + dependencyFiles: [ "Gemfile", "Gemfile.lock" ] + }, + 'python': { + isCompiled: false, + startCommand: "python3 src/server.py", + dependencyFiles: [ "requirements.txt", "requirements.lock" ] + }, + 'python-ml': { + isCompiled: false, + startCommand: "python3 src/server.py", + dependencyFiles: [ "requirements.txt", "requirements.lock" ] + }, + 'deno': { + isCompiled: false, + startCommand: "deno start", + dependencyFiles: [ ] + }, + 'dart': { + isCompiled: true, + startCommand: "src/function/server", + dependencyFiles: [ ] + }, + 'dotnet': { + isCompiled: true, + startCommand: "dotnet src/function/DotNetRuntime.dll", + dependencyFiles: [ ] + }, + 'java': { + isCompiled: true, + startCommand: "java -jar src/function/java-runtime-1.0.0.jar", + dependencyFiles: [ ] + }, + 'swift': { + isCompiled: true, + startCommand: "src/function/Runtime serve --env production --hostname 0.0.0.0 --port 3000", + dependencyFiles: [ ] + }, + 'kotlin': { + isCompiled: true, + startCommand: "java -jar src/function/kotlin-runtime-1.0.0.jar", + dependencyFiles: [ ] + }, + 'bun': { + isCompiled: false, + startCommand: "bun src/server.ts", + dependencyFiles: [ "package.json", "package-lock.json", "bun.lockb" ] + }, +}; + +const JwtManager = { + userJwt: null, + functionJwt: null, + + timerWarn: null, + timerError: null, + + async setup(userId = null) { + if(this.timerWarn) { + clearTimeout(this.timerWarn); + } + + if(this.timerError) { + clearTimeout(this.timerError); + } + + this.timerWarn = setTimeout(() => { + log("Warning: Authorized JWT will expire in 5 minutes. Please stop and re-run the command to refresh tokens for 1 hour."); + }, 1000 * 60 * 55); // 55 mins + + this.timerError = setTimeout(() => { + log("Warning: Authorized JWT just expired. Please stop and re-run the command to obtain new tokens with 1 hour validity."); + log("Some Appwrite API communication is not authorized now.") + }, 1000 * 60 * 60); // 60 mins + + if(userId) { + await usersGet({ + userId, + parseOutput: false + }); + const userResponse = await usersCreateJWT({ + userId, + duration: 60*60, + parseOutput: false + }); + this.userJwt = userResponse.jwt; + } + + const functionResponse = await projectsCreateJWT({ + projectId: localConfig.getProject().projectId, + // TODO: Once we have endpoint for this, use it + scopes: ["sessions.write","users.read","users.write","teams.read","teams.write","databases.read","databases.write","collections.read","collections.write","attributes.read","attributes.write","indexes.read","indexes.write","documents.read","documents.write","files.read","files.write","buckets.read","buckets.write","functions.read","functions.write","execution.read","execution.write","locale.read","avatars.read","health.read","providers.read","providers.write","messages.read","messages.write","topics.read","topics.write","subscribers.read","subscribers.write","targets.read","targets.write","rules.read","rules.write","migrations.read","migrations.write","vcs.read","vcs.write","assistant.read"], + duration: 60*60, + parseOutput: false + }); + this.functionJwt = functionResponse.jwt; + } +}; + +const Queue = { + files: [], + locked: false, + events: new EventEmitter(), + debounce: null, + push(file) { + if(!this.files.includes(file)) { + this.files.push(file); + } + + if(!this.locked) { + this._trigger(); + } + }, + lock() { + this.files = []; + this.locked = true; + }, + unlock() { + this.locked = false; + if(this.files.length > 0) { + this._trigger(); + } + }, + _trigger() { + if(this.debounce) { + return; + } + + this.debounce = setTimeout(() => { + this.events.emit('reload', { files: this.files }); + this.debounce = null; + }, 300); + } +}; + +module.exports = { + openRuntimesVersion, + runtimeNames, + systemTools, + JwtManager, + Queue +} diff --git a/templates/cli/lib/id.js.twig b/templates/cli/lib/id.js.twig new file mode 100644 index 000000000..b628463da --- /dev/null +++ b/templates/cli/lib/id.js.twig @@ -0,0 +1,30 @@ +class ID { + // Generate an hex ID based on timestamp + // Recreated from https://www.php.net/manual/en/function.uniqid.php + static #hexTimestamp() { + const now = new Date(); + const sec = Math.floor(now.getTime() / 1000); + const msec = now.getMilliseconds(); + + // Convert to hexadecimal + const hexTimestamp = sec.toString(16) + msec.toString(16).padStart(5, '0'); + return hexTimestamp; + } + + static custom(id) { + return id + } + + static unique(padding = 7) { + // Generate a unique ID with padding to have a longer ID + const baseId = ID.#hexTimestamp(); + let randomPadding = ''; + for (let i = 0; i < padding; i++) { + const randomHexDigit = Math.floor(Math.random() * 16).toString(16); + randomPadding += randomHexDigit; + } + return baseId + randomPadding; + } +} + +module.exports = ID; diff --git a/templates/cli/lib/paginate.js.twig b/templates/cli/lib/paginate.js.twig index 8c57fa731..c78814a0e 100644 --- a/templates/cli/lib/paginate.js.twig +++ b/templates/cli/lib/paginate.js.twig @@ -5,7 +5,6 @@ const paginate = async (action, args = {}, limit = 100, wrapper = '') => { while (true) { const offset = pageNumber * limit; - // Merge the limit and offset into the args const response = await action({ ...args, @@ -48,4 +47,4 @@ const paginate = async (action, args = {}, limit = 100, wrapper = '') => { module.exports = { paginate -}; \ No newline at end of file +}; diff --git a/templates/cli/lib/parser.js.twig b/templates/cli/lib/parser.js.twig index 60b3e9c4d..12358131e 100644 --- a/templates/cli/lib/parser.js.twig +++ b/templates/cli/lib/parser.js.twig @@ -2,10 +2,18 @@ const chalk = require('chalk'); const commander = require('commander'); const Table = require('cli-table3'); const { description } = require('../package.json'); +const { globalConfig } = require("./config.js"); +const os = require('os'); +const Client = require("./client"); const cliConfig = { - verbose: false, - json: false + verbose: false, + json: false, + force: false, + all: false, + ids: [], + report: false, + reportData: {} }; const parse = (data) => { @@ -110,17 +118,60 @@ const drawJSON = (data) => { } const parseError = (err) => { - if(cliConfig.verbose) { - console.error(err); - } + if (cliConfig.report) { + (async () => { + let appwriteVersion = 'unknown'; + const endpoint = globalConfig.getEndpoint(); + const isCloud = endpoint.includes('cloud.appwrite.io') ? 'Yes' : 'No'; + + try { + const client = new Client().setEndpoint(endpoint); + const res = await client.call('get', '/health/version'); + appwriteVersion = res.version; + } catch { + } + + const version = '{{ sdk.version }}'; + const stepsToReproduce = `Running \`appwrite ${cliConfig.reportData.data.args.join(' ')}\``; + const yourEnvironment = `CLI version: ${version}\nOperation System: ${os.type()}\nAppwrite version: ${appwriteVersion}\nIs Cloud: ${isCloud}`; + + const stack = '```\n' + err.stack + '\n```'; + + const githubIssueUrl = new URL('https://github.com/appwrite/appwrite/issues/new'); + githubIssueUrl.searchParams.append('labels', 'bug'); + githubIssueUrl.searchParams.append('template', 'bug.yaml'); + githubIssueUrl.searchParams.append('title', `🐛 Bug Report: ${err.message}`); + githubIssueUrl.searchParams.append('actual-behavior', `CLI Error:\n${stack}`); + githubIssueUrl.searchParams.append('steps-to-reproduce', stepsToReproduce); + githubIssueUrl.searchParams.append('environment', yourEnvironment); + + log(`To report this error you can:\n - Create a support ticket in our Discord server https://appwrite.io/discord \n - Create an issue in our Github\n ${githubIssueUrl.href}\n`); - error(err.message); - process.exit(1) + error('\n Stack Trace: \n'); + console.error(err); + process.exit(1); + })() + } else { + if (cliConfig.verbose) { + console.error(err); + } else { + log('For detailed error pass the --verbose or --report flag'); + error(err.message); + } + process.exit(1); + } + } const actionRunner = (fn) => { - return (...args) => fn(...args).catch(parseError); + return (...args) => { + if (cliConfig.all && (Array.isArray(cliConfig.ids) && cliConfig.ids.length !== 0)) { + error(`The '--all' and '--id' flags cannot be used together.`); + process.exit(1); + } + return fn(...args).catch(parseError) + }; } const parseInteger = (value) => { @@ -156,10 +207,12 @@ const commandDescriptions = { "graphql": `The graphql command allows you to query and mutate any resource type on your Appwrite server.`, "avatars": `The avatars command aims to help you complete everyday tasks related to your app image, icons, and avatars.`, "databases": `The databases command allows you to create structured collections of documents, query and filter lists of documents.`, - "deploy": `The deploy command provides a convenient wrapper for deploying your functions and collections.`, + "init": `The init command provides a convenient wrapper for creating and initializing project, functions, collections, buckets, teams and messaging in Appwrite.`, + "push": `The push command provides a convenient wrapper for pushing your functions, collections, buckets, teams and messaging.`, + "run": `The run command allows you to run project locally to allow easy development and quick debugging.`, "functions": `The functions command allows you view, create and manage your Cloud Functions.`, "health": `The health command allows you to both validate and monitor your {{ spec.title|caseUcfirst }} server's health.`, - "init": `The init command helps you initialize your {{ spec.title|caseUcfirst }} project, functions and collections`, + "pull": `The pull command helps you pull your {{ spec.title|caseUcfirst }} project, functions, collections, buckets, teams and messaging`, "locale": `The locale command allows you to customize your app based on your users' location.`, "projects": `The projects command allows you to view, create and manage your {{ spec.title|caseUcfirst }} projects.`, "storage": `The storage command allows you to manage your project files.`, @@ -168,6 +221,8 @@ const commandDescriptions = { "client": `The client command allows you to configure your CLI`, "login": `The login command allows you to authenticate and manage a user account.`, "logout": `The logout command allows you to logout of your {{ spec.title|caseUcfirst }} account.`, + "whoami": `The whoami command gives information about the currently logged in user.`, + "register": `Outputs the link to create an {{ spec.title|caseUcfirst }} account..`, "console" : `The console command allows gives you access to the APIs used by the Appwrite console.`, "assistant": `The assistant command allows you to interact with the Appwrite Assistant AI`, "messaging": `The messaging command allows you to send messages.`, @@ -184,6 +239,7 @@ const commandDescriptions = { } module.exports = { + drawTable, parse, actionRunner, parseInteger, @@ -192,5 +248,6 @@ module.exports = { success, error, commandDescriptions, - cliConfig -} \ No newline at end of file + cliConfig, + drawTable +} diff --git a/templates/cli/lib/questions.js.twig b/templates/cli/lib/questions.js.twig index e7667e1aa..639e8509d 100644 --- a/templates/cli/lib/questions.js.twig +++ b/templates/cli/lib/questions.js.twig @@ -1,15 +1,22 @@ -const { localConfig } = require('./config'); +const chalk = require("chalk"); +const Client = require("./client"); +const { localConfig, globalConfig } = require('./config'); const { projectsList } = require('./commands/projects'); -const { functionsListRuntimes } = require('./commands/functions'); +const { teamsList } = require('./commands/teams'); +const { functionsListRuntimes, functionsList } = require('./commands/functions'); const { accountListMfaFactors } = require("./commands/account"); const { sdkForConsole } = require("./sdks"); - +const { validateRequired } = require("./validations"); +const { paginate } = require('./paginate'); +const { isPortTaken } = require('./utils'); const { databasesList } = require('./commands/databases'); const { checkDeployConditions } = require('./utils'); const JSONbig = require("json-bigint")({ storeAsString: false }); +const whenOverride = (answers) => answers.override === undefined ? true : answers.override; + const getIgnores = (runtime) => { - const languge = runtime.split('-')[0]; + const languge = runtime.split("-").slice(0, -1).join("-"); switch (languge) { case 'cpp': @@ -29,6 +36,7 @@ const getIgnores = (runtime) => { case 'php': return ['vendor']; case 'python': + case 'python-ml': return ['__pypackages__']; case 'ruby': return ['vendor']; @@ -42,7 +50,7 @@ const getIgnores = (runtime) => { }; const getEntrypoint = (runtime) => { - const languge = runtime.split('-')[0]; + const languge = runtime.split("-").slice(0, -1).join("-"); switch (languge) { case 'dart': @@ -56,6 +64,7 @@ const getEntrypoint = (runtime) => { case 'php': return 'src/index.php'; case 'python': + case 'python-ml': return 'src/main.py'; case 'ruby': return 'lib/main.rb'; @@ -77,7 +86,7 @@ const getEntrypoint = (runtime) => { }; const getInstallCommand = (runtime) => { - const languge = runtime.split('-')[0]; + const languge = runtime.split("-").slice(0, -1).join("-"); switch (languge) { case 'dart': @@ -91,6 +100,7 @@ const getInstallCommand = (runtime) => { case 'php': return 'composer install'; case 'python': + case 'python-ml': return 'pip install -r requirements.txt'; case 'ruby': return 'bundle install'; @@ -113,7 +123,7 @@ const questionsInitProject = [ type: "confirm", name: "override", message: - `An {{ spec.title|caseUcfirst }} project ( ${localConfig.getProject()['projectName']} ) is already associated with the current directory. Would you like to override`, + `An {{ spec.title|caseUcfirst }} project ( ${localConfig.getProject()['projectId']} ) is already associated with the current directory. Would you like to override`, when() { return Object.keys(localConfig.getProject()).length !== 0; } @@ -121,61 +131,72 @@ const questionsInitProject = [ { type: "list", name: "start", - when(answers) { - if (answers.override == undefined) { - return true - } - return answers.override; - }, + when: whenOverride, message: "How would you like to start?", choices: [ { - name: "Create a new {{ spec.title|caseUcfirst }} project", - value: "new", + name: "Create new project", + value: "new" }, { - name: "Link this directory to an existing {{ spec.title|caseUcfirst }} project", - value: "existing", - }, - ], + name: "Link directory to an existing project", + value: "existing" + } + ] + }, + { + type: "search-list", + name: "organization", + message: "Choose your organization", + choices: async () => { + let client = await sdkForConsole(true); + const { teams } = await paginate(teamsList, { parseOutput: false, sdk: client }, 100, 'teams'); + + let choices = teams.map((team, idx) => { + return { + name: `${team.name} (${team['$id']})`, + value: team['$id'] + } + }) + + if (choices.length == 0) { + throw new Error(`No organizations found. Please create a new organization at ${globalConfig.getEndpoint().replace('/v1', '/console/onboarding')}`) + } + + return choices; + }, + when: whenOverride }, { type: "input", name: "project", message: "What would you like to name your project?", default: "My Awesome Project", - when(answers) { - return answers.start == "new"; - }, + when: (answer) => answer.start !== 'existing' }, { type: "input", name: "id", message: "What ID would you like to have for your project?", default: "unique()", - when(answers) { - return answers.start == "new"; - }, + when: (answer) => answer.start !== 'existing' }, { - type: "list", + type: "search-list", name: "project", message: "Choose your {{ spec.title|caseUcfirst }} project.", - when(answers) { - return answers.start == "existing"; - }, - choices: async () => { - let response = await projectsList({ - parseOutput: false - }) - let projects = response["projects"] - let choices = projects.map((project, idx) => { + choices: async (answers) => { + const queries = [ + JSON.stringify({ method: 'equal', attribute: 'teamId', values: [answers.organization.id] }), + JSON.stringify({ method: 'orderDesc', attribute: 'Id' }) + ] + + const { projects } = await paginate(projectsList, { parseOutput: false, queries, }, 100, 'projects'); + + let choices = projects.map((project) => { return { name: `${project.name} (${project['$id']})`, - value: { - name: project.name, - id: project['$id'] - } + value: project['$id'] } }) @@ -184,11 +205,50 @@ const questionsInitProject = [ } return choices; + }, + when: (answer) => answer.start === 'existing' + } +]; +const questionsPullResources = [ + { + type: "list", + name: "resource", + message: "Which resources would you like to pull?", + choices: [ + { name: 'Project', value: 'project' }, + { name: 'Functions', value: 'functions' }, + { name: 'Collections', value: 'collections' }, + { name: 'Buckets', value: 'buckets' }, + { name: 'Teams', value: 'teams' }, + { name: 'Topics', value: 'messages' } + ] + } +] + +const questionsPullFunctions = [ + { + type: "checkbox", + name: "functions", + message: "Which functions would you like to pull?", + validate: (value) => validateRequired('function', value), + choices: async () => { + const { functions } = await paginate(functionsList, { parseOutput: false }, 100, 'functions'); + + if (functions.length === 0) { + throw "We couldn't find any functions in your {{ spec.title|caseUcfirst }} project"; + } + + return functions.map(func => { + return { + name: `${func.name} (${func.$id})`, + value: func + } + }); } } ]; -const questionsInitFunction = [ +const questionsCreateFunction = [ { type: "input", name: "name", @@ -215,9 +275,10 @@ const questionsInitFunction = [ name: `${runtime.name} (${runtime['$id']})`, value: { id: runtime['$id'], + name: runtime['$id'].split('-')[0], entrypoint: getEntrypoint(runtime['$id']), ignore: getIgnores(runtime['$id']), - commands : getInstallCommand(runtime['$id']) + commands: getInstallCommand(runtime['$id']) }, } }) @@ -226,11 +287,146 @@ const questionsInitFunction = [ } ]; -const questionsInitCollection = [ +const questionsCreateFunctionSelectTemplate = (templates) => { + return [ + { + type: "search-list", + name: "template", + message: "What template would you like to use?", + choices: templates.map((template) => { + const name = `${template[0].toUpperCase()}${template.split('').slice(1).join('')}`.replace(/[-_]/g, ' '); + + return { value: template, name } + }) + } + ]; +}; + + + +const questionsCreateBucket = [ + { + type: "input", + name: "bucket", + message: "What would you like to name your bucket?", + default: "My Awesome Bucket" + }, + { + type: "input", + name: "id", + message: "What ID would you like to have for your bucket?", + default: "unique()" + }, + { + type: "list", + name: "fileSecurity", + message: "Enable File-Security configuring permissions for individual file", + choices: ["No", "Yes"] + } +]; + +const questionsCreateTeam = [ + { + type: "input", + name: "bucket", + message: "What would you like to name your team?", + default: "My Awesome Team" + }, + { + type: "input", + name: "id", + message: "What ID would you like to have for your team?", + default: "unique()" + } +]; + +const questionsCreateCollection = [ + { + type: "list", + name: "method", + message: "What database would you like to use for your collection", + choices: ["New", "Existing"], + when: async () => { + return localConfig.getDatabases().length !== 0; + } + }, + { + type: "search-list", + name: "database", + message: "Choose the collection database", + choices: async () => { + const databases = localConfig.getDatabases(); + + let choices = databases.map((database, idx) => { + return { + name: `${database.name} (${database.$id})`, + value: database.$id + } + }) + + if (choices.length === 0) { + throw new Error("No databases found. Please create one in project console.") + } + + return choices; + }, + when: (answers) => (answers.method ?? '').toLowerCase() === 'existing' + }, + { + type: "input", + name: "databaseName", + message: "What would you like to name your database?", + default: "My Awesome Database", + when: (answers) => (answers.method ?? '').toLowerCase() !== 'existing' + }, + { + type: "input", + name: "databaseId", + message: "What ID would you like to have for your database?", + default: "unique()", + when: (answers) => (answers.method ?? '').toLowerCase() !== 'existing' + }, + { + type: "input", + name: "collection", + message: "What would you like to name your collection?", + default: "My Awesome Collection" + }, + { + type: "input", + name: "id", + message: "What ID would you like to have for your collection?", + default: "unique()" + }, + { + type: "list", + name: "documentSecurity", + message: "Enable Document-Security for configuring permissions for individual documents", + choices: ["No", "Yes"] + } +]; + +const questionsCreateMessagingTopic = [ + { + type: "input", + name: "topic", + message: "What would you like to name your messaging topic?", + default: "My Awesome Topic" + }, + { + type: "input", + name: "id", + message: "What ID would you like to have for your messaging topic?", + default: "unique()" + } +]; + +const questionsPullCollection = [ { type: "checkbox", name: "databases", - message: "From which database would you like to init collections?", + message: "From which database would you like to pull collections?", + validate: (value) => validateRequired('collection', value), choices: async () => { let response = await databasesList({ parseOutput: false @@ -252,6 +448,16 @@ const questionsInitCollection = [ ]; const questionsLogin = [ + { + type: "list", + name: "method", + message: "You're already logged in, what you like to do?", + choices: [ + { name: 'Login to a different account', value: 'login' }, + { name: 'Change to a different existed account', value: 'select' } + ], + when: () => globalConfig.getCurrentSession() !== '' + }, { type: "input", name: "email", @@ -262,6 +468,7 @@ const questionsLogin = [ } return true; }, + when: (answers) => answers.method !== 'select' }, { type: "password", @@ -273,20 +480,132 @@ const questionsLogin = [ return "Please enter your password"; } return true; - } + }, + when: (answers) => answers.method !== 'select' + }, + { + type: "search-list", + name: "accountId", + message: "Select an account to use", + choices() { + const sessions = globalConfig.getSessions(); + const current = globalConfig.getCurrentSession(); + + const data = []; + + const longestEmail = sessions.reduce((prev, current) => (prev && (prev.email ?? '').length > (current.email ?? '').length) ? prev : current).email.length; + + sessions.forEach((session) => { + if (session.email) { + data.push({ + current: current === session.id, + value: session.id, + name: `${session.email.padEnd(longestEmail)} ${current === session.id ? chalk.green.bold('current') : ' '.repeat(6)} ${session.endpoint}`, + }); + } + }) + + return data.sort((a, b) => Number(b.current) - Number(a.current)) + }, + when: (answers) => answers.method === 'select' }, ]; +const questionGetEndpoint = [ + { + type: "input", + name: "endpoint", + message: "Enter the endpoint of your {{ spec.title|caseUcfirst }} server", + default: "http://localhost/v1", + async validate(value) { + if (!value) { + return "Please enter a valid endpoint."; + } + let client = new Client().setEndpoint(value); + try { + let response = await client.call('get', '/health/version'); + if (response.version) { + return true; + } else { + throw new Error(); + } + } catch (error) { + return "Invalid endpoint or your Appwrite server is not running as expected."; + } + } + } +]; -const questionsDeployFunctions = [ +const questionsLogout = [ + { + type: "checkbox", + name: "accounts", + message: "Select accounts to logout from", + validate: (value) => validateRequired('account', value), + choices() { + const sessions = globalConfig.getSessions(); + const current = globalConfig.getCurrentSession(); + + const data = []; + + const longestEmail = sessions.reduce((prev, current) => (prev && (prev.email ?? '').length > (current.email ?? '').length) ? prev : current).email.length; + + sessions.forEach((session) => { + if (session.email) { + data.push({ + current: current === session.id, + value: session.id, + name: `${session.email.padEnd(longestEmail)} ${current === session.id ? chalk.green.bold('current') : ' '.repeat(6)} ${session.endpoint}`, + }); + } + }) + + return data.sort((a, b) => Number(b.current) - Number(a.current)) + } + } +]; + +const questionsPushResources = [ + { + type: "list", + name: "resource", + message: "Which resources would you like to push?", + choices: [ + { name: 'Project', value: 'project' }, + { name: 'Functions', value: 'functions' }, + { name: 'Collections', value: 'collections' }, + { name: 'Buckets', value: 'buckets' }, + { name: 'Teams', value: 'teams' }, + { name: 'Topics', value: 'messages' } + ] + } +]; + +const questionsInitResources = [ + { + type: "list", + name: "resource", + message: "Which resource would you create?", + choices: [ + { name: 'Function', value: 'function' }, + { name: 'Collection', value: 'collection' }, + { name: 'Bucket', value: 'bucket' }, + { name: 'Team', value: 'team' }, + { name: 'Topic', value: 'message' } + ] + } +]; + +const questionsPushFunctions = [ { type: "checkbox", name: "functions", - message: "Which functions would you like to deploy?", + message: "Which functions would you like to push?", + validate: (value) => validateRequired('function', value), choices: () => { let functions = localConfig.getFunctions(); checkDeployConditions(localConfig) if (functions.length === 0) { - throw new Error("No functions found in the current directory."); + throw new Error("No functions found in the current directory Use 'appwrite pull functions' to synchronize existing one, or use 'appwrite init function' to create a new one."); } let choices = functions.map((func, idx) => { return { @@ -304,17 +623,18 @@ const questionsDeployFunctions = [ }, ] -const questionsDeployCollections = [ +const questionsPushCollections = [ { type: "checkbox", name: "collections", - message: "Which collections would you like to deploy?", + message: "Which collections would you like to push?", + validate: (value) => validateRequired('collection', value), choices: () => { let collections = localConfig.getCollections(); checkDeployConditions(localConfig) if (collections.length === 0) { - throw new Error("No collections found in the current directory. Run `{{ language.params.executableName }} init collection` to fetch all your collections."); + throw new Error("No collections found in the current directory. Use 'appwrite pull collections' to synchronize existing one, or use 'appwrite init collection' to create a new one."); } return collections.map(collection => { return { @@ -326,21 +646,22 @@ const questionsDeployCollections = [ }, { type: "input", - name: "override", - message: 'Are you sure you want to override this collection? This can lead to loss of data! Type "YES" to confirm.' - }, + name: "changes", + message: `Would you like to apply these changes? Type "YES" to confirm.` + } ] -const questionsDeployBuckets = [ +const questionsPushBuckets = [ { type: "checkbox", name: "buckets", - message: "Which buckets would you like to deploy?", + message: "Which buckets would you like to push?", + validate: (value) => validateRequired('bucket', value), choices: () => { let buckets = localConfig.getBuckets(); checkDeployConditions(localConfig) if (buckets.length === 0) { - throw new Error("No buckets found in the current directory. Run `appwrite init bucket` to fetch all your buckets."); + throw new Error("No buckets found in the current directory. Use 'appwrite pull buckets' to synchronize existing one, or use 'appwrite init bucket' to create a new one."); } return buckets.map(bucket => { return { @@ -349,12 +670,32 @@ const questionsDeployBuckets = [ } }); } + } +] + +const questionsPushMessagingTopics = [ + { + type: "checkbox", + name: "topics", + message: "Which messaging topic would you like to push?", + choices: () => { + let topics = localConfig.getMessagingTopics(); + if (topics.length === 0) { + throw new Error("No topics found in the current directory. Use 'appwrite pull topics' to synchronize existing one, or use 'appwrite init topic' to create a new one."); + } + return topics.map(topic => { + return { + name: `${topic.name} (${topic['$id']})`, + value: topic.$id + } + }); + } }, { type: "input", name: "override", - message: 'Are you sure you want to override this bucket? This can lead to loss of data! Type "YES" to confirm.' - }, + message: 'Would you like to override existing topics? This can lead to loss of data! Type "YES" to confirm.' + } ] const questionsGetEntrypoint = [ @@ -372,16 +713,17 @@ const questionsGetEntrypoint = [ }, ] -const questionsDeployTeams = [ +const questionsPushTeams = [ { type: "checkbox", name: "teams", - message: "Which teams would you like to deploy?", + message: "Which teams would you like to push?", + validate: (value) => validateRequired('team', value), choices: () => { let teams = localConfig.getTeams(); checkDeployConditions(localConfig); if (teams.length === 0) { - throw new Error("No teams found in the current directory. Run `appwrite init team` to fetch all your teams."); + throw new Error("No teams found in the current directory. Use 'appwrite pull teams' to synchronize existing one, or use 'appwrite init team' to create a new one."); } return teams.map(team => { return { @@ -391,43 +733,38 @@ const questionsDeployTeams = [ }); } }, - { - type: "input", - name: "override", - message: 'Are you sure you want to override this team? This can lead to loss of data! Type "YES" to confirm.' - }, ]; const questionsListFactors = [ { type: "list", name: "factor", - message: "Your account is protected by multiple factors. Which factor would you like to use to authenticate?", + message: "Your account is protected by multi-factor authentication. Please choose one for verification.", choices: async () => { let client = await sdkForConsole(false); const factors = await accountListMfaFactors({ sdk: client, parseOutput: false }); - + const choices = [ { - name: `TOTP (Time-based One-time Password)`, + name: `Authenticator app (Get a code from a third-party authenticator app)`, value: 'totp' }, { - name: `E-mail`, + name: `Email (Get a security code at your Appwrite email address)`, value: 'email' }, { - name: `Phone (SMS)`, + name: `SMS (Get a security code on your Appwrite phone number)`, value: 'phone' }, { - name: `Recovery code`, + name: `Recovery code (Use one of your recovery codes for verification)`, value: 'recoveryCode' } - ].filter((ch) => factors[ch.value] === true); + ].filter((ch) => factors[ch.value] === true); return choices; } @@ -448,16 +785,51 @@ const questionsMfaChallenge = [ } ]; +const questionsRunFunctions = [ + { + type: "list", + name: "function", + message: "Which function would you like to develop locally?", + validate: (value) => validateRequired('function', value), + choices: () => { + let functions = localConfig.getFunctions(); + if (functions.length === 0) { + throw new Error("No functions found in the current directory. Use 'appwrite pull functions' to synchronize existing one, or use 'appwrite init function' to create a new one."); + } + let choices = functions.map((func, idx) => { + return { + name: `${func.name} (${func.$id})`, + value: func.$id + } + }) + return choices; + } + } +]; + module.exports = { questionsInitProject, + questionsCreateFunction, + questionsCreateFunctionSelectTemplate, + questionsCreateBucket, + questionsCreateCollection, + questionsCreateMessagingTopic, + questionsPullFunctions, questionsLogin, - questionsInitFunction, - questionsInitCollection, - questionsDeployFunctions, - questionsDeployCollections, - questionsDeployBuckets, - questionsDeployTeams, + questionsPullResources, + questionsLogout, + questionsPullCollection, + questionsPushResources, + questionsPushFunctions, + questionsPushCollections, + questionsPushBuckets, + questionsPushMessagingTopics, + questionsPushTeams, questionsGetEntrypoint, questionsListFactors, - questionsMfaChallenge + questionsMfaChallenge, + questionsRunFunctions, + questionGetEndpoint, + questionsInitResources, + questionsCreateTeam }; diff --git a/templates/cli/lib/sdks.js.twig b/templates/cli/lib/sdks.js.twig index 49b161f11..2b6e15e13 100644 --- a/templates/cli/lib/sdks.js.twig +++ b/templates/cli/lib/sdks.js.twig @@ -68,7 +68,7 @@ const sdkForProject = async () => { } if (!project) { - throw new Error("Project is not set. Please run `{{ language.params.executableName }} init project` to initialize the current directory with an {{ spec.title|caseUcfirst }} project."); + throw new Error("Project is not set. Please run `{{ language.params.executableName }} init` to initialize the current directory with an {{ spec.title|caseUcfirst }} project."); } client diff --git a/templates/cli/lib/spinner.js.twig b/templates/cli/lib/spinner.js.twig new file mode 100644 index 000000000..2f5b3ad11 --- /dev/null +++ b/templates/cli/lib/spinner.js.twig @@ -0,0 +1,103 @@ +const progress = require('cli-progress'); +const chalk = require('chalk'); + +const SPINNER_ARC = 'arc'; +const SPINNER_DOTS = 'dots'; + +const spinners = { + [SPINNER_ARC]: { + "interval": 100, + "frames": ["◜", "◠", "◝", "◞", "◡", "◟"] + }, + [SPINNER_DOTS]: { + "interval": 80, + "frames": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + } +} + +class Spinner { + static start(clearOnComplete = true, hideCursor = true) { + Spinner.updatesBar = new progress.MultiBar({ + format: this.#formatter, + hideCursor, + clearOnComplete, + stopOnComplete: true, + noTTYOutput: true + }); + } + + static stop() { + Spinner.updatesBar.stop(); + } + + static #formatter(options, params, payload) { + const status = payload.status.padEnd(12); + const middle = `${payload.resource} (${payload.id})`.padEnd(40); + + let prefix = chalk.cyan(payload.prefix ?? '⧗'); + let start = chalk.cyan(status); + let end = chalk.yellow(payload.end); + + if (status.toLowerCase().trim() === 'pushed') { + start = chalk.greenBright.bold(status); + prefix = chalk.greenBright.bold('✓'); + end = ''; + } else if (status.toLowerCase().trim() === 'deploying') { + start = chalk.cyanBright.bold(status); + } else if (status.toLowerCase().trim() === 'deployed') { + start = chalk.green.bold(status); + prefix = chalk.green.bold('✓'); + } else if (status.toLowerCase().trim() === 'error') { + start = chalk.red.bold(status); + prefix = chalk.red.bold('✗'); + end = chalk.red(payload.errorMessage); + } + + return Spinner.#line(prefix, start, middle, end); + } + + static #line(prefix, start, middle, end, separator = '•') { + return `${prefix} ${start} ${separator} ${middle} ${!end ? '' : separator} ${end}`; + + } + + constructor(payload, total = 100, startValue = 0) { + this.bar = Spinner.updatesBar.create(total, startValue, payload) + } + + update(payload) { + this.bar.update(payload); + return this; + } + + fail(payload) { + this.stopSpinner(); + this.update({ status: 'Error', ...payload }); + } + + startSpinner(name) { + let spinnerFrame = 1; + const spinner = spinners[name] ?? spinners['dots']; + + this.spinnerInterval = setInterval(() => { + if (spinnerFrame === spinner.frames.length) spinnerFrame = 1; + this.bar.update({ prefix: spinner.frames[spinnerFrame++] }); + }, spinner.interval); + } + + stopSpinner() { + clearInterval(this.spinnerInterval); + } + + replaceSpinner(name) { + this.stopSpinner(); + this.startSpinner(name); + } +} + + +module.exports = { + Spinner, + SPINNER_ARC, + SPINNER_DOTS +} diff --git a/templates/cli/lib/utils.js.twig b/templates/cli/lib/utils.js.twig index bc06aaf2b..a2cf3e3c1 100644 --- a/templates/cli/lib/utils.js.twig +++ b/templates/cli/lib/utils.js.twig @@ -1,9 +1,13 @@ const fs = require("fs"); const path = require("path"); +const net = require("net"); +const childProcess = require('child_process'); +const { localConfig, globalConfig } = require("./config"); +const { success } = require('./parser') function getAllFiles(folder) { const files = []; - for(const pathDir of fs.readdirSync(folder)) { + for (const pathDir of fs.readdirSync(folder)) { const pathAbsolute = path.join(folder, pathDir); if (fs.statSync(pathAbsolute).isDirectory()) { files.push(...getAllFiles(pathAbsolute)); @@ -14,13 +18,247 @@ function getAllFiles(folder) { return files; } +async function isPortTaken(port) { + const taken = await new Promise((res, rej) => { + const tester = net.createServer() + .once('error', function (err) { + if (err.code != 'EADDRINUSE') return rej(err) + res(true) + }) + .once('listening', function() { + tester.once('close', function() { res(false) }) + .close() + }) + .listen(port); + }); + + return taken; +} + +function systemHasCommand(command) { + const isUsingWindows = process.platform == 'win32' + + try { + if(isUsingWindows) { + childProcess.execSync('where ' + command, { stdio: 'pipe' }) + } else { + childProcess.execSync(`[[ $(${command} --version) ]] || { exit 1; } && echo "OK"`, { stdio: 'pipe', shell: '/bin/bash' }); + } + } catch (error) { + console.log(error); + return false; + } + + return true; +} + const checkDeployConditions = (localConfig) => { if (Object.keys(localConfig.data).length === 0) { - throw new Error("No appwrite.json file found in the current directory. This command must be run in the folder holding your appwrite.json file. Please run this command again in the folder containing your appwrite.json file, or run appwrite init project."); + throw new Error("No appwrite.json file found in the current directory. Please run this command again in the folder containing your appwrite.json file, or run 'appwrite init project' to link current directory to an Appwrite project."); + } +} + +function showConsoleLink(serviceName, action, ...ids) { + const projectId = localConfig.getProject().projectId; + + const url = new URL(globalConfig.getEndpoint().replace('/v1', '/console')); + url.pathname += `/project-${projectId}`; + action = action.toLowerCase(); + + switch (serviceName) { + case "account": + url.pathname = url.pathname.replace(`/project-${projectId}`, ''); + url.pathname += getAccountPath(action); + break; + case "databases": + url.pathname += getDatabasePath(action, ids); + break; + case "functions": + url.pathname += getFunctionsPath(action, ids); + break; + case "messaging": + url.pathname += getMessagingPath(action, ids); + break; + case "projects": + url.pathname = url.pathname.replace(`/project-${projectId}`, ''); + url.pathname += getProjectsPath(action, ids); + break; + case "storage": + url.pathname += getBucketsPath(action, ids); + break; + case "teams": + url.pathname += getTeamsPath(action, ids); + break; + case "users": + url.pathname += getUsersPath(action, ids); + break; + default: + return; + } + + + success(url); +} + +function getAccountPath(action) { + let path = '/account'; + + if (action === 'listsessions') { + path += '/sessions'; + } + + return path; +} + +function getDatabasePath(action, ids) { + let path = '/databases'; + + + if (['get', 'listcollections', 'getcollection', 'listattributes', 'listdocuments', 'getdocument', 'listindexes', 'getdatabaseusage'].includes(action)) { + path += `/database-${ids[0]}`; + } + + if (action === 'getdatabaseusage') { + path += `/usage`; + } + + if (['getcollection', 'listattributes', 'listdocuments', 'getdocument', 'listindexes'].includes(action)) { + path += `/collection-${ids[1]}`; + } + + if (action === 'listattributes') { + path += '/attributes'; + } + if (action === 'listindexes') { + path += '/indexes'; + } + if (action === 'getdocument') { + path += `/document-${ids[2]}`; + } + + + return path; +} + +function getFunctionsPath(action, ids) { + let path = '/functions'; + + if (action !== 'list') { + path += `/function-${ids[0]}`; + } + + if (action === 'getdeployment') { + path += `/deployment-${ids[1]}` + } + + if (action === 'getexecution' || action === 'listexecution') { + path += `/executions` + } + if (action === 'getfunctionusage') { + path += `/usage` + } + + return path; +} + +function getMessagingPath(action, ids) { + let path = '/messaging'; + + if (['getmessage', 'listmessagelogs'].includes(action)) { + path += `/message-${ids[0]}`; + } + + if (['listproviders', 'getprovider'].includes(action)) { + path += `/providers`; + } + + if (action === 'getprovider') { + path += `/provider-${ids[0]}`; + } + + if (['listtopics', 'gettopic'].includes(action)) { + path += `/topics`; + } + + if (action === 'gettopic') { + path += `/topic-${ids[0]}`; } + + return path; +} + +function getProjectsPath(action, ids) { + let path = ''; + + if (action !== 'list') { + path += `/project-${ids[0]}`; + } + + if (['listkeys', 'getkey'].includes(action)) { + path += '/overview/keys' + } + + if (['listplatforms', 'getplatform'].includes(action)) { + path += '/overview/platforms' + } + + if (['listwebhooks', 'getwebhook'].includes(action)) { + path += '/settings/webhooks' + } + + if (['getplatform', 'getkey', 'getwebhook'].includes(action)) { + path += `/${ids[1]}`; + } + + return path; +} + +function getBucketsPath(action, ids) { + let path = '/storage'; + + if (action !== 'listbuckets') { + path += `/bucket-${ids[0]}`; + } + + if (action === 'getbucketusage') { + path += `/usage` + } + + if (action === 'getfile') { + path += `/file-${ids[1]}` + } + + return path; +} + +function getTeamsPath(action, ids) { + let path = '/auth/teams'; + + if (action !== 'list') { + path += `/team-${ids[0]}`; + } + + return path; +} + +function getUsersPath(action, ids) { + let path = '/auth'; + + if (action !== 'list') { + path += `/user-${ids[0]}`; + } + + if (action === 'listsessions') { + path += 'sessions'; + } + + return path; } module.exports = { getAllFiles, - checkDeployConditions -}; \ No newline at end of file + isPortTaken, + systemHasCommand, + checkDeployConditions, + showConsoleLink +}; diff --git a/templates/cli/lib/validations.js.twig b/templates/cli/lib/validations.js.twig new file mode 100644 index 000000000..bfae5ba37 --- /dev/null +++ b/templates/cli/lib/validations.js.twig @@ -0,0 +1,17 @@ +const validateRequired = (resource, value) => { + if (Array.isArray(value)) { + if (value.length <= 0) { + return `Please select at least one ${resource}`; + } + } else { + if (value === undefined || value === null || value === 0 || (typeof value === "string" && value.trim() === '')) { + return `${resource} is required`; + } + } + + return true; +} + +module.exports = { + validateRequired +} diff --git a/templates/cli/package.json.twig b/templates/cli/package.json.twig index 1392ff294..24c51c976 100644 --- a/templates/cli/package.json.twig +++ b/templates/cli/package.json.twig @@ -24,13 +24,17 @@ "dependencies": { "undici": "^5.28.2", "chalk": "4.1.2", + "cli-progress": "^3.12.0", "cli-table3": "^0.6.2", "commander": "^9.2.0", "form-data": "^4.0.0", "json-bigint": "^1.0.0", "inquirer": "^8.2.4", + "inquirer-search-list": "^1.2.6", "tar": "^6.1.11", - "ignore": "^5.2.0" + "ignore": "^5.2.0", + "chokidar": "^3.6.0", + "tail": "^2.2.6" }, "devDependencies": { "pkg": "5.8.1"