diff --git a/package.json b/package.json index aea3e52..ac3b26c 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,25 @@ "lib": "lib" }, "dependencies": { - "@extollo/di": "git+https://code.garrettmills.dev/extollo/di", - "@extollo/util": "git+https://code.garrettmills.dev/extollo/util", "@types/busboy": "^0.2.3", + "@types/mkdirp": "^1.0.1", "@types/negotiator": "^0.6.1", "@types/node": "^14.14.37", + "@types/pg": "^8.6.0", + "@types/pluralize": "^0.0.29", "@types/pug": "^2.0.4", + "@types/rimraf": "^3.0.0", + "@types/ssh2": "^0.5.46", "busboy": "^0.3.1", "colors": "^1.4.0", "dotenv": "^8.2.0", + "mkdirp": "^1.0.4", "negotiator": "^0.6.2", + "pg": "^8.6.0", + "pluralize": "^8.0.0", "pug": "^3.0.2", + "rimraf": "^3.0.2", + "ssh2": "^1.1.0", "ts-node": "^9.1.1", "typescript": "^4.2.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7021beb..d0a7e91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,15 +1,23 @@ dependencies: - '@extollo/di': code.garrettmills.dev/extollo/di/902c31997016d24c735448bbf1e58f06e94f4139 - '@extollo/util': code.garrettmills.dev/extollo/util/131e0c93ee7ea67d771053fbd69fbf7f6612fadf '@types/busboy': 0.2.3 + '@types/mkdirp': 1.0.1 '@types/negotiator': 0.6.1 '@types/node': 14.14.37 + '@types/pg': 8.6.0 + '@types/pluralize': 0.0.29 '@types/pug': 2.0.4 + '@types/rimraf': 3.0.0 + '@types/ssh2': 0.5.46 busboy: 0.3.1 colors: 1.4.0 dotenv: 8.2.0 + mkdirp: 1.0.4 negotiator: 0.6.2 + pg: 8.6.0 + pluralize: 8.0.0 pug: 3.0.2 + rimraf: 3.0.2 + ssh2: 1.1.0 ts-node: 9.1.1_typescript@4.2.3 typescript: 4.2.3 lockfileVersion: 5.2 @@ -42,7 +50,7 @@ packages: /@types/glob/7.1.3: dependencies: '@types/minimatch': 3.0.4 - '@types/node': 14.14.37 + '@types/node': 14.17.1 dev: false resolution: integrity: sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w== @@ -52,7 +60,7 @@ packages: integrity: sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA== /@types/mkdirp/1.0.1: dependencies: - '@types/node': 14.14.37 + '@types/node': 14.17.1 dev: false resolution: integrity: sha512-HkGSK7CGAXncr8Qn/0VqNtExEE+PHMWb+qlR1faHMao7ng6P3tAaoWWBMdva0gL5h4zprjIO89GJOLXsMcDm1Q== @@ -64,6 +72,22 @@ packages: dev: false resolution: integrity: sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw== + /@types/node/14.17.1: + dev: false + resolution: + integrity: sha512-/tpUyFD7meeooTRwl3sYlihx2BrJE7q9XF71EguPFIySj9B7qgnRtHsHTho+0AUm4m1SvWGm6uSncrR94q6Vtw== + /@types/pg/8.6.0: + dependencies: + '@types/node': 14.17.1 + pg-protocol: 1.5.0 + pg-types: 2.2.0 + dev: false + resolution: + integrity: sha512-3JXFrsl8COoqVB1+2Pqelx6soaiFVXzkT3fkuSNe7GB40ysfT0FHphZFPiqIXpMyTHSFRdLTyZzrFBrJRPAArA== + /@types/pluralize/0.0.29: + dev: false + resolution: + integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA== /@types/pug/2.0.4: dev: false resolution: @@ -71,27 +95,23 @@ packages: /@types/rimraf/3.0.0: dependencies: '@types/glob': 7.1.3 - '@types/node': 14.14.37 + '@types/node': 14.17.1 dev: false resolution: integrity: sha512-7WhJ0MdpFgYQPXlF4Dx+DhgvlPCfz/x5mHaeDQAKhcenvQP1KCpLQ18JklAqeGMYSAT2PxLpzd0g2/HE7fj7hQ== /@types/ssh2-streams/0.1.8: dependencies: - '@types/node': 14.14.37 + '@types/node': 14.17.1 dev: false resolution: integrity: sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA== /@types/ssh2/0.5.46: dependencies: - '@types/node': 14.14.37 + '@types/node': 14.17.1 '@types/ssh2-streams': 0.1.8 dev: false resolution: integrity: sha512-1pC8FHrMPYdkLoUOwTYYifnSEPzAFZRsp3JFC/vokQ+dRrVI+hDBwz0SNmQ3pL6h39OSZlPs0uCG7wKJkftnaA== - /@types/uuid/8.3.0: - dev: false - resolution: - integrity: sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== /acorn/7.4.1: dev: false engines: @@ -125,10 +145,10 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw== - /balanced-match/1.0.0: + /balanced-match/1.0.2: dev: false resolution: - integrity: sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== /bcrypt-pbkdf/1.0.2: dependencies: tweetnacl: 0.14.5 @@ -137,7 +157,7 @@ packages: integrity: sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= /brace-expansion/1.1.11: dependencies: - balanced-match: 1.0.0 + balanced-match: 1.0.2 concat-map: 0.0.1 dev: false resolution: @@ -146,6 +166,12 @@ packages: dev: false resolution: integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + /buffer-writer/2.0.0: + dev: false + engines: + node: '>=4' + resolution: + integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== /busboy/0.3.1: dependencies: dicer: 0.3.0 @@ -184,6 +210,16 @@ packages: dev: false resolution: integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw== + /cpu-features/0.0.2: + dependencies: + nan: 2.14.2 + dev: false + engines: + node: '>=8.0.0' + optional: true + requiresBuild: true + resolution: + integrity: sha512-/2yieBqvMcRj8McNzkycjW2v3OIUOibBfd2dLEJ0nWts8NobAxwiyw9phVNS6oDL8x8tz9F7uNVFEVpJncQpeA== /create-require/1.1.1: dev: false resolution: @@ -228,7 +264,7 @@ packages: dev: false resolution: integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== - /glob/7.1.6: + /glob/7.1.7: dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -238,7 +274,7 @@ packages: path-is-absolute: 1.0.1 dev: false resolution: - integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== /has-symbols/1.0.2: dev: false engines: @@ -322,6 +358,11 @@ packages: hasBin: true resolution: integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + /nan/2.14.2: + dev: false + optional: true + resolution: + integrity: sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== /negotiator/0.6.2: dev: false engines: @@ -340,6 +381,10 @@ packages: dev: false resolution: integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + /packet-reader/1.0.0: + dev: false + resolution: + integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== /path-is-absolute/1.0.1: dev: false engines: @@ -350,6 +395,97 @@ packages: dev: false resolution: integrity: sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + /pg-connection-string/2.5.0: + dev: false + resolution: + integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== + /pg-int8/1.0.1: + dev: false + engines: + node: '>=4.0.0' + resolution: + integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + /pg-pool/3.3.0_pg@8.6.0: + dependencies: + pg: 8.6.0 + dev: false + peerDependencies: + pg: '>=8.0' + resolution: + integrity: sha512-0O5huCql8/D6PIRFAlmccjphLYWC+JIzvUhSzXSpGaf+tjTZc4nn+Lr7mLXBbFJfvwbP0ywDv73EiaBsxn7zdg== + /pg-protocol/1.5.0: + dev: false + resolution: + integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ== + /pg-types/2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + dev: false + engines: + node: '>=4' + resolution: + integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + /pg/8.6.0: + dependencies: + buffer-writer: 2.0.0 + packet-reader: 1.0.0 + pg-connection-string: 2.5.0 + pg-pool: 3.3.0_pg@8.6.0 + pg-protocol: 1.5.0 + pg-types: 2.2.0 + pgpass: 1.0.4 + dev: false + engines: + node: '>= 8.0.0' + peerDependencies: + pg-native: '>=2.0.0' + peerDependenciesMeta: + pg-native: + optional: true + resolution: + integrity: sha512-qNS9u61lqljTDFvmk/N66EeGq3n6Ujzj0FFyNMGQr6XuEv4tgNTXvJQTfJdcvGit5p5/DWPu+wj920hAJFI+QQ== + /pgpass/1.0.4: + dependencies: + split2: 3.2.2 + dev: false + resolution: + integrity: sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w== + /pluralize/8.0.0: + dev: false + engines: + node: '>=4' + resolution: + integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + /postgres-array/2.0.0: + dev: false + engines: + node: '>=4' + resolution: + integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + /postgres-bytea/1.0.0: + dev: false + engines: + node: '>=0.10.0' + resolution: + integrity: sha1-AntTPAqokOJtFy1Hz5zOzFIazTU= + /postgres-date/1.0.7: + dev: false + engines: + node: '>=0.10.0' + resolution: + integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + /postgres-interval/1.2.0: + dependencies: + xtend: 4.0.2 + dev: false + engines: + node: '>=0.10.0' + resolution: + integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== /promise/7.3.1: dependencies: asap: 2.0.6 @@ -447,10 +583,16 @@ packages: dev: false resolution: integrity: sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw== - /reflect-metadata/0.1.13: + /readable-stream/3.6.0: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 dev: false + engines: + node: '>= 6' resolution: - integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== /resolve/1.20.0: dependencies: is-core-module: 2.2.0 @@ -460,11 +602,15 @@ packages: integrity: sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== /rimraf/3.0.2: dependencies: - glob: 7.1.6 + glob: 7.1.7 dev: false hasBin: true resolution: integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + /safe-buffer/5.2.1: + dev: false + resolution: + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== /safer-buffer/2.1.2: dev: false resolution: @@ -482,30 +628,37 @@ packages: node: '>=0.10.0' resolution: integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - /ssh2-streams/0.4.10: + /split2/3.2.2: dependencies: - asn1: 0.2.4 - bcrypt-pbkdf: 1.0.2 - streamsearch: 0.1.2 + readable-stream: 3.6.0 dev: false - engines: - node: '>=5.2.0' resolution: - integrity: sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ== - /ssh2/0.8.9: + integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg== + /ssh2/1.1.0: dependencies: - ssh2-streams: 0.4.10 + asn1: 0.2.4 + bcrypt-pbkdf: 1.0.2 dev: false engines: - node: '>=5.2.0' + node: '>=10.16.0' + optionalDependencies: + cpu-features: 0.0.2 + nan: 2.14.2 + requiresBuild: true resolution: - integrity: sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw== + integrity: sha512-CidQLG2ZacoT0Z7O6dOyisj4JdrOrLVJ4KbHjVNz9yI1vO08FAYQPcnkXY9BP8zeYo+J/nBgY6Gg4R7w4WFWtg== /streamsearch/0.1.2: dev: false engines: node: '>=0.8.0' resolution: integrity: sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= + /string_decoder/1.3.0: + dependencies: + safe-buffer: 5.2.1 + dev: false + resolution: + integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== /to-fast-properties/2.0.0: dev: false engines: @@ -544,11 +697,10 @@ packages: hasBin: true resolution: integrity: sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== - /uuid/8.3.2: + /util-deprecate/1.0.2: dev: false - hasBin: true resolution: - integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= /void-elements/3.1.0: dev: false engines: @@ -570,60 +722,37 @@ packages: dev: false resolution: integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + /xtend/4.0.2: + dev: false + engines: + node: '>=0.4' + resolution: + integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== /yn/3.1.1: dev: false engines: node: '>=6' resolution: integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== - code.garrettmills.dev/extollo/di/902c31997016d24c735448bbf1e58f06e94f4139: - dependencies: - '@extollo/util': code.garrettmills.dev/extollo/util/131e0c93ee7ea67d771053fbd69fbf7f6612fadf - '@types/node': 14.14.37 - reflect-metadata: 0.1.13 - typescript: 4.2.3 - dev: false - name: '@extollo/di' - prepare: true - requiresBuild: true - resolution: - commit: 902c31997016d24c735448bbf1e58f06e94f4139 - repo: https://code.garrettmills.dev/extollo/di - type: git - version: 0.4.4 - code.garrettmills.dev/extollo/util/131e0c93ee7ea67d771053fbd69fbf7f6612fadf: - dependencies: - '@types/mkdirp': 1.0.1 - '@types/node': 14.14.37 - '@types/rimraf': 3.0.0 - '@types/ssh2': 0.5.46 - '@types/uuid': 8.3.0 - colors: 1.4.0 - mkdirp: 1.0.4 - rimraf: 3.0.2 - ssh2: 0.8.9 - typescript: 4.2.3 - uuid: 8.3.2 - dev: false - name: '@extollo/util' - prepare: true - requiresBuild: true - resolution: - commit: 131e0c93ee7ea67d771053fbd69fbf7f6612fadf - repo: https://code.garrettmills.dev/extollo/util - type: git - version: 0.3.2 specifiers: - '@extollo/di': git+https://code.garrettmills.dev/extollo/di - '@extollo/util': git+https://code.garrettmills.dev/extollo/util '@types/busboy': ^0.2.3 + '@types/mkdirp': ^1.0.1 '@types/negotiator': ^0.6.1 '@types/node': ^14.14.37 + '@types/pg': ^8.6.0 + '@types/pluralize': ^0.0.29 '@types/pug': ^2.0.4 + '@types/rimraf': ^3.0.0 + '@types/ssh2': ^0.5.46 busboy: ^0.3.1 colors: ^1.4.0 dotenv: ^8.2.0 + mkdirp: ^1.0.4 negotiator: ^0.6.2 + pg: ^8.6.0 + pluralize: ^8.0.0 pug: ^3.0.2 + rimraf: ^3.0.2 + ssh2: ^1.1.0 ts-node: ^9.1.1 typescript: ^4.2.3 diff --git a/src/cli/Directive.ts b/src/cli/Directive.ts new file mode 100644 index 0000000..a8442ff --- /dev/null +++ b/src/cli/Directive.ts @@ -0,0 +1,443 @@ +import {Injectable, Inject} from "../di" +import {infer, ErrorWithContext} from "../util" +import {CLIOption} from "./directive/options/CLIOption" +import {PositionalOption} from "./directive/options/PositionalOption"; +import {FlagOption} from "./directive/options/FlagOption"; +import {AppClass} from "../lifecycle/AppClass"; +import {Logging} from "../service/Logging"; + +/** + * Type alias for a definition of a command-line option. + * + * This can be either an instance of CLIOption or a string describing an option. + * + * @example + * Some examples of positional/flag options defined by strings: + * `'{file name} | canonical name of the resource to create'` + * + * `'--push -p {value} | the value to be pushed'` + * + * `'--force -f | do a force push'` + */ +export type OptionDefinition = CLIOption | string + +/** + * An error thrown when an invalid option was detected. + */ +export class OptionValidationError extends ErrorWithContext {} + +/** + * A base class representing a sub-command in the command-line utility. + */ +@Injectable() +export abstract class Directive extends AppClass { + @Inject() + protected readonly logging!: Logging + + /** Parsed option values. */ + private _optionValues: any + + /** + * Get the keyword or array of keywords that will specify this directive. + * + * @example + * If this returns `['up', 'start']`, the directive can be run by either of: + * + * ```shell + * ./ex up + * ./ex start + * ``` + */ + public abstract getKeywords(): string | string[] + + /** + * Get the usage description of this directive. Should be brief (1 sentence). + */ + public abstract getDescription(): string + + /** + * Optionally, specify a longer usage text that is shown on the directive's `--help` page. + */ + public getHelpText(): string { + return '' + } + + /** + * Get an array of options defined for this command. + */ + public getOptions(): OptionDefinition[] { + return [] + } + + /** + * Called when the directive is run from the command line. + * + * The raw arguments are provided as `argv`, but you are encouraged to use + * `getOptions()` and `option()` helpers to access the parsed options instead. + * + * @param argv + */ + public abstract handle(argv: string[]): void | Promise + + /** + * Sets the parsed option values. + * @param optionValues + * @private + */ + private _setOptionValues(optionValues: any) { + this._optionValues = optionValues; + } + + /** + * Get the value of a parsed option. If none exists, return `defaultValue`. + * @param name + * @param defaultValue + */ + public option(name: string, defaultValue?: any) { + if ( name in this._optionValues ) { + return this._optionValues[name] + } + + return defaultValue + } + + /** + * Invoke this directive with the specified arguments. + * + * If usage was requested (see `didRequestUsage()`), it prints the extended usage info. + * + * Otherwise, it parses the options from `argv` and calls `handle()`. + * + * @param argv + */ + async invoke(argv: string[]) { + const options = this.getResolvedOptions() + + if ( this.didRequestUsage(argv) ) { + // @ts-ignore + const positionalArguments: PositionalOption[] = options.filter(opt => opt instanceof PositionalOption) + + // @ts-ignore + const flagArguments: FlagOption[] = options.filter(opt => opt instanceof FlagOption) + + const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ') + const flagDisplay: string = flagArguments.length ? ' [...flags]' : '' + + console.log([ + '', + `DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`, + '', + `USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`, + ].join('\n')) + + if ( positionalArguments.length ) { + console.log([ + '', + `POSITIONAL ARGUMENTS:`, + ...(positionalArguments.map(arg => { + return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}` + })), + ].join('\n')) + } + + if ( flagArguments.length ) { + console.log([ + '', + `FLAGS:`, + ...(flagArguments.map(arg => { + return ` ${arg.shortFlag ? arg.shortFlag + ', ' : ''}${arg.longFlag}${arg.argumentDescription ? ' {' + arg.argumentDescription + '}' : ''}${arg.message ? ' - ' + arg.message : ''}` + })), + ].join('\n')) + } + + const help = this.getHelpText() + if ( help ) { + console.log('\n' + help) + } + + console.log('\n') + } else { + try { + const optionValues = this.parseOptions(options, argv) + this._setOptionValues(optionValues) + await this.handle(argv) + } catch (e) { + console.error(e.message) + if ( e instanceof OptionValidationError ) { + // expecting, value, requirements + if ( e.context.expecting ) { + console.error(` - Expecting: ${e.context.expecting}`) + } + + if ( e.context.requirements && Array.isArray(e.context.requirements) ) { + for ( const req of e.context.requirements ) { + console.error(` - ${req}`) + } + } + + if ( e.context.value ) { + console.error(` - ${e.context.value}`) + } + } + console.error('\nUse --help for more info.') + } + } + } + + /** + * Resolve the array of option definitions to CLIOption instances. + * Of note, this resolves the string-form definitions to actual CLIOption instances. + */ + public getResolvedOptions(): CLIOption[] { + return this.getOptions().map(option => { + if ( typeof option === 'string' ) { + return this.instantiateOptionFromString(option) + } else { + return option + } + }) + } + + /** + * Get the main keyword displayed for this directive. + * @example + * If `getKeywords()` returns `['up', 'start']`, this will return `'up'`. + */ + public getMainKeyword(): string { + const kws = this.getKeywords() + + if ( Array.isArray(kws) ) { + return kws[0] + } + + return kws + } + + /** + * Returns true if the given keyword should invoke this directive. + * @param name + */ + public matchesKeyword(name: string) { + let kws = this.getKeywords() + if ( !Array.isArray(kws) ) kws = [kws] + return kws.includes(name) + } + + /** + * Print the given output to the log as success text. + * @param output + */ + success(output: any) { + this.logging.success(output, true) + } + + /** + * Print the given output to the log as error text. + * @param output + */ + error(output: any) { + this.logging.error(output, true) + } + + /** + * Print the given output to the log as warning text. + * @param output + */ + warn(output: any) { + this.logging.warn(output, true) + } + + /** + * Print the given output to the log as info text. + * @param output + */ + info(output: any) { + this.logging.info(output, true) + } + + /** + * Print the given output to the log as debugging text. + * @param output + */ + debug(output: any) { + this.logging.debug(output, true) + } + + /** + * Print the given output to the log as verbose text. + * @param output + */ + verbose(output: any) { + this.logging.verbose(output, true) + } + + /** + * Get the flag option that signals help. Usually, this is named 'help' + * and supports the flags '--help' and '-?'. + */ + getHelpOption() { + return new FlagOption('--help', '-?', 'usage information about this directive') + } + + /** + * Process the raw CLI arguments using an array of option class instances to build + * a mapping of option names to provided values. + */ + parseOptions(options: CLIOption[], args: string[]) { + // @ts-ignore + let positionalArguments: PositionalOption[] = options.filter(cls => cls instanceof PositionalOption) + + // @ts-ignore + const flagArguments: FlagOption[] = options.filter(cls => cls instanceof FlagOption) + const optionValue: any = {} + + flagArguments.push(this.getHelpOption()) + + let expectingFlagArgument = false + let positionalFlagName = '' + for ( const value of args ) { + if ( value.startsWith('--') ) { + if ( expectingFlagArgument ) { + throw new OptionValidationError(`Unexpected flag argument. Expecting argument for flag: ${positionalFlagName}`, { + expecting: positionalFlagName, + }) + } else { + const flagArgument = flagArguments.filter(x => x.longFlag === value) + if ( flagArgument.length < 1 ) { + throw new OptionValidationError(`Unknown flag argument: ${value}`, { + value, + }) + } else { + if ( flagArgument[0].argumentDescription ) { + positionalFlagName = flagArgument[0].getArgumentName() + expectingFlagArgument = true + } else { + optionValue[flagArgument[0].getArgumentName()] = true + } + } + } + } else if ( value.startsWith('-') ) { + if ( expectingFlagArgument ) { + throw new OptionValidationError(`Unknown flag argument: ${value}`, { + expecting: positionalFlagName, + }) + } else { + const flagArgument = flagArguments.filter(x => x.shortFlag === value) + if ( flagArgument.length < 1 ) { + throw new OptionValidationError(`Unknown flag argument: ${value}`, { + value + }) + } else { + if ( flagArgument[0].argumentDescription ) { + positionalFlagName = flagArgument[0].getArgumentName() + expectingFlagArgument = true + } else { + optionValue[flagArgument[0].getArgumentName()] = true + } + } + } + } else if ( expectingFlagArgument ) { + const inferredValue = infer(value) + const optionInstance = flagArguments.filter(x => x.getArgumentName() === positionalFlagName)[0] + if ( !optionInstance.validate(inferredValue) ) { + throw new OptionValidationError(`Invalid value for argument: ${positionalFlagName}`, { + requirements: optionInstance.getRequirementDisplays(), + }) + } + + optionValue[positionalFlagName] = inferredValue + expectingFlagArgument = false + } else { + if ( positionalArguments.length < 1 ) { + throw new OptionValidationError(`Unknown positional argument: ${value}`, { + value + }) + } else { + const inferredValue = infer(value) + if ( !positionalArguments[0].validate(inferredValue) ) { + throw new OptionValidationError(`Invalid value for argument: ${positionalArguments[0].getArgumentName()}`, { + requirements: positionalArguments[0].getRequirementDisplays(), + }) + } + + optionValue[positionalArguments[0].getArgumentName()] = infer(value) + positionalArguments = positionalArguments.slice(1) + } + } + } + + if ( expectingFlagArgument ) { + throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, { + expecting: positionalFlagName + }) + } + + if ( positionalArguments.length > 0 ) { + throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, { + expecting: positionalArguments[0].getArgumentName() + }) + } + + return optionValue + } + + /** + * Create an instance of CLIOption based on a string definition of a particular format. + * + * e.g. '{file name} | canonical name of the resource to create' + * e.g. '--push -p {value} | the value to be pushed' + * e.g. '--force -f | do a force push' + * + * @param string + */ + protected instantiateOptionFromString(string: string): CLIOption { + if ( string.startsWith('{') ) { + // The string is a positional argument + const stringParts = string.split('|').map(x => x.trim()) + const name = stringParts[0].replace(/\{|\}/g, '') + return stringParts.length > 1 ? (new PositionalOption(name, stringParts[1])) : (new PositionalOption(name)) + } else { + // The string is a flag argument + const stringParts = string.split('|').map(x => x.trim()) + + // Parse the flag parts first + const hasArgument = stringParts[0].indexOf('{') >= 0 + const flagString = hasArgument ? stringParts[0].substr(0, stringParts[0].indexOf('{')).trim() : stringParts[0].trim() + const flagParts = flagString.split(' ') + + let longFlag = flagParts[0].startsWith('--') ? flagParts[0] : undefined + if ( !longFlag && flagParts.length > 1 ) { + if ( flagParts[1].startsWith('--') ) { + longFlag = flagParts[1] + } + } + + let shortFlag = flagParts[0].length === 2 ? flagParts[0] : undefined + if ( !shortFlag && flagParts.length > 1 ) { + if ( flagParts[1].length === 2 ) { + shortFlag = flagParts[1] + } + } + + const argumentDescription = hasArgument ? stringParts[0].substring(stringParts[0].indexOf('{')+1, stringParts[0].indexOf('}')) : undefined + const description = stringParts.length > 1 ? stringParts[1] : undefined + + return new FlagOption(longFlag, shortFlag, description, argumentDescription) + } + } + + /** + * Determines if, at any point in the arguments, the help option's short or long flag appears. + * @returns {boolean} - true if the help flag appeared + */ + didRequestUsage(argv: string[]) { + const help_option = this.getHelpOption() + for ( const arg of argv ) { + if ( arg.trim() === help_option.longFlag || arg.trim() === help_option.shortFlag ) { + return true + } + } + + return false + } +} diff --git a/src/cli/Template.ts b/src/cli/Template.ts new file mode 100644 index 0000000..955cb1f --- /dev/null +++ b/src/cli/Template.ts @@ -0,0 +1,64 @@ +import {UniversalPath} from '../util' + +/** + * Interface defining a template that can be generated using the TemplateDirective. + */ +export interface Template { + /** + * The name of the template as it will be specified from the command line. + * + * @example + * If this is `'mytemplate'`, then the template will be created with: + * + * ```shell + * ./ex new mytemplate some:path + * ``` + */ + name: string, + + /** + * The suffix of the file generated by this template. + * @example `.mytemplate.ts` + * @example `.controller.ts` + */ + fileSuffix: string, + + /** + * Brief description of the template displayed on the --help page for the TemplateDirective. + * Should be brief (1 sentence). + */ + description: string, + + /** + * Array of path-strings that are resolved relative to the base `app` directory. + * @example `['http', 'controllers']` + * @example `['units']` + */ + baseAppPath: string[], + + /** + * Render the given template to a string which will be written to the file. + * Note: this method should NOT write the contents to `targetFilePath`. + * + * @example + * If the user enters: + * + * ```shell + * ./ex new mytemplate path:to:NewInstance + * ``` + * + * Then, the following params are: + * ```typescript + * { + * name: 'NewInstance', + * fullCanonicalPath: 'path:to:NewInstance', + * targetFilePath: UniversalPath { } + * } + * ``` + * + * @param name - the singular name of the resource + * @param fullCanonicalName - the full canonical name of the resource + * @param targetFilePath - the UniversalPath where the file will be written + */ + render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => string | Promise +} diff --git a/src/cli/directive/RunDirective.ts b/src/cli/directive/RunDirective.ts new file mode 100644 index 0000000..1b8a1ac --- /dev/null +++ b/src/cli/directive/RunDirective.ts @@ -0,0 +1,31 @@ +import {Directive} from "../Directive" +import {CommandLineApplication} from "../service" +import {Injectable} from "../../di" +import {ErrorWithContext} from "../../util" +import {Unit} from "../../lifecycle/Unit"; + +/** + * A directive that starts the framework's final target normally. + * In most cases, this runs the HTTP server, which would have been replaced + * by the CommandLineApplication unit. + */ +@Injectable() +export class RunDirective extends Directive { + getDescription(): string { + return 'run the application normally' + } + + getKeywords(): string | string[] { + return ['run', 'up'] + } + + async handle(): Promise { + if ( !CommandLineApplication.getReplacement() ) { + throw new ErrorWithContext(`Cannot run application: no run target specified.`) + } + + const unit = this.make(CommandLineApplication.getReplacement()) + await this.app().startUnit(unit) + await this.app().stopUnit(unit) + } +} diff --git a/src/cli/directive/ShellDirective.ts b/src/cli/directive/ShellDirective.ts new file mode 100644 index 0000000..ae60634 --- /dev/null +++ b/src/cli/directive/ShellDirective.ts @@ -0,0 +1,47 @@ +import {Directive} from "../Directive" +import * as colors from "colors/safe" +import * as repl from 'repl' +import {DependencyKey} from "../../di"; + +/** + * Launch an interactive REPL shell from within the application. + * This is very useful for debugging and testing things during development. + */ +export class ShellDirective extends Directive { + protected options: any = { + welcome: `powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`, + prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`, + } + + /** + * The created Node.js REPL server. + * @protected + */ + protected repl?: repl.REPLServer + + getDescription(): string { + return 'launch an interactive shell inside your application' + } + + getKeywords(): string | string[] { + return ['shell'] + } + + getHelpText(): string { + return '' + } + + async handle(): Promise { + const state: any = { + app: this.app(), + make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters), + } + + await new Promise(res => { + console.log(this.options.welcome) + this.repl = repl.start(this.options.prompt) + Object.assign(this.repl.context, state) + this.repl.on('exit', () => res()) + }) + } +} diff --git a/src/cli/directive/TemplateDirective.ts b/src/cli/directive/TemplateDirective.ts new file mode 100644 index 0000000..e700429 --- /dev/null +++ b/src/cli/directive/TemplateDirective.ts @@ -0,0 +1,90 @@ +import {Directive, OptionDefinition} from "../Directive" +import {PositionalOption} from "./options/PositionalOption" +import {CommandLine} from "../service" +import {Inject, Injectable} from "../../di"; +import {ErrorWithContext} from "../../util"; + +/** + * Create a new file based on a template registered with the CommandLine service. + */ +@Injectable() +export class TemplateDirective extends Directive { + @Inject() + protected readonly cli!: CommandLine + + getKeywords(): string | string[] { + return ['new', 'make'] + } + + getDescription(): string { + return 'create a new file from a registered template' + } + + getOptions(): OptionDefinition[] { + const registeredTemplates = this.cli.getTemplates() + const template = new PositionalOption('template_name', 'the template to base the new file on (e.g. model, controller)') + template.whitelist(...registeredTemplates.pluck('name').all()) + + const destination = new PositionalOption('file_name', 'canonical name of the file to create (e.g. auth:Group, dash:Activity)') + + return [template, destination] + } + + getHelpText(): string { + const registeredTemplates = this.cli.getTemplates() + + return [ + 'Modules in Extollo register templates that can be used to quickly create common file types.', + '', + 'For example, you can create a new model from @extollo/orm using the "model" template:', + '', + './ex new model auth:Group', + '', + 'This would create a new Group model in the ./src/app/models/auth/Group.model.ts file.', + '', + 'AVAILABLE TEMPLATES:', + '', + ...(registeredTemplates.map(template => { + return ` - ${template.name}: ${template.description}` + }).all()) + ].join('\n') + } + + async handle(argv: string[]) { + const templateName: string = this.option('template_name') + const destinationName: string = this.option('file_name') + + if ( destinationName.includes('/') || destinationName.includes('\\') ) { + this.error(`The destination should be a canonical name, not a file path.`) + this.error(`Reference sub-directories using the : character instead.`) + this.error(`Did you mean ${destinationName.replace(/\/|\\/g, ':')}?`) + process.exitCode = 1 + return + } + + const template = this.cli.getTemplate(templateName) + if ( !template ) { + throw new ErrorWithContext(`Unable to find template supposedly registered with name: ${templateName}`, { + templateName, + destinationName, + }) + } + + const name = destinationName.split(':').reverse()[0] + const path = this.app().path('..', 'src', 'app', ...template.baseAppPath, ...(`${destinationName}${template.fileSuffix}`).split(':')) + + if ( await path.exists() ) { + this.error(`File already exists: ${path}`) + process.exitCode = 1 + return + } + + // Make sure the parent direction exists + await path.concat('..').mkdir() + + const contents = await template.render(name, destinationName, path.clone()) + await path.write(contents) + + this.success(`Created new ${template.name} in ${path}`) + } +} diff --git a/src/cli/directive/UsageDirective.ts b/src/cli/directive/UsageDirective.ts new file mode 100644 index 0000000..aaddeeb --- /dev/null +++ b/src/cli/directive/UsageDirective.ts @@ -0,0 +1,54 @@ +import {Directive} from "../Directive" +import {Injectable, Inject} from "../../di" +import {padRight} from "../../util" +import {CommandLine} from "../service" + +/** + * Directive that prints the help message and usage information about + * directives registered with the command line utility. + */ +@Injectable() +export class UsageDirective extends Directive { + @Inject() + protected readonly cli!: CommandLine + + public getKeywords(): string | string[] { + return 'help' + } + + public getDescription(): string { + return 'print information about available commands' + } + + public handle(argv: string[]): void | Promise { + const directiveStrings = this.cli.getDirectives() + .map(cls => this.make(cls)) + .map<[string, string]>(dir => { + return [dir.getMainKeyword(), dir.getDescription()] + }) + + const maxLen = directiveStrings.max(x => x[0].length) + + const printStrings = directiveStrings.map(grp => { + return [padRight(grp[0], maxLen + 1), grp[1]] + }) + .map(grp => { + return ` ${grp[0]}: ${grp[1]}` + }) + .toArray() + + console.log(this.cli.getASCIILogo()) + console.log([ + '', + 'Welcome to Extollo! Specify a command to get started.', + '', + `USAGE: ex [..options]`, + '', + ...printStrings, + '', + 'For usage information about a particular command, pass the --help flag.', + '-------------------------------------------', + `powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills`, + ].join('\n')) + } +} diff --git a/src/cli/directive/options/CLIOption.ts b/src/cli/directive/options/CLIOption.ts new file mode 100644 index 0000000..a8bed39 --- /dev/null +++ b/src/cli/directive/options/CLIOption.ts @@ -0,0 +1,244 @@ +/** + * A CLI option. Supports basic comparative, and set-based validation. + * @class + */ +export abstract class CLIOption { + + /** + * Do we use the whitelist? + * @type {boolean} + * @private + */ + protected _useWhitelist: boolean = false + + /** + * Do we use the blacklist? + * @type {boolean} + * @private + */ + protected _useBlacklist: boolean = false + + /** + * Do we use the less-than comparison? + * @type {boolean} + * @private + */ + protected _useLessThan: boolean = false + + /** + * Do we use the greater-than comparison? + * @type {boolean} + * @private + */ + protected _useGreaterThan: boolean = false + + /** + * Do we use the equality operator? + * @type {boolean} + * @private + */ + protected _useEquality: boolean = false + + /** + * Is this option optional? + * @type {boolean} + * @private + */ + protected _optional: boolean = false + + /** + * Whitelisted values. + * @type {Array<*>} + * @private + */ + protected _whitelist: T[] = [] + + /** + * Blacklisted values. + * @type {Array<*>} + * @private + */ + protected _blacklist: T[] = [] + + /** + * Value to be compared in less than. + * @type {*} + * @private + */ + protected _lessThanValue?: T + + /** + * If true, the less than will be less than or equal to. + * @type {boolean} + * @private + */ + protected _lessThanBit: boolean = false + + /** + * Value to be compared in greater than. + * @type {*} + * @private + */ + protected _greaterThanValue?: T + + /** + * If true, the greater than will be greater than or equal to. + * @type {boolean} + * @private + */ + protected _greateerThanBit: boolean = false + + /** + * The value to be used to check equality. + * @type {*} + * @private + */ + protected _equalityValue?: T + + /** + * Whitelist the specified item or items and enable the whitelist. + * @param {...*} items - the items to whitelist + */ + whitelist(...items: T[]) { + this._useWhitelist = true + items.forEach(item => this._whitelist.push(item)) + } + + /** + * Blacklist the specified item or items and enable the blacklist. + * @param {...*} items - the items to blacklist + */ + blacklist(...items: T[]) { + this._useBlacklist = true + items.forEach(item => this._blacklist.push(item)) + } + + /** + * Specifies the value to be used in less-than comparison and enables less-than comparison. + * @param {*} value + */ + lessThan(value: T) { + this._useLessThan = true + this._lessThanValue = value + } + + /** + * Specifies the value to be used in less-than or equal-to comparison and enables that comparison. + * @param {*} value + */ + lessThanOrEqualTo(value: T) { + this._lessThanBit = true + this.lessThan(value) + } + + /** + * Specifies the value to be used in greater-than comparison and enables that comparison. + * @param {*} value + */ + greaterThan(value: T) { + this._useGreaterThan = true + this._greaterThanValue = value + } + + /** + * Specifies the value to be used in greater-than or equal-to comparison and enables that comparison. + * @param {*} value + */ + greaterThanOrEqualTo(value: T) { + this._greateerThanBit = true + this.greaterThan(value) + } + + /** + * Specifies the value to be used in equality comparison and enables that comparison. + * @param {*} value + */ + equals(value: T) { + this._useEquality = true + this._equalityValue = value + } + + /** + * Checks if the specified value passes the configured comparisons. + * @param {*} value + * @returns {boolean} + */ + validate(value: any) { + let is_valid = true + if ( this._useEquality ) { + is_valid = is_valid && (this._equalityValue === value) + } + + if ( this._useLessThan && typeof this._lessThanValue !== 'undefined' ) { + if ( this._lessThanBit ) { + is_valid = is_valid && (value <= this._lessThanValue) + } else { + is_valid = is_valid && (value < this._lessThanValue) + } + } + + if ( this._useGreaterThan && typeof this._greaterThanValue !== 'undefined' ) { + if ( this._greateerThanBit ) { + is_valid = is_valid && (value >= this._greaterThanValue) + } else { + is_valid = is_valid && (value > this._greaterThanValue) + } + } + + if ( this._useWhitelist ) { + is_valid = is_valid && this._whitelist.some(x => { + return x === value + }) + } + + if ( this._useBlacklist ) { + is_valid = is_valid && !(this._blacklist.some(x => x === value)) + } + + return is_valid + } + + /** + * Sets the Option as optional. + */ + optional() { + this._optional = true + return this + } + + /** + * Get the argument name. Should be overridden by child classes. + * @returns {string} + */ + abstract getArgumentName(): string + + /** + * Get an array of strings denoting the human-readable requirements for this option to be valid. + * @returns {Array} + */ + getRequirementDisplays() { + const clauses = [] + + if ( this._useBlacklist ) { + clauses.push(`must not be one of: ${this._blacklist.map(x => String(x)).join(', ')}`) + } + + if ( this._useWhitelist ) { + clauses.push(`must be one of: ${this._whitelist.map(x => String(x)).join(', ')}`) + } + + if ( this._useGreaterThan ) { + clauses.push(`must be greater than${this._greateerThanBit ? ' or equal to' : ''}: ${String(this._greaterThanValue)}`) + } + + if ( this._useLessThan ) { + clauses.push(`must be less than${this._lessThanBit ? ' or equal to' : ''}: ${String(this._lessThanValue)}`) + } + + if ( this._useEquality ) { + clauses.push(`must be equal to: ${String(this._equalityValue)}`) + } + + return clauses + } +} diff --git a/src/cli/directive/options/FlagOption.ts b/src/cli/directive/options/FlagOption.ts new file mode 100644 index 0000000..436b0d9 --- /dev/null +++ b/src/cli/directive/options/FlagOption.ts @@ -0,0 +1,45 @@ +import {CLIOption} from "./CLIOption" + +/** + * Non-positional, flag-based CLI option. + */ +export class FlagOption extends CLIOption { + + constructor( + /** + * The long-form flag for this option. + * @example --path, --create + */ + public readonly longFlag?: string, + /** + * The short-form flag for this option. + * @example -p, -c + */ + public readonly shortFlag?: string, + /** + * Usage message describing this flag. + */ + public readonly message?: string, + /** + * Description of the argument required by this flag. + * If this is set, the flag will expect a positional argument to follow as a param. + */ + public readonly argumentDescription?: string + ) { super() } + + /** + * Get the referential name for this option. + * Defaults to the long flag (without the '--'). If this cannot + * be found, the short flag (without the '-') is used. + * @returns {string} + */ + getArgumentName() { + if ( this.longFlag ) { + return this.longFlag.replace('--', '') + } else if ( this.shortFlag ) { + return this.shortFlag.replace('-', '') + } + + throw new Error('Missing either a long- or short-flag for FlagOption.') + } +} diff --git a/src/cli/directive/options/PositionalOption.ts b/src/cli/directive/options/PositionalOption.ts new file mode 100644 index 0000000..7b0f173 --- /dev/null +++ b/src/cli/directive/options/PositionalOption.ts @@ -0,0 +1,32 @@ +import {CLIOption} from "./CLIOption" + +/** + * A positional CLI option. Defined without a flag. + */ +export class PositionalOption extends CLIOption { + + /** + * Instantiate the option. + * @param {string} name - the name of the option + * @param {string} message - message describing the option + */ + constructor( + /** + * The display name of this positional argument. + * @example path, filename + */ + public readonly name: string, + /** + * A usage message describing this parameter. + */ + public readonly message: string = '' + ) { super() } + + /** + * Gets the name of the option. + * @returns {string} + */ + getArgumentName () { + return this.name + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..8bc4fa8 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,13 @@ +export * from './Directive' +export * from './Template' + +export * from './service/CommandLineApplication' +export * from './service/CommandLine' + +export * from './directive/options/CLIOption' +export * from './directive/options/FlagOption' +export * from './directive/options/PositionalOption' + +export * from './directive/ShellDirective' +export * from './directive/TemplateDirective' +export * from './directive/UsageDirective' diff --git a/src/cli/service/CommandLine.ts b/src/cli/service/CommandLine.ts new file mode 100644 index 0000000..e7caf5b --- /dev/null +++ b/src/cli/service/CommandLine.ts @@ -0,0 +1,124 @@ +import {Singleton, Instantiable, Inject} from "../../di" +import {Collection} from "../../util" +import {CommandLineApplication} from "./CommandLineApplication" +import {Directive} from "../Directive" +import {Template} from "../Template" +import {directive_template} from "../templates/directive" +import {unit_template} from "../templates/unit"; +import {controller_template} from "../templates/controller"; +import {middleware_template} from "../templates/middleware"; +import {routes_template} from "../templates/routes"; +import {config_template} from "../templates/config"; +import {Unit} from "../../lifecycle/Unit"; +import {Logging} from "../../service/Logging"; + +/** + * Service for managing directives, templates, and other resources related + * to the command line utilities. + */ +@Singleton() +export class CommandLine extends Unit { + @Inject() + protected readonly logging!: Logging + + /** Directive classes registered with the CLI command. */ + protected directives: Collection> = new Collection>() + + /** Templates registered with the CLI command. These can be created with the TemplateDirective. */ + protected templates: Collection