├── .bowerrc ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue_template.md ├── pull_request_template.md └── workflows │ ├── addtomainproject.yml │ └── releases.yml ├── .gitignore ├── .travis.yml ├── README.md ├── bin └── adapt.js ├── json ├── help-create.json ├── help-create │ ├── component.json │ ├── course.json │ └── question.json ├── help-devinstall.json ├── help-install.json ├── help-ls.json ├── help-register.json ├── help-rename.json ├── help-search.json ├── help-uninstall.json ├── help-unregister.json ├── help-update.json ├── help-version.json └── help.json ├── lib ├── api.js ├── cli.js ├── commands │ ├── authenticate.js │ ├── create.js │ ├── create │ │ ├── component.js │ │ ├── course.js │ │ ├── extension.js │ │ └── question.js │ ├── devinstall.js │ ├── help.js │ ├── install.js │ ├── ls.js │ ├── register.js │ ├── rename.js │ ├── search.js │ ├── uninstall.js │ ├── unregister.js │ ├── update.js │ └── version.js ├── econnreset.js ├── integration │ ├── AdaptFramework.js │ ├── AdaptFramework │ │ ├── build.js │ │ ├── clone.js │ │ ├── deleteSrcCore.js │ │ ├── deleteSrcCourse.js │ │ ├── download.js │ │ ├── erase.js │ │ ├── getLatestVersion.js │ │ └── npmInstall.js │ ├── Plugin.js │ ├── PluginManagement.js │ ├── PluginManagement │ │ ├── autenticate.js │ │ ├── install.js │ │ ├── print.js │ │ ├── register.js │ │ ├── rename.js │ │ ├── schemas.js │ │ ├── search.js │ │ ├── uninstall.js │ │ ├── unregister.js │ │ └── update.js │ ├── Project.js │ ├── Target.js │ └── getBowerRegistryConfig.js ├── logger.js └── util │ ├── JSONReadValidate.js │ ├── constants.js │ ├── createPromptTask.js │ ├── download.js │ ├── errors.js │ ├── extract.js │ ├── getDirNameFromImportMeta.js │ └── promises.js ├── package-lock.json └── package.json /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "registry": "http://adapt-bower-repository.herokuapp.com/" 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "node": true, 5 | "commonjs": false, 6 | "es2020": true 7 | }, 8 | "extends": [ 9 | "standard" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 2020 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | bin/adapt text eol=lf -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We heartily welcome contributions to the Adapt project source code and community. 2 | Here is a list of resources you may find useful: 3 | 4 | * [Contributing to the Adapt project documentation](https://github.com/adaptlearning/adapt_framework/wiki/Contributing-to-the-Adapt-Project) 5 | * [The Adapt framework wiki](https://github.com/adaptlearning/adapt_framework/wiki) 6 | * [Gitter chat room](https://gitter.im/adaptlearning/adapt_framework) 7 | * [General GitHub documentation](http://help.github.com/) 8 | * [GitHub pull request documentation](http://help.github.com/send-pull-requests/) 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Subject of the issue/enhancement/features 2 | Describe your issue here. 3 | 4 | ### Your environment 5 | * version of the cli 6 | * operating system(s) 7 | 8 | ### Steps to reproduce 9 | Tell us how to reproduce this issue. 10 | 11 | ### Expected behaviour 12 | Tell us what should happen 13 | 14 | ### Actual behaviour 15 | Tell us what happens instead 16 | 17 | ### Screenshots (if you can) 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: Create an issue to help us improve 4 | --- 5 | 6 | ### Subject of the issue/enhancement/features 7 | Describe your issue here. 8 | 9 | ### Your environment 10 | * version (AT/Framework) 11 | * which browser and its version 12 | * device(s) + operating system(s) 13 | 14 | ### Steps to reproduce 15 | Tell us how to reproduce this issue. 16 | 17 | ### Expected behaviour 18 | Tell us what should happen 19 | 20 | ### Actual behaviour 21 | Tell us what happens instead 22 | 23 | ### Screenshots (if you can) 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | [//]: # (Please title your PR according to eslint commit conventions) 2 | [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details) 3 | 4 | [//]: # (Link the PR to the original issue) 5 | 6 | [//]: # (Delete Fix, Update, New and/or Breaking sections as appropriate) 7 | ### Fix 8 | * A sentence describing each fix 9 | 10 | ### Update 11 | * A sentence describing each update 12 | 13 | ### New 14 | * A sentence describing each new feature 15 | 16 | ### Breaking 17 | * A sentence describing each breaking change 18 | 19 | [//]: # (List appropriate steps for testing if needed) 20 | ### Testing 21 | 1. Steps for testing 22 | 23 | [//]: # (Mention any other dependencies) 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/addtomainproject.yml: -------------------------------------------------------------------------------- 1 | name: Add to main project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | add-to-project: 13 | name: Add to main project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/add-to-project@v0.1.0 17 | with: 18 | project-url: https://github.com/orgs/adaptlearning/projects/2 19 | github-token: ${{ secrets.ADDTOPROJECT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 'lts/*' 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Release 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | run: npx semantic-release 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # we don't want to include the src file that may be installed by the cli 3 | src 4 | bower.json 5 | adapt.json 6 | .idea 7 | .idea/workspace.xml 8 | my-adapt-course 9 | test-plugin 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | node_js: 4 | - "lts/*" 5 | 6 | sudo: false 7 | 8 | env: 9 | - CXX=g++-4.8 10 | 11 | addons: 12 | apt: 13 | sources: 14 | - ubuntu-toolchain-r-test 15 | packages: 16 | - g++-4.8 17 | 18 | branches: 19 | only: 20 | - master 21 | - develop 22 | 23 | git: 24 | depth: 10 25 | 26 | cache: 27 | directories: 28 | - node_modules 29 | 30 | before_script: 31 | - npm install -g grunt-cli 32 | 33 | install: 34 | - npm config set spin false 35 | - npm install 36 | 37 | matrix: 38 | fast_finish: true 39 | 40 | notifications: 41 | webhooks: 42 | urls: 43 | - https://webhooks.gitter.im/e/2b42e20cf5f5550c1dc0 44 | on_success: change # options: [always|never|change] default: always 45 | on_failure: always # options: [always|never|change] default: always 46 | on_start: never # options: [always|never|change] default: always 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Adapt Command Line Interface (CLI) 2 | ============================ 3 | 4 | [![Build Status](https://travis-ci.org/adaptlearning/adapt-cli.png?branch=master)](https://travis-ci.org/adaptlearning/adapt-cli) [![Join the chat at https://gitter.im/adaptlearning/adapt-cli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/adaptlearning/adapt-cli?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 5 | 6 | The **Adapt CLI** is a command line interface for use with the [Adapt framework](https://github.com/adaptlearning/adapt_framework). Its primary usefulness is to install, update, and uninstall Adapt plug-ins. In doing so, it references the [Adapt Plugin Browser](https://www.adaptlearning.org/index.php/plugin-browser/). Consequently, the CLI includes several commands for working with this plug-in registry. 7 | 8 | >IMPORTANT: The **Adapt CLI** is not intended to be used with courses in the [Adapt authoring tool](https://github.com/adaptlearning/adapt_authoring). The authoring tool tracks versions of plug-ins in its database. Using the CLI bypasses this tracking. 9 | 10 | ## Installation 11 | 12 | Before installing the Adapt CLI, you must install [NodeJS](http://nodejs.org) and [git](http://git-scm.com/downloads). 13 | To install the Adapt CLI globally, run the following command: 14 | 15 | `npm install -g adapt-cli` 16 | 17 | Some systems may require elevated permissions in order to install a script globally. 18 | 19 | ## Commands 20 | 21 | ### adapt version 22 | 23 | ##### Model: 24 | `adapt version` 25 | 26 | This command reports both the version of the CLI and the version of the Adapt framework. The version of the framework is reported as "0.0.0" unless the command is run from the root directory of an installed framework. 27 |
Back to Top
28 | 29 | ### adapt help 30 | 31 | ##### Model: 32 | `adapt help []` 33 | 34 | **command**: A CLI command. 35 | 36 | ##### Examples: 37 | 38 | 1. To list all the CLI commands with a brief description of each: 39 | `adapt help` 40 | 41 | 1. To report a brief description of a specific command: 42 | `adapt help create` 43 | 44 |
Back to Top
45 | 46 | ### adapt create 47 | 48 | 49 | ##### Model: 50 | `adapt create [ ]` 51 | 52 | **type**: What to create. Acceptable values are `course` and `component`. 53 | **path**: The directory of the new course. Enclose in quotes if the path/name includes spaces. 54 | **branch|tag**: The branch or tag name of the framework to be downloaded. This is optional. If not specified, the master branch will be used. 55 | 56 | ##### Examples: 57 | 58 | 1. To create an Adapt course *(do not use in conjunction with the Adapt authoring tool)*: 59 | `adapt create course "My Course"` 60 | This will create a new directory named "My Course" in your current working directory. It will download the Adapt framework from its master branch, including the default course, into the new directory. Before using the course, run: 61 | `grunt build` 62 | 63 | 1. To create an Adapt course from a specific branch: 64 | `adapt create course "My Course" legacy` 65 | This is the same as example 1 except that the framework will be downloaded from the 'legacy' branch, not from the default master branch. 66 | 67 | 1. To create an Adapt course from a specific tag: 68 | `adapt create course "My Course" v3.5.1` 69 | This is the same as example 1 except that v3.5.1 of the framework will be downloaded, rather than the master branch. 70 | 71 | 1. To create an empty component: 72 | `adapt create component "test-component"` 73 | This command is useful for developing new components. It will create a new directory named "test-component" in the current working directory. It will populate the directory with files required for an Adapt component and insert "test-component" into the code where required. 74 |
Back to Top
75 | 76 | ### adapt search 77 | 78 | ##### Model: 79 | `adapt search []` 80 | 81 | **plug-in**: The optional name of the plug-in that you want to search for. Any part of the name may be used; multiple results may be returned if the partial name is not unique. 82 | 83 | The **search** command searches within [Adapt Plugin Browser](https://www.adaptlearning.org/index.php/plugin-browser/). It will return the plug-in's complete name and its source repository only if the plug-in is registered. 84 | 85 | ##### Examples: 86 | 1. To view the name and repository of all plug-ins registered with the Adapt Plugin Browser: 87 | `adapt search` 88 | 89 | 1. To locate the repository of a known plug-in that is registered: 90 | `adapt search adapt-contrib-pageLevelProgress` OR 91 | `adapt search contrib-pageLevelProgress` OR 92 | `adapt search pageLevel` 93 | 94 | 95 | ### adapt install 96 | 97 | ##### Models: 98 | `adapt install [#|@]` 99 | `adapt install [--dry-run|--compatible]` 100 | 101 | **plug-in**: The name of the plug-in to be installed. The name may be fully qualified with "adapt-" or the prefix may be omitted. Parts of names are not acceptable. 102 | **version**: A specific version number of the plug-in. This is optional. 103 | 104 | ##### Examples: 105 | 1. To install all plug-ins listed in *adapt.json*: 106 | `adapt install` 107 | This command must be run in the root directory of an Adapt framework installation. 108 | 109 | 1. To report the plug-ins that will be installed if `adapt install` is run: 110 | `adapt install --dry-run` 111 | This command must be run in the root directory of an Adapt framework installation. 112 | 113 | 1. To install versions of all plug-ins listed in *adapt.json* that are compatible with the installed framework version. This overrides any incompatible settings provided on the command line or in *adapt.json*. 114 | `adapt install --compatible` 115 | This command must be run in the root directory of an Adapt framework installation. 116 | 117 | 1. To install a plug-in that has been registered with the [Adapt Plug-in Browser](https://www.adaptlearning.org/index.php/plugin-browser/) registry: 118 | `adapt install adapt-contrib-pageLevelProgress` OR 119 | `adapt install contrib-pageLevelProgress` 120 | 121 | 1. To install a specific version of a registered plug-in: 122 | `adapt install adapt-contrib-pageLevelProgress#1.1.0` OR 123 | `adapt install adapt-contrib-pageLevelProgress@1.1.0` OR 124 | `adapt install contrib-pageLevelProgress#1.1.0` OR 125 | `adapt install contrib-pageLevelProgress@1.1.0` 126 | 127 | 1. To use the CLI to install a plug-in that is not registered with [Adapt Plug-in Browser](https://www.adaptlearning.org/index.php/plugin-browser/) registry: 128 | 1. Copy the uncompressed folder to the proper location for the type of plug-in. Components: *src/components*. Extensions: *src/extensions*. Menu: *src/menu*. Theme: *src/theme*. Note: The Adapt framework allows only one theme. Uninstall the current before replacing it with an alternative. More than one menu is allowed. 129 | 1. Open *adapt.json* in an editor and add the full name of the new component to the list. 130 | 1. Run the following command from the course root: 131 | `adapt install` 132 | After installation, most CLI commands will operate on the plug-in with the exception of `adapt update`. Plug-ins must be registered with the [Adapt Plugin Browser](https://www.adaptlearning.org/index.php/plugin-browser/) for `adapt update` to succeed. 133 | 134 | 1. To update all registered plug-ins to their most recent public release: 135 | `adapt update` 136 | Since no plug-in name is specified in this command, all plug-ins listed in *adapt.json* are reinstalled. Whether the plug-in is updated will be determined by the compatible framework versions specified in *adapt.json*. If it includes a plug-in that is not registered, it will not be updated. 137 | **Note to developers:** The CLI determines newest version by comparing release tags in the GitHub repo. Be sure to use a tag when you release a new version. 138 | 139 | 140 | ### adapt ls 141 | 142 | ##### Model: 143 | `adapt ls` 144 | 145 | This command lists the name and version number of all plug-ins listed in the *adapt.json* file of the current directory. 146 | 147 | 148 | ### adapt uninstall 149 | 150 | ##### Model: 151 | `adapt uninstall ` 152 | 153 | **plug-in**: The name of the plug-in to be installed. The name may be fully qualified with "adapt-" or the prefix may be omitted. Parts of names are not acceptable. 154 | 155 | ##### Examples: 156 | 1. To uninstall a plug-in: 157 | `adapt uninstall adapt-contrib-pageLevelProgress` OR 158 | `adapt uninstall contrib-pageLevelProgress` 159 | Because the plug-in registry is not referenced during the uninstall process, this command will work whether or not the plug-in is registered with the Adapt Plugin Browser.. 160 | 161 | 162 | ### adapt devinstall 163 | 164 | ##### Model: 165 | `adapt devinstall [[#]]` 166 | 167 | **plug-in**: Name of the plug-in to be cloned. 168 | **version**: Version of the plug-in to be installed. 169 | 170 | ##### Examples: 171 | 172 | 1. To clone as git repositories the Adapt framework and all the plug-ins listed in *adapt.json* of the current directory: 173 | `adapt devinstall` 174 | 175 | 1. To clone a specific plug-in listed in the *adapt.json*: 176 | `adapt devinstall adapt-contrib-matching` 177 | 178 | 1. To clone a specific version of a plug-in listed in the *adapt.json*: 179 | `adapt devinstall adapt-contrib-matching#v2.2.0` 180 | 181 | 182 | ### adapt update 183 | 184 | ##### Models: 185 | `adapt update [[#|@]][--check]` 186 | `adapt update [components|extensions|menu|theme|all][--check]` 187 | 188 | **plug-in**: Name of the plug-in to be cloned. 189 | **version**: Version of the plug-in to be installed. 190 | Before running the update command, ensure that there is no *bower.json* file in your project directory. 191 | 192 | ##### Examples: 193 | 194 | 1. To report the latest compatible version for each plug-in in the current directory (plug-ins are not updated): 195 | `adapt update --check` 196 | Note: The `--check` option may be used to report on a specific plug-in or on a complete plug-in group (components, extensions, theme, menu): 197 | `adapt update adapt-contrib-matching --check` 198 | `adapt update extensions --check` 199 | 200 | 1. To update a registered plug-in: 201 | `adapt update adapt-contrib-pageLevelProgress` OR 202 | `adapt update contrib-pageLevelProgress` 203 | 204 | 1. To update a specific version of a registered plug-in: 205 | `adapt update adapt-contrib-pageLevelProgress#1.1.0` OR 206 | `adapt update adapt-contrib-pageLevelProgress@1.1.0` OR 207 | `adapt update contrib-pageLevelProgress#1.1.0` OR 208 | `adapt update contrib-pageLevelProgress@1.1.0` 209 | 210 | 211 | ### adapt register 212 | 213 | ##### Command: 214 | `adapt register` 215 | 216 | This command must be run from within the root directory of the plug-in you want to register. "name" and "repository" will be read from *bower.json* in the current directory. The plug-in name must be prefixed with "adapt-" and each word separated with a hyphen (-). Plug-in names are checked against those already registered to avoid duplicates. 217 | 218 | 219 | URL format must be of the form `https://github.com//.git` 220 | 221 | ### adapt rename 222 | 223 | ##### Command: 224 | `adapt rename ` 225 | 226 | **current-name**: Name of the plug-in currently used in the plug-in registry. 227 | **new-name**: Name proposed to replace the current plug-in name. 228 | 229 | Please note that you must authenticate with GitHub to use **rename**. You must be a collaborator on the endpoint registered with the plug-in or a collaborator on the Adapt framework. Access to GitHub is for authentication only. 230 | 231 | ##### Example: 232 | 233 | 1. To rename a plug-in: 234 | `adapt rename adapt-incorrectName adapt-betterName` 235 | 236 | 237 | ### adapt unregister 238 | 239 | ##### Command: 240 | `adapt unregister []` 241 | 242 | **plug-in**: Name of the plug-in currently used in the plug-in registry. 243 | 244 | Please note that you must authenticate with GitHub to use **unregister**. You must be a collaborator on the endpoint registered with the plug-in or a collaborator on the Adapt framework. Access to GitHub is for authentication only. 245 | 246 | ##### Examples: 247 | 248 | 1. To unregister a plug-in while in the root directory of the plug-in: 249 | `adapt unregister` 250 | 251 | 1. To unregister a plug-in by name: 252 | `adapt unregister adapt-myMistake` 253 | 254 | 255 | 256 | The Plug-in Registry 257 | ------------------- 258 | 259 | The Adapt community maintains the [Adapt Plugin Browser](https://www.adaptlearning.org/index.php/plugin-browser/) as a convenient registry of components, extensions, themes, and menus. The plug-in system is powered by [Bower](http://bower.io/): http://adapt-bower-repository.herokuapp.com/packages/. To register, a plug-in must be a valid bower package with *bower.json*, and have a unique name that is prefixed with "adapt-". 260 | 261 | See [Developing plug-ins](https://github.com/adaptlearning/adapt_framework/wiki/Developing-plugins) for more information on defining your plug-in's package and on [registering your plug-in](https://github.com/adaptlearning/adapt_framework/wiki/Registering-a-plugin). 262 | 263 | 264 | ---------------------------- 265 | adapt learning logo 266 | **Author / maintainer:** Adapt Core Team with [contributors](https://github.com/adaptlearning/adapt-contrib-hotgraphic/graphs/contributors) 267 | -------------------------------------------------------------------------------- /bin/adapt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import cli from '../lib/cli.js' 3 | cli.withOptions().execute() 4 | -------------------------------------------------------------------------------- /json/help-create.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "create", 3 | "description": "Create a new adapt course", 4 | "usage": [ 5 | "create " 6 | ], 7 | "commands": { 8 | "course": "Create new adapt course", 9 | "component": "Create new component using template adapt-component", 10 | "question": "Create new question component using template adapt-questionComponent" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /json/help-create/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "create component", 3 | "description": "create new component", 4 | "usage": [ 5 | "create component", 6 | "create component ", 7 | "create component " 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /json/help-create/course.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "create course", 3 | "description": "to create new course", 4 | "usage": [ 5 | "create course", 6 | "create course ", 7 | "create course " 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /json/help-create/question.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "create question", 3 | "description": "create new question component", 4 | "usage": [ 5 | "create question", 6 | "create question ", 7 | "create question " 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /json/help-devinstall.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "devinstall", 3 | "description": "This command will clone the adapt_framework and all the plugins defined in adapt.json as git repos. For more info visit: https://github.com/adaptlearning/adapt-cli/pull/46", 4 | "usage": [ 5 | "devinstall", 6 | "devinstall ", 7 | "devinstall #" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /json/help-install.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "install", 3 | "description": "Install plugins according to the Adapt manifest (adapt.json). You can also specify which plugins to install as arguments. When specifying arguments you can provide an optional semantic version (semver). If a semver is not given the command will attempt to install the latest compatible version. Add the --dry-run option to simulate an installation without making any changes. Add the --compatible option to override any given semvers: the command will attempt to install the latest compatible version for all requested plugins.", 4 | "usage": [ 5 | "install [--dry-run, --compatible]", 6 | "install ", 7 | "install #", 8 | "install @" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /json/help-ls.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "ls", 3 | "description": "List the plugin(s) name with version number mentioned in adapt.json file.", 4 | "usage": [ 5 | "ls" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /json/help-register.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "register", 3 | "description": "Register a plugin to adapt's remote registry by reading bower.json file. Check folowing URL for more detail - https://github.com/adaptlearning/adapt_framework/wiki/Registering-a-plugin", 4 | "usage": [ 5 | "register" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /json/help-rename.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "rename", 3 | "description": "Unregister a plugin at Adapt's remote registry. Specify as two arguments the current name of the plugin to be renamed and a new name. Please note that you must authenticate with GitHub to unregister and must be a collaborator on the endpoint registered with the plugin or a collaborator on the Adapt framework. Access to GitHub is for authentication only.", 4 | "usage": [ 5 | "rename " 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /json/help-search.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "search", 3 | "description": "List out all the plugin(s) in adapt's remote registry or could also search a plugin with supplied partial name.", 4 | "usage": [ 5 | "search", 6 | "search " 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /json/help-uninstall.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "uninstall", 3 | "description": "Remove a local plugin by its name.", 4 | "usage": [ 5 | "uninstall " 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /json/help-unregister.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "unregister", 3 | "description": "Unregister a plugin at Adapt's remote registry. This command can be run without arguments in a directory which contains the bower.json file for the plugin to be unregistered. Alternatively, specify the name of the plugin as a single argument. Please note that you must authenticate with GitHub to unregister and must be a collaborator on the endpoint registered with the plugin or a collaborator on the Adapt framework. Access to GitHub is for authentication only.", 4 | "usage": [ 5 | "unregister", 6 | "unregister " 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /json/help-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "update", 3 | "description": "Update plugins to their latest compatible version. You can also specify which plugins to update as arguments. When specifying arguments you can provide an optional semantic version (semver). Add the --check option for a summary of available updates for the given plugin(s). When using this option no changes are made. Ensure that there is no bower.json file in your project directory.", 4 | "usage": [ 5 | "update [--check]", 6 | "update [ ..] where is one of [components|extensions|menu|theme|all]", 7 | "update ", 8 | "update #", 9 | "update @", 10 | "update [ ..] where target is the name of a plugin or group" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /json/help-version.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "version", 3 | "description": "Display version of adapt-cli.", 4 | "usage": [ 5 | "version" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /json/help.json: -------------------------------------------------------------------------------- 1 | { 2 | "usage": [ 3 | "" 4 | ], 5 | "commands": { 6 | "create": "Create a new Adapt course or plugin", 7 | "devinstall": "Get the framework and plugins as Git repository", 8 | "help": "List out the commands available with adapt-cli", 9 | "install": "Install plugin(s) within the Adapt course directory", 10 | "ls": "List all the plugin names mentioned in adapt.json", 11 | "rename": "Rename a plugin at the Adapt remote registry", 12 | "register": "Register a plugin with the Adapt remote registry", 13 | "search": "List/Search for plugin(s) at the Adapt remote registry", 14 | "uninstall": "Remove a local plugin", 15 | "unregister": "Unregister a plugin from the Adapt remote registry", 16 | "update": "Update one or more plugins", 17 | "version": "Display version of adapt-cli" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | import http from 'http' // api http import, to hold open process for testing 2 | import { ADAPT_FRAMEWORK } from './util/constants.js' 3 | import { 4 | build, 5 | download, 6 | erase, 7 | getLatestVersion, 8 | npmInstall, 9 | deleteSrcCourse, 10 | deleteSrcCore 11 | } from './integration/AdaptFramework.js' 12 | import { 13 | install, 14 | uninstall, 15 | update, 16 | schemas 17 | } from './integration/PluginManagement.js' 18 | import Plugin from './integration/Plugin.js' 19 | import Project from './integration/Project.js' 20 | import fs from 'fs-extra' 21 | import async from 'async' 22 | import './econnreset.js' 23 | 24 | // TODO: api, check no interactivity, should be sorted, will fail silently if it absolutely cannot do what you've asked 25 | // TODO: api, console and error output, error on fail when isInteractive: false? or something else? return Targets? 26 | // TODO: api, figure out error translations, should probably error with codes? 27 | 28 | class API { 29 | /** 30 | * Installs a clean copy of the framework 31 | * @param {Object} options 32 | * @param {string} [options.version=null] Specific version of the framework to install 33 | * @param {string} [options.repository] URL to github repo 34 | * @param {string} [options.cwd=process.cwd()] Directory to install into 35 | * @return {Promise} 36 | */ 37 | async installFramework ({ 38 | version = null, 39 | repository = ADAPT_FRAMEWORK, 40 | cwd = process.cwd(), 41 | logger 42 | } = {}) { 43 | if (!version) version = await getLatestVersion({ repository }) 44 | await erase({ 45 | isInteractive: false, 46 | cwd, 47 | logger 48 | }) 49 | await download({ 50 | repository, 51 | branch: version, 52 | cwd, 53 | logger 54 | }) 55 | await deleteSrcCourse({ 56 | cwd 57 | }) 58 | await deleteSrcCore({ 59 | cwd 60 | }) 61 | await npmInstall({ 62 | cwd, 63 | logger 64 | }) 65 | } 66 | 67 | /** 68 | * Updates an existing installation of the framework 69 | * @param {Object} options 70 | * @param {string} [options.version=null] Specific version of the framework to install 71 | * @param {string} [options.repository] URL to github repo 72 | * @param {string} [options.cwd=process.cwd()] Directory to install into 73 | * @return {Promise} 74 | */ 75 | async updateFramework ({ 76 | version = null, 77 | repository = ADAPT_FRAMEWORK, 78 | cwd = process.cwd(), 79 | logger 80 | } = {}) { 81 | // cache state of plugins, as these will be wiped 82 | const plugins = (await new Project({ cwd }).getInstallTargets()) 83 | .map(p => p.isLocalSource ? p.sourcePath : `${p.name}@${p.requestedVersion}`) 84 | 85 | await this.installFramework({ version, repository, cwd, logger }) 86 | // restore plugins 87 | await this.installPlugins({ plugins, cwd, logger }) 88 | } 89 | 90 | /** 91 | * @param {Object} options 92 | * @param {string} [options.cwd=process.cwd()] Directory to install into 93 | * @returns {string} 94 | */ 95 | getCurrentFrameworkVersion ({ 96 | cwd = process.cwd(), 97 | } = {}) { 98 | return new Project({ cwd }).version 99 | } 100 | 101 | /** 102 | * @param {Object} options 103 | * @param {Object} [options.repository=ADAPT_FRAMEWORK] The github repository url 104 | * @returns {string} 105 | */ 106 | async getLatestFrameworkVersion ({ 107 | repository = ADAPT_FRAMEWORK 108 | } = {}) { 109 | return getLatestVersion({ repository }) 110 | } 111 | 112 | /** 113 | * Runs build for a current course 114 | * @param {Object} options 115 | * @param {boolean} [options.sourceMaps=false] Whether to run the build with sourcemaps 116 | * @param {boolean} [options.checkJSON=false] Whether to run without checking the json 117 | * @param {boolean} [options.cache=true] Whether to clear build caches 118 | * @param {string} [options.outputDir="build/"] Root path of the framework installation 119 | * @param {string} [options.cachePath="build/.cache"] Path of compilation cache file 120 | * @param {string} [options.cwd=process.cwd()] Root path of the framework installation 121 | * @return {Promise} 122 | */ 123 | async buildCourse ({ 124 | sourceMaps = false, 125 | checkJSON = false, 126 | cache = true, 127 | outputDir = null, 128 | cachePath = './build/.cache', 129 | cwd = process.cwd(), 130 | logger 131 | } = {}) { 132 | await build({ 133 | sourceMaps, 134 | checkJSON, 135 | cache, 136 | cwd, 137 | outputDir, 138 | cachePath, 139 | logger 140 | }) 141 | } 142 | 143 | /** 144 | * Installs multiple plugins 145 | * Can install from source folder or bower registry 146 | * @param {Object} options 147 | * @param {[string]} [options.plugins=null] An array with name@version or name@path strings 148 | * @param {string} [options.cwd=process.cwd()] Root path of the framework installation 149 | * @return {Promise<[Target]>} 150 | */ 151 | async installPlugins ({ 152 | plugins = null, 153 | cwd = process.cwd(), 154 | logger 155 | } = {}) { 156 | return await install({ 157 | plugins, 158 | isInteractive: false, 159 | cwd, 160 | logger 161 | }) 162 | } 163 | 164 | /** 165 | * Uninstalls multiple plugins 166 | * @param {Object} options 167 | * @param {[string]} [options.plugins=null] An array with name@version or name@path strings 168 | * @param {string} [options.cwd=process.cwd()] Root path of the framework installation 169 | * @return {Promise<[Target]>} 170 | */ 171 | async uninstallPlugins ({ 172 | plugins = null, 173 | cwd = process.cwd(), 174 | logger 175 | } = {}) { 176 | return await uninstall({ 177 | plugins, 178 | isInteractive: false, 179 | cwd, 180 | logger 181 | }) 182 | } 183 | 184 | /** 185 | * Updates multiple plugins 186 | * Can install from source folder or bower registry 187 | * @param {Object} options 188 | * @param {[string]} [options.plugins=null] An array with name@version or name@path strings 189 | * @param {string} [options.cwd=process.cwd()] Root path of the framework installation 190 | * @return {Promise<[Target]>} 191 | */ 192 | async updatePlugins ({ 193 | plugins = null, 194 | cwd = process.cwd(), 195 | logger 196 | } = {}) { 197 | return await update({ 198 | plugins, 199 | isInteractive: false, 200 | cwd, 201 | logger 202 | }) 203 | } 204 | 205 | /** 206 | * Retrieves all schemas defined in the project 207 | * Clears schema cache 208 | * @param {string} [options.cwd=process.cwd()] Root path of the framework installation 209 | * @return {Promise<[string]>} Resolves with array of JSON schema filepaths 210 | */ 211 | async getSchemaPaths ({ 212 | cwd = process.cwd() 213 | } = {}) { 214 | this._schemaPaths = await schemas({ cwd }) 215 | this._schemas = {} 216 | return this._schemaPaths 217 | } 218 | 219 | /** 220 | * Retrieves named schema 221 | * Caches schemas for subsequent use 222 | * Call getSchemaPaths to reset cache 223 | * @param {string} options.name Schema filepath as returned from getSchemaPaths 224 | * @return {Promise} Resolves with the JSON schema contents 225 | */ 226 | async getSchema ({ 227 | name 228 | } = {}) { 229 | if (!this._schemaPaths) return new Error('Please run getSchemaPaths first') 230 | if (!fs.existsSync(name) || !this._schemaPaths.includes(name)) throw new Error(`Schema does not exist: ${name}`) 231 | return (this._schemas[name] = this._schemas[name] ?? await fs.readJSON(name)) 232 | } 233 | 234 | /** 235 | * Returns all installed plugins 236 | * @return {Promise<[Plugin]>} 237 | */ 238 | async getInstalledPlugins ({ 239 | cwd = process.cwd() 240 | } = {}) { 241 | const project = new Project({ cwd }) 242 | if (!project.isAdaptDirectory) throw new Error(`Not in an adapt folder at: ${cwd}`) 243 | const installedPlugins = await project.getInstalledPlugins() 244 | for (const plugin of installedPlugins) { 245 | await plugin.fetchProjectInfo() 246 | } 247 | return installedPlugins 248 | } 249 | 250 | /** 251 | * Gets the update information for installed and named plugins 252 | * @param {Object} options 253 | * @param {[string]} [options.plugins=null] An y of plugin names (if not specified, all plugins are checked) 254 | * @param {string} [options.cwd=process.cwd()] Root path of the framework installation 255 | * @return {Promise<[Plugin]>} 256 | */ 257 | async getPluginUpdateInfos ({ 258 | plugins = null, 259 | cwd = process.cwd() 260 | } = {}) { 261 | /** @type {Project} */ 262 | const project = new Project({ cwd }) 263 | if (!project.isAdaptDirectory) throw new Error(`Not in an adapt folder at: ${cwd}`) 264 | const frameworkVersion = project.version 265 | /** @type {[Plugin]} */ 266 | const installedPlugins = await project.getInstalledPlugins() 267 | const filteredPlugins = !plugins?.length 268 | ? installedPlugins 269 | : plugins 270 | .map(name => installedPlugins.find(plugin => plugin.packageName === name)) 271 | .filter(Boolean) 272 | await async.eachOfLimit(filteredPlugins, 8, async plugin => { 273 | await plugin.fetchProjectInfo() 274 | await plugin.fetchBowerInfo() 275 | await plugin.findCompatibleVersion(frameworkVersion) 276 | }) 277 | return filteredPlugins 278 | } 279 | 280 | /** 281 | * Returns an object representing the plugin at the path specified 282 | * @returns {Plugin} 283 | */ 284 | async getPluginFromPath ({ 285 | pluginPath, 286 | cwd = null 287 | } = {}) { 288 | const project = cwd ? new Project({ cwd }) : null 289 | return Plugin.fromPath({ pluginPath, project }) 290 | } 291 | } 292 | 293 | const api = new API() 294 | export default api 295 | 296 | // debugging 297 | if (process.argv.includes('--debug-wait')) { 298 | // http server to hold open process for testing 299 | const a = http.createServer((request, response) => { 300 | response.writeHead(200, { 'Content-Type': 'text/plain' }) 301 | response.end('test', 'utf-8') 302 | }) 303 | a.listen(999) 304 | // make api global to debug more easily 305 | global.api = api 306 | console.log('API Ready') 307 | } 308 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | import authenticate from './commands/authenticate.js' 2 | import create from './commands/create.js' 3 | import devinstall from './commands/devinstall.js' 4 | import help from './commands/help.js' 5 | import install from './commands/install.js' 6 | import ls from './commands/ls.js' 7 | import register from './commands/register.js' 8 | import rename from './commands/rename.js' 9 | import search from './commands/search.js' 10 | import uninstall from './commands/uninstall.js' 11 | import unregister from './commands/unregister.js' 12 | import update from './commands/update.js' 13 | import version from './commands/version.js' 14 | import logger from './logger.js' 15 | import './econnreset.js' 16 | 17 | const commands = { 18 | authenticate, 19 | create, 20 | devinstall, 21 | help, 22 | install, 23 | ls, 24 | register, 25 | rename, 26 | search, 27 | uninstall, 28 | unregister, 29 | update, 30 | version 31 | } 32 | 33 | const translationTable = [ 34 | { pattern: /^-v$|^--version$/i, replacement: 'version' }, 35 | { pattern: /^upgrade$/i, replacement: 'update' } 36 | ] 37 | 38 | class CLI { 39 | withOptions (argv = process.argv || ['node', 'path']) { 40 | const parameters = argv.slice(2).map(param => { 41 | const translation = translationTable.find(item => item.pattern.test(param)) 42 | return translation ? translation.replacement : param 43 | }) 44 | const name = parameters.length 45 | ? String.prototype.toLowerCase.call(parameters.shift()) 46 | : '' 47 | this.command = { 48 | name, 49 | parameters 50 | } 51 | return this 52 | } 53 | 54 | async execute () { 55 | try { 56 | if (!commands[this.command.name]) { 57 | const e = new Error(`Unknown command "${this.command.name}", please check the documentation.`) 58 | logger?.log(e.message) 59 | throw e 60 | } 61 | const commandArguments = [logger].concat(this.command.parameters) 62 | await commands[this.command.name](...commandArguments) 63 | } catch (err) { 64 | console.error(err) 65 | process.exit(err ? 1 : 0) 66 | } 67 | } 68 | } 69 | 70 | export default new CLI() 71 | -------------------------------------------------------------------------------- /lib/commands/authenticate.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { authenticate as pluginAuthenticate } from '../integration/PluginManagement.js' 3 | 4 | export default async function authenticate (logger, ...args) { 5 | // strip flags 6 | args = args.filter(arg => !String(arg).startsWith('--')) 7 | try { 8 | const confirmation = await pluginAuthenticate({ 9 | logger, 10 | cwd: process.cwd(), 11 | pluginName: args[0] 12 | }) 13 | const { username, type, pluginName } = confirmation 14 | logger?.log(chalk.green(`${username} authenticated as ${type} for ${pluginName}`)) 15 | } catch (err) { 16 | logger?.error('Authentication failed') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/commands/create.js: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer' 2 | import component from './create/component.js' 3 | import question from './create/question.js' 4 | import extension from './create/extension.js' 5 | import course from './create/course.js' 6 | import { ADAPT_FRAMEWORK } from '../util/constants.js' 7 | import { getLatestVersion as getAdaptLatestVersion } from '../integration/AdaptFramework.js' 8 | 9 | const subTasks = { 10 | component, 11 | question, 12 | extension, 13 | course 14 | } 15 | 16 | /** 17 | * TODO: Change component name to camel case 18 | */ 19 | export const DEFAULT_TYPE_NAME = { 20 | course: 'my-adapt-course', 21 | component: 'myAdaptComponent', 22 | question: 'myAdaptQuestion', 23 | extension: 'myAdaptExtension' 24 | } 25 | 26 | export default async function create (logger, type = 'course', name, branch, bypassPrompts) { 27 | let options = { 28 | type, 29 | name, 30 | branch 31 | } 32 | 33 | if (!bypassPrompts) { 34 | options = await confirmOptions({ 35 | type, 36 | name, 37 | branch, 38 | logger 39 | }) 40 | } 41 | 42 | const action = subTasks[options.type] 43 | if (!action) throw new Error('' + options.type + ' is not a supported type') 44 | try { 45 | await action({ 46 | name: options.name, 47 | branch: options.branch, 48 | cwd: process.cwd(), 49 | logger 50 | }) 51 | } catch (err) { 52 | logger?.error("Oh dear, something went wrong. I'm terribly sorry.", err.message) 53 | throw err 54 | } 55 | } 56 | 57 | async function confirmOptions ({ logger, type, name, branch }) { 58 | const typeSchema = [ 59 | { 60 | name: 'type', 61 | choices: ['course', 'component', 'question', 'extension'], 62 | type: 'list', 63 | default: type 64 | } 65 | ] 66 | const typeSchemaResults = await inquirer.prompt(typeSchema) 67 | branch = branch || (typeSchemaResults.type === 'course') 68 | ? await getAdaptLatestVersion({ repository: ADAPT_FRAMEWORK }) 69 | : 'master' 70 | const propertySchema = [ 71 | { 72 | name: 'branch', 73 | message: 'branch/tag', 74 | type: 'input', 75 | default: branch || 'not specified' 76 | }, 77 | { 78 | name: 'name', 79 | message: 'name', 80 | type: 'input', 81 | default: name || DEFAULT_TYPE_NAME[typeSchemaResults.type] 82 | }, 83 | { 84 | name: 'ready', 85 | message: 'create now?', 86 | type: 'confirm', 87 | default: true 88 | } 89 | ] 90 | const propertySchemaResults = await inquirer.prompt(propertySchema) 91 | if (!propertySchemaResults.ready) throw new Error('Aborted. Nothing has been created.') 92 | const finalProperties = { 93 | ...typeSchemaResults, 94 | ...propertySchemaResults 95 | } 96 | return finalProperties 97 | } 98 | -------------------------------------------------------------------------------- /lib/commands/create/component.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import globs from 'globs' 3 | import path from 'path' 4 | import chalk from 'chalk' 5 | import slug from 'speakingurl' 6 | import Project from '../../integration/Project.js' 7 | import downloader from '../../util/download.js' 8 | import { ADAPT_COMPONENT } from '../../util/constants.js' 9 | 10 | export default async function component ({ 11 | name, 12 | repository = ADAPT_COMPONENT, 13 | cwd, 14 | branch, 15 | logger 16 | }) { 17 | name = slug(name, { maintainCase: true }) 18 | 19 | const project = new Project({ cwd, logger }) 20 | let pluginDir 21 | if (project.containsManifestFile) { 22 | const componentsDirectory = 'src/components' 23 | pluginDir = path.join(cwd, componentsDirectory, 'adapt-' + name) 24 | if (!fs.existsSync(componentsDirectory)) fs.mkdirSync(componentsDirectory) 25 | } else { 26 | pluginDir = path.join(cwd, name) 27 | } 28 | 29 | await downloader({ 30 | branch, 31 | repository, 32 | cwd: pluginDir, 33 | logger 34 | }) 35 | 36 | const files = await new Promise((resolve, reject) => { 37 | globs('**', { cwd: pluginDir }, (err, matches) => { 38 | if (err) return reject(err) 39 | resolve(matches.map(match => path.join(pluginDir, match))) 40 | }) 41 | }) 42 | 43 | const filesRenamed = files.map(from => { 44 | const to = from.replace(/((contrib-)?componentName)/g, name) 45 | fs.renameSync(from, to) 46 | return to 47 | }) 48 | 49 | await Promise.all(filesRenamed.map(async function (file) { 50 | if (fs.statSync(file).isDirectory()) return 51 | const lowerCaseName = name.toLowerCase() 52 | const content = (await fs.readFile(file)).toString() 53 | const modifiedContent = content 54 | .replace(/((contrib-)?componentName)/g, name) 55 | .replace(/((contrib-)?componentname)/g, lowerCaseName) 56 | return fs.writeFile(file, modifiedContent) 57 | })) 58 | 59 | logger?.log('\n' + chalk.green(pluginDir), 'has been created.\n') 60 | 61 | if (fs.existsSync('./adapt.json')) { 62 | logger?.log(chalk.grey('To use this component in your course, use the registered name:') + chalk.yellow(name)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/commands/create/course.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { 3 | erase, 4 | download, 5 | npmInstall 6 | } from '../../integration/AdaptFramework.js' 7 | import path from 'path' 8 | import { install as pluginsInstall } from '../../integration/PluginManagement.js' 9 | 10 | export default async function course ({ name, branch, cwd, logger }) { 11 | cwd = path.join(cwd, name) 12 | await erase({ logger, cwd }) 13 | await download({ logger, cwd, branch }) 14 | await npmInstall({ logger, cwd }) 15 | await pluginsInstall({ logger, cwd }) 16 | logger?.log(` 17 | ${chalk.green(name)} has been created. 18 | 19 | ${chalk.grey('To build the course, run:')} 20 | cd ${name} 21 | grunt build 22 | 23 | ${chalk.grey('Then to view the course, run:')} 24 | grunt server 25 | `) 26 | } 27 | -------------------------------------------------------------------------------- /lib/commands/create/extension.js: -------------------------------------------------------------------------------- 1 | import component from 'adapt-cli/lib/commands/create/component.js' 2 | import { ADAPT_EXTENSION } from 'adapt-cli/lib/util/constants.js' 3 | 4 | export default async function extension ({ 5 | name, 6 | repository = ADAPT_EXTENSION, 7 | cwd, 8 | branch, 9 | logger 10 | }) { 11 | return component({ 12 | name, 13 | repository, 14 | cwd, 15 | branch, 16 | logger 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /lib/commands/create/question.js: -------------------------------------------------------------------------------- 1 | import component from './component.js' 2 | import { ADAPT_QUESTION } from '../../util/constants.js' 3 | 4 | export default async function question ({ 5 | name, 6 | repository = ADAPT_QUESTION, 7 | cwd, 8 | branch, 9 | logger 10 | }) { 11 | return component({ 12 | name, 13 | repository, 14 | cwd, 15 | branch, 16 | logger 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /lib/commands/devinstall.js: -------------------------------------------------------------------------------- 1 | import { clone as adaptClone } from '../integration/AdaptFramework.js' 2 | import { install as pluginsInstall } from '../integration/PluginManagement.js' 3 | import { ADAPT_FRAMEWORK } from '../util/constants.js' 4 | import path from 'path' 5 | import Project from '../integration/Project.js' 6 | import gh from 'parse-github-url' 7 | 8 | export default async function devinstall (logger, ...args) { 9 | const NAME = gh(ADAPT_FRAMEWORK).repo 10 | const isInAdapt = new Project().isAdaptDirectory 11 | // In adapt folder or download adapt into adapt_framework folder 12 | const cwd = isInAdapt 13 | ? process.cwd() 14 | : path.resolve(NAME) 15 | // strip flags 16 | const isClean = args.includes('--clean') 17 | const isDryRun = args.includes('--dry-run') || args.includes('--check') 18 | const isCompatibleEnabled = args.includes('--compatible') 19 | args = args.filter(arg => !String(arg).startsWith('--')) 20 | // always perform a clone on the adapt directory 21 | if (!isInAdapt || args.includes(NAME)) { 22 | await adaptClone({ logger, cwd }) 23 | args = args.filter(arg => arg !== NAME) 24 | } 25 | const plugins = args 26 | return await pluginsInstall({ 27 | logger, 28 | cwd, 29 | isClean, 30 | isDryRun, 31 | isCompatibleEnabled, 32 | dev: true, 33 | plugins 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /lib/commands/help.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'path' 3 | import chalk from 'chalk' 4 | import getDirNameFromImportMeta from '../util/getDirNameFromImportMeta.js' 5 | const __dirname = getDirNameFromImportMeta(import.meta) 6 | 7 | export default function help (logger, ...args) { 8 | const name = args.join(' ') 9 | const json = name 10 | ? path.resolve(__dirname, `../../json/help-${name.replace(/\s+/g, '/')}.json`) 11 | : path.resolve(__dirname, '../../json/help.json') 12 | if (!fs.existsSync(json)) { 13 | logger?.log(`adapt ${chalk.red(name)} Unknown command: ${name}`) 14 | return 15 | } 16 | const jsonData = fs.readJSONSync(json) 17 | logger?.log('\nUsage: \n') 18 | jsonData.usage.forEach(usage => logger?.log(` ${chalk.cyan('adapt')} ${usage}`)) 19 | if (jsonData.commands && Object.entries(jsonData.commands).length) { 20 | logger?.log('\n\nwhere is one of:\n') 21 | Object.entries(jsonData.commands).forEach(([command, description]) => { 22 | logger?.log(` ${command}${new Array(23 - command.length).join(' ')}${description}`) 23 | }) 24 | } 25 | if (jsonData.description) { 26 | logger?.log(`\nDescription:\n\n ${jsonData.description}`) 27 | } 28 | if (!name) { 29 | logger?.log('\nSee \'adapt help \' for more information on a specific command.\n') 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/commands/install.js: -------------------------------------------------------------------------------- 1 | import { install as pluginsInstall } from '../integration/PluginManagement.js' 2 | 3 | export default async function install (logger, ...args) { 4 | /** strip flags */ 5 | const isClean = args.includes('--clean') 6 | const isDryRun = args.includes('--dry-run') || args.includes('--check') 7 | const isCompatibleEnabled = args.includes('--compatible') 8 | const plugins = args.filter(arg => !String(arg).startsWith('--')) 9 | await pluginsInstall({ 10 | logger, 11 | isClean, 12 | isDryRun, 13 | isCompatibleEnabled, 14 | plugins 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /lib/commands/ls.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import _ from 'lodash' 3 | import Project from '../integration/Project.js' 4 | import { eachOfLimitProgress } from '../util/promises.js' 5 | 6 | export default async function ls (logger) { 7 | const project = new Project({ logger }) 8 | const frameworkVersion = project.version 9 | project.tryThrowInvalidPath() 10 | const installTargets = (await project.getInstallTargets()) 11 | const installTargetsIndex = installTargets.reduce((hash, p) => { 12 | const name = (p.name || p.sourcePath) 13 | hash[name] = p 14 | return hash 15 | }, {}) 16 | const installedPlugins = await project.getInstalledPlugins(); 17 | const notInstalled = _.difference(installTargets.map(p => (p.name || p.sourcePath)), installedPlugins.map(p => (p.name || p.sourcePath))) 18 | const plugins = installedPlugins.concat(notInstalled.map(name => installTargetsIndex[name])) 19 | await eachOfLimitProgress( 20 | plugins, 21 | async (target) => { 22 | await target.fetchProjectInfo() 23 | await target.fetchSourceInfo() 24 | await target.findCompatibleVersion(frameworkVersion) 25 | }, 26 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Getting plugin info ${percentage}% complete`) 27 | ) 28 | logger?.log(`${chalk.bold.cyan('')} Getting plugin info 100% complete`) 29 | plugins 30 | .sort((a, b) => a.name.localeCompare(b.name)) 31 | .forEach(p => { 32 | const name = (p.name || p.sourcePath) 33 | logger?.log(`${chalk.cyan(p.name || p.sourcePath)} ${chalk.green('adapt.json')}: ${installTargetsIndex[name]?.requestedVersion || 'n/a'} ${chalk.green('installed')}: ${p.sourcePath || p.projectVersion || 'n/a'} ${chalk.green('latest')}: ${p.sourcePath || p.latestCompatibleSourceVersion || 'n/a'}`) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /lib/commands/register.js: -------------------------------------------------------------------------------- 1 | import { register as pluginRegister } from '../integration/PluginManagement.js' 2 | 3 | export default async function register (logger, ...args) { 4 | // strip flags 5 | args = args.filter(arg => !String(arg).startsWith('--')) 6 | return await pluginRegister({ 7 | logger, 8 | cwd: process.cwd(), 9 | args 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /lib/commands/rename.js: -------------------------------------------------------------------------------- 1 | import { rename as pluginRename } from '../integration/PluginManagement.js' 2 | 3 | export default async function rename (logger, ...args) { 4 | /** strip flags */ 5 | args = args.filter(arg => !String(arg).startsWith('--')) 6 | const oldName = args[0] 7 | const newName = args[1] 8 | await pluginRename({ 9 | logger, 10 | cwd: process.cwd(), 11 | oldName, 12 | newName 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /lib/commands/search.js: -------------------------------------------------------------------------------- 1 | import { search as pluginsSearch } from '../integration/PluginManagement.js' 2 | 3 | export default async function search (logger, ...args) { 4 | /** strip flags */ 5 | args = args.filter(arg => !String(arg).startsWith('--')) 6 | const searchTerm = (args[0] || '') 7 | await pluginsSearch({ 8 | logger, 9 | searchTerm 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /lib/commands/uninstall.js: -------------------------------------------------------------------------------- 1 | import { uninstall as pluginsUninstall } from '../integration/PluginManagement.js' 2 | 3 | export default async function uninstall (logger, ...args) { 4 | const plugins = args.filter(arg => !String(arg).startsWith('--')) 5 | await pluginsUninstall({ 6 | logger, 7 | plugins 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /lib/commands/unregister.js: -------------------------------------------------------------------------------- 1 | import { unregister as pluginUnregister } from '../integration/PluginManagement.js' 2 | 3 | export default async function register (logger, ...args) { 4 | // strip flags 5 | args = args.filter(arg => !String(arg).startsWith('--')) 6 | const pluginName = args[0] 7 | return await pluginUnregister({ 8 | logger, 9 | cwd: process.cwd(), 10 | pluginName 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /lib/commands/update.js: -------------------------------------------------------------------------------- 1 | import { update as pluginsUpdate } from '../integration/PluginManagement.js' 2 | 3 | export default async function update (logger, ...args) { 4 | /** strip flags */ 5 | const isDryRun = args.includes('--dry-run') || args.includes('--check') 6 | const plugins = args.filter(arg => !String(arg).startsWith('--')) 7 | await pluginsUpdate({ 8 | logger, 9 | plugins, 10 | isDryRun 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /lib/commands/version.js: -------------------------------------------------------------------------------- 1 | import Project from '../integration/Project.js' 2 | import path from 'path' 3 | import { readValidateJSONSync } from '../util/JSONReadValidate.js' 4 | import getDirNameFromImportMeta from '../util/getDirNameFromImportMeta.js' 5 | const __dirname = getDirNameFromImportMeta(import.meta) 6 | 7 | export default function version (logger) { 8 | const cliVersionPath = path.join(__dirname, '../../package.json') 9 | const cliVersion = readValidateJSONSync(cliVersionPath).version 10 | const project = new Project() 11 | logger?.log('CLI: ' + cliVersion) 12 | logger?.log('Framework: ' + project.version) 13 | } 14 | -------------------------------------------------------------------------------- /lib/econnreset.js: -------------------------------------------------------------------------------- 1 | // guard against ECONNRESET issues https://github.com/adaptlearning/adapt-cli/issues/169 2 | process.on('uncaughtException', (error, origin) => { 3 | if (error?.code === 'ECONNRESET') return 4 | console.error('UNCAUGHT EXCEPTION') 5 | console.error(error) 6 | console.error(origin) 7 | }) 8 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework.js: -------------------------------------------------------------------------------- 1 | import build from './AdaptFramework/build.js' 2 | import getLatestVersion from './AdaptFramework/getLatestVersion.js' 3 | import clone from './AdaptFramework/clone.js' 4 | import erase from './AdaptFramework/erase.js' 5 | import download from './AdaptFramework/download.js' 6 | import npmInstall from './AdaptFramework/npmInstall.js' 7 | import deleteSrcCourse from './AdaptFramework/deleteSrcCourse.js' 8 | import deleteSrcCore from './AdaptFramework/deleteSrcCore.js' 9 | 10 | export { 11 | build, 12 | getLatestVersion, 13 | clone, 14 | erase, 15 | download, 16 | npmInstall, 17 | deleteSrcCourse, 18 | deleteSrcCore 19 | } 20 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/build.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { exec } from 'child_process' 3 | import Project from '../Project.js' 4 | import path from 'path' 5 | 6 | export default async function adaptBuild ({ 7 | sourceMaps = false, 8 | checkJSON = false, 9 | cache = true, 10 | outputDir = null, 11 | cachePath = null, 12 | cwd = process.cwd(), 13 | logger 14 | } = {}) { 15 | cwd = path.resolve(process.cwd(), cwd) 16 | const project = new Project({ cwd, logger }) 17 | project.tryThrowInvalidPath() 18 | logger?.log(chalk.cyan('running build')) 19 | await new Promise((resolve, reject) => { 20 | const cmd = [ 21 | 'npx grunt', 22 | !checkJSON 23 | ? `server-build:${sourceMaps ? 'dev' : 'prod'}` // AAT 24 | : `${sourceMaps ? 'diff' : 'build'}`, // Handbuilt 25 | !cache && '--disable-cache', 26 | outputDir && `--outputdir=${outputDir}`, 27 | cachePath && `--cachepath=${cachePath}` 28 | ].filter(Boolean).join(' '); 29 | exec(cmd, { cwd }, (error, stdout, stderr) => { 30 | if(error || stderr) { 31 | const matches = stdout.match(/>> Error:\s(.+)\s/); 32 | const e = new Error('grunt tasks failed') 33 | e.cmd = cmd; 34 | e.raw = matches?.[1] ?? stdout; 35 | e.source = error; 36 | e.stderr = stderr; 37 | return reject(e) 38 | } 39 | resolve() 40 | }) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/clone.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { exec } from 'child_process' 3 | import { ADAPT_FRAMEWORK } from '../../util/constants.js' 4 | import path from 'path' 5 | 6 | export default async function clone ({ 7 | repository = ADAPT_FRAMEWORK, 8 | branch = 'master', 9 | cwd = process.cwd(), 10 | logger 11 | } = {}) { 12 | repository = repository.replace(/\.git/g, '') 13 | cwd = path.resolve(process.cwd(), cwd) 14 | if (!branch && !repository) throw new Error('Repository details are required.') 15 | logger?.write(chalk.cyan('cloning framework to', cwd, '\t')) 16 | await new Promise(function (resolve, reject) { 17 | const child = exec(`git clone ${repository} "${cwd}"`) 18 | child.addListener('error', reject) 19 | child.addListener('exit', resolve) 20 | }) 21 | await new Promise(function (resolve, reject) { 22 | const child = exec(`git checkout ${branch}`) 23 | child.addListener('error', reject) 24 | child.addListener('exit', resolve) 25 | }) 26 | logger?.log(' ', 'done!') 27 | } 28 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/deleteSrcCore.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'path' 3 | 4 | export default function deleteSrcCore ({ 5 | cwd = process.cwd() 6 | } = {}) { 7 | cwd = path.resolve(process.cwd(), cwd) 8 | return fs.rm(path.resolve(cwd, 'src/core'), { recursive: true, force: true }) 9 | } 10 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/deleteSrcCourse.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'path' 3 | 4 | export default function deleteSrcCourse ({ 5 | cwd = process.cwd() 6 | } = {}) { 7 | cwd = path.resolve(process.cwd(), cwd) 8 | return fs.rm(path.resolve(cwd, 'src/course'), { recursive: true, force: true }) 9 | } 10 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/download.js: -------------------------------------------------------------------------------- 1 | import downloader from '../../util/download.js' 2 | import { ADAPT_FRAMEWORK } from '../../util/constants.js' 3 | import path from 'path' 4 | 5 | export default async function download ({ 6 | repository = ADAPT_FRAMEWORK, 7 | branch, 8 | tmp, 9 | cwd, 10 | logger 11 | } = {}) { 12 | repository = repository.replace(/\.git/g, '') 13 | cwd = path.resolve(process.cwd(), cwd) 14 | return downloader({ 15 | repository, 16 | branch, 17 | tmp, 18 | cwd, 19 | logger 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/erase.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import fs from 'fs-extra' 3 | import inquirer from 'inquirer' 4 | import path from 'path' 5 | 6 | export default async function erase ({ 7 | isInteractive = true, 8 | cwd, 9 | logger 10 | } = {}) { 11 | cwd = path.resolve(process.cwd(), cwd) 12 | if (!fs.existsSync(cwd)) return 13 | if (isInteractive) { 14 | const results = await inquirer.prompt([{ 15 | name: 'overwrite existing course?', 16 | type: 'confirm', 17 | default: false 18 | }]) 19 | if (!results['overwrite existing course?']) { 20 | throw new Error('Course already exists and cannot overwrite.') 21 | } 22 | } 23 | logger?.log(chalk.cyan('deleting existing course')) 24 | await fs.rm(cwd, { recursive: true }) 25 | } -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/getLatestVersion.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import semver from 'semver' 3 | import gh from 'parse-github-url' 4 | import { ADAPT_DEFAULT_USER_AGENT, ADAPT_FRAMEWORK, ADAPT_ALLOW_PRERELEASE } from '../../util/constants.js' 5 | const semverOptions = { includePrerelease: ADAPT_ALLOW_PRERELEASE } 6 | 7 | export default async function getLatestVersion ({ versionLimit, repository = ADAPT_FRAMEWORK }) { 8 | repository = repository.replace(/\.git/g, '') 9 | const OWNER = gh(repository).owner 10 | const NAME = gh(repository).name 11 | // used in pagination 12 | let nextPage = `https://api.github.com/repos/${OWNER}/${NAME}/releases` 13 | // taken from https://gist.github.com/niallo/3109252 14 | const parseLinkHeader = header => { 15 | if (!header || header.length === 0) { 16 | return [] 17 | } 18 | const links = {} 19 | // Parse each part into a named link 20 | header.split(',').forEach(p => { 21 | const section = p.split(';') 22 | if (section.length !== 2) { 23 | throw new Error("section could not be split on ';'") 24 | } 25 | const url = section[0].replace(/<(.*)>/, '$1').trim() 26 | const name = section[1].replace(/rel="(.*)"/, '$1').trim() 27 | links[name] = url 28 | }) 29 | return links 30 | } 31 | const processPage = async () => { 32 | const response = await fetch(nextPage, { 33 | headers: { 34 | 'User-Agent': ADAPT_DEFAULT_USER_AGENT 35 | }, 36 | method: 'GET' 37 | }) 38 | const body = await response.text() 39 | if (response?.status === 403 && response?.headers['x-ratelimit-remaining'] === '0') { 40 | // we've exceeded the API limit 41 | const reqsReset = new Date(response.headers['x-ratelimit-reset'] * 1000) 42 | throw new Error(`Couldn't check latest version of ${NAME}. You have exceeded GitHub's request limit of ${response.headers['x-ratelimit-limit']} requests per hour. Please wait until at least ${reqsReset.toTimeString()} before trying again.`) 43 | } 44 | if (response?.status !== 200) { 45 | throw new Error(`Couldn't check latest version of ${NAME}. GitubAPI did not respond with a 200 status code.`) 46 | } 47 | nextPage = parseLinkHeader(response.headers.link).next 48 | let releases 49 | try { 50 | // parse and sort releases (newest first) 51 | releases = JSON.parse(body).sort((a, b) => { 52 | if (semver.lt(a.tag_name, b.tag_name, semverOptions)) return 1 53 | if (semver.gt(a.tag_name, b.tag_name, semverOptions)) return -1 54 | return 0 55 | }) 56 | } catch (e) { 57 | throw new Error(`Failed to parse GitHub release data\n${e}`) 58 | } 59 | const compatibleRelease = releases.find(release => { 60 | const isFullRelease = !release.draft && !release.prerelease 61 | const satisfiesVersion = !versionLimit || semver.satisfies(release.tag_name, versionLimit, semverOptions) 62 | if (!isFullRelease || !satisfiesVersion) return false 63 | return true 64 | }) 65 | if (!compatibleRelease && nextPage) { 66 | return await processPage() 67 | } 68 | if (!compatibleRelease) { 69 | throw new Error(`Couldn't find any releases compatible with specified framework version (${versionLimit}), please check that it is a valid version.`) 70 | } 71 | return compatibleRelease.tag_name 72 | } 73 | return await processPage() 74 | } 75 | -------------------------------------------------------------------------------- /lib/integration/AdaptFramework/npmInstall.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { spawn } from 'child_process' 3 | import path from 'path' 4 | 5 | export default async function npmInstall ({ 6 | logger, 7 | cwd 8 | } = {}) { 9 | cwd = path.resolve(process.cwd(), cwd) 10 | await new Promise((resolve, reject) => { 11 | logger?.log(chalk.cyan('installing node dependencies')) 12 | const npm = spawn((process.platform === 'win32' ? 'npm.cmd' : 'npm'), ['--unsafe-perm', 'install'], { 13 | stdio: 'inherit', 14 | cwd, 15 | shell: true 16 | }) 17 | npm.on('close', code => { 18 | if (code) return reject(new Error('npm install failed')) 19 | resolve() 20 | }) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /lib/integration/Plugin.js: -------------------------------------------------------------------------------- 1 | import slug from 'speakingurl' 2 | import globs from 'globs' 3 | import bower from 'bower' 4 | import endpointParser from 'bower-endpoint-parser' 5 | import semver from 'semver' 6 | import fs from 'fs-extra' 7 | import path from 'path' 8 | import getBowerRegistryConfig from './getBowerRegistryConfig.js' 9 | import { ADAPT_ALLOW_PRERELEASE, PLUGIN_TYPES, PLUGIN_TYPE_FOLDERS, PLUGIN_DEFAULT_TYPE } from '../util/constants.js' 10 | /** @typedef {import("./Project.js").default} Project */ 11 | const semverOptions = { includePrerelease: ADAPT_ALLOW_PRERELEASE } 12 | 13 | // when a bower command errors this is the maximum number of attempts the command will be repeated 14 | const BOWER_MAX_TRY = 5 15 | 16 | export default class Plugin { 17 | /** 18 | * @param {Object} options 19 | * @param {string} options.name 20 | * @param {string} options.requestedVersion 21 | * @param {boolean} options.isContrib 22 | * @param {boolean} options.isCompatibleEnabled whether to target the latest compatible version for all plugin installations (overrides requestedVersion) 23 | * @param {Project} options.project 24 | * @param {string} options.cwd 25 | * @param {Object} options.logger 26 | */ 27 | constructor ({ 28 | name, 29 | requestedVersion = '*', 30 | isContrib = false, 31 | isCompatibleEnabled = false, 32 | project, 33 | cwd = (project?.cwd ?? process.cwd()), 34 | logger 35 | } = {}) { 36 | this.logger = logger 37 | /** @type {Project} */ 38 | this.project = project 39 | this.cwd = cwd 40 | this.BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd: this.cwd }) 41 | const endpoint = name + '#' + (isCompatibleEnabled ? '*' : requestedVersion) 42 | const ep = endpointParser.decompose(endpoint) 43 | this.sourcePath = null 44 | this.name = ep.name || ep.source 45 | this.packageName = (/^adapt-/i.test(this.name) ? '' : 'adapt-') + (!isContrib ? '' : 'contrib-') + slug(this.name, { maintainCase: true }) 46 | // the constraint given by the user 47 | this.requestedVersion = requestedVersion 48 | // the most recent version of the plugin compatible with the given framework 49 | this.latestCompatibleSourceVersion = null 50 | // a non-wildcard constraint resolved to the highest version of the plugin that satisfies the requestedVersion and is compatible with the framework 51 | this.matchedVersion = null 52 | // a flag describing if the plugin can be updated 53 | this.canBeUpdated = null 54 | 55 | const isNameAPath = /\\|\//g.test(this.name) 56 | const isVersionAPath = /\\|\//g.test(this.requestedVersion) 57 | const isLocalPath = (isNameAPath || isVersionAPath) 58 | if (isLocalPath) { 59 | // wait to name the plugin until the local config file is loaded 60 | this.sourcePath = isNameAPath ? this.name : this.requestedVersion 61 | this.name = isVersionAPath ? this.packageName : '' 62 | this.packageName = isNameAPath ? '' : this.packageName 63 | this.requestedVersion = '*' 64 | } 65 | // the path of the source files 66 | this.projectPath = null 67 | // the project plugin .bower.json or bower.json 68 | this._projectInfo = null 69 | // the result of a query to the server or disk for source files 70 | this._sourceInfo = null 71 | // server given versions 72 | this._versionsInfo = null 73 | 74 | Plugin.instances.push(this) 75 | } 76 | 77 | /** 78 | * the installed version is the latest version 79 | * @returns {boolean|null} 80 | */ 81 | get isUpToDate () { 82 | if (!this.hasFrameworkCompatibleVersion) return true 83 | const canCheckSourceAgainstProject = (this.latestSourceVersion && this.projectVersion) 84 | if (!canCheckSourceAgainstProject) return null 85 | const isLatestVersion = (this.projectVersion === this.latestSourceVersion) 86 | const isLatestMatchedVersion = (this.projectVersion === this.matchedVersion) 87 | const isProjectVersionGreater = semver.gt(this.projectVersion, this.matchedVersion) 88 | return (isLatestVersion || isLatestMatchedVersion || isProjectVersionGreater) 89 | } 90 | 91 | /** 92 | * the most recent version of the plugin 93 | * @returns {string|null} 94 | */ 95 | get latestSourceVersion () { 96 | return (this._sourceInfo?.version || null) 97 | } 98 | 99 | /** 100 | * the installed version of the plugin 101 | * @returns {string|null} 102 | */ 103 | get projectVersion () { 104 | return (this._projectInfo?.version || null) 105 | } 106 | 107 | /** 108 | * the required framework version from the source package json 109 | * @returns {string|null} 110 | */ 111 | get frameworkVersion () { 112 | return (this._sourceInfo?.framework || null) 113 | } 114 | 115 | /** 116 | * a list of tags denoting the source versions of the plugin 117 | * @returns {[string]} 118 | */ 119 | get sourceVersions () { 120 | return this._versionsInfo 121 | } 122 | 123 | /** 124 | * plugin will be or was installed from a local source 125 | * @returns {boolean} 126 | */ 127 | get isLocalSource () { 128 | return Boolean(this.sourcePath || this?._projectInfo?._wasInstalledFromPath) 129 | } 130 | 131 | /** 132 | * check if source path is a zip 133 | * @returns {boolean} 134 | */ 135 | get isLocalSourceZip () { 136 | return Boolean(this.isLocalSource && (this.sourcePath?.includes('.zip') || this._projectInfo?._source?.includes('.zip'))) 137 | } 138 | 139 | /** @returns {boolean} */ 140 | get isVersioned () { 141 | return Boolean(this.sourceVersions?.length) 142 | } 143 | 144 | /** 145 | * is a contrib plugin 146 | * @returns {boolean} 147 | */ 148 | get isContrib () { 149 | return /^adapt-contrib/.test(this.packageName) 150 | } 151 | 152 | /** 153 | * whether querying the server or disk for plugin information worked 154 | * @returns {boolean} 155 | */ 156 | get isPresent () { 157 | return Boolean(this._projectInfo || this._sourceInfo) 158 | } 159 | 160 | /** 161 | * has user requested version 162 | * @returns {boolean} 163 | */ 164 | get hasUserRequestVersion () { 165 | return (this.requestedVersion !== '*') 166 | } 167 | 168 | /** 169 | * the supplied a constraint is valid and supported by the plugin 170 | * @returns {boolean|null} 171 | */ 172 | get hasValidRequestVersion () { 173 | return (this.latestSourceVersion) 174 | ? semver.validRange(this.requestedVersion, semverOptions) && 175 | (this.isVersioned 176 | ? semver.maxSatisfying(this.sourceVersions, this.requestedVersion, semverOptions) !== null 177 | : semver.satisfies(this.latestSourceVersion, this.requestedVersion, semverOptions) 178 | ) 179 | : null 180 | } 181 | 182 | /** @returns {boolean} */ 183 | get hasFrameworkCompatibleVersion () { 184 | return (this.latestCompatibleSourceVersion !== null) 185 | } 186 | 187 | async fetchSourceInfo () { 188 | if (this.isLocalSource) return await this.fetchLocalSourceInfo() 189 | await this.fetchBowerInfo() 190 | } 191 | 192 | async fetchLocalSourceInfo () { 193 | if (this._sourceInfo) return this._sourceInfo 194 | this._sourceInfo = null 195 | if (!this.isLocalSource) throw new Error('Plugin name or version must be a path to the source') 196 | if (this.isLocalSourceZip) throw new Error('Cannot install from zip files') 197 | this._sourceInfo = await new Promise((resolve, reject) => { 198 | // get bower.json data 199 | const paths = [ 200 | path.resolve(this.cwd, `${this.sourcePath}/bower.json`) 201 | ] 202 | const bowerJSON = paths.reduce((bowerJSON, bowerJSONPath) => { 203 | if (bowerJSON) return bowerJSON 204 | if (!fs.existsSync(bowerJSONPath)) return null 205 | return fs.readJSONSync(bowerJSONPath) 206 | }, null) 207 | resolve(bowerJSON) 208 | }) 209 | if (!this._sourceInfo) return 210 | this.name = this._sourceInfo.name 211 | this.matchedVersion = this.latestSourceVersion 212 | this.packageName = this.name 213 | } 214 | 215 | async fetchBowerInfo () { 216 | if (this._sourceInfo) return this._sourceInfo 217 | this._sourceInfo = null 218 | if (this.isLocalSource) return 219 | const perform = async (attemptCount = 0) => { 220 | try { 221 | return await new Promise((resolve, reject) => { 222 | bower.commands.info(`${this.packageName}`, null, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG }) 223 | .on('end', resolve) 224 | .on('error', reject) 225 | }) 226 | } catch (err) { 227 | const isFinished = (err?.code === 'ENOTFOUND' || attemptCount >= BOWER_MAX_TRY) 228 | if (isFinished) return null 229 | return await perform(attemptCount + 1) 230 | } 231 | } 232 | const info = await perform() 233 | if (!info) return 234 | this._sourceInfo = info.latest 235 | this._versionsInfo = info.versions.filter(version => semverOptions.includePrerelease ? true : !semver.prerelease(version)) 236 | } 237 | 238 | async fetchProjectInfo () { 239 | if (this._projectInfo) return this._projectInfo 240 | this._projectInfo = null 241 | this._projectInfo = await new Promise((resolve, reject) => { 242 | // get bower.json data 243 | globs([ 244 | `${this.cwd.replace(/\\/g, '/')}/src/*/${this.packageName}/.bower.json`, 245 | `${this.cwd.replace(/\\/g, '/')}/src/*/${this.packageName}/bower.json` 246 | ], (err, matches) => { 247 | if (err) return resolve(null) 248 | const tester = new RegExp(`/${this.packageName}/`, 'i') 249 | const match = matches.find(match => tester.test(match)) 250 | if (!match) { 251 | // widen the search 252 | globs([ 253 | `${this.cwd.replace(/\\/g, '/')}/src/**/.bower.json`, 254 | `${this.cwd.replace(/\\/g, '/')}/src/**/bower.json` 255 | ], (err, matches) => { 256 | if (err) return resolve(null) 257 | const tester = new RegExp(`/${this.packageName}/`, 'i') 258 | const match = matches.find(match => tester.test(match)) 259 | if (!match) return resolve(null) 260 | this.projectPath = path.resolve(match, '../') 261 | resolve(fs.readJSONSync(match)) 262 | }) 263 | return 264 | } 265 | this.projectPath = path.resolve(match, '../') 266 | resolve(fs.readJSONSync(match)) 267 | }) 268 | }) 269 | if (!this._projectInfo) return 270 | this.name = this._projectInfo.name 271 | this.packageName = this.name 272 | } 273 | 274 | async findCompatibleVersion (framework) { 275 | const getBowerVersionInfo = async (version) => { 276 | const perform = async (attemptCount = 0) => { 277 | try { 278 | return await new Promise((resolve, reject) => { 279 | bower.commands.info(`${this.packageName}@${version}`, null, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG }) 280 | .on('end', resolve) 281 | .on('error', reject) 282 | }) 283 | } catch (err) { 284 | const isFinished = (err?.code === 'ENOTFOUND' || attemptCount >= BOWER_MAX_TRY) 285 | if (isFinished) return null 286 | return await perform(attemptCount++) 287 | } 288 | } 289 | return await perform() 290 | } 291 | const getMatchingVersion = async () => { 292 | if (this.isLocalSource) { 293 | const info = this.projectVersion ? this._projectInfo : this._sourceInfo 294 | const satisfiesConstraint = !this.hasValidRequestVersion || semver.satisfies(info.version, this.requestedVersion, semverOptions) 295 | const satisfiesFramework = semver.satisfies(framework, info.framework) 296 | if (satisfiesFramework && satisfiesConstraint) this.latestCompatibleSourceVersion = info.version 297 | return info.version 298 | } 299 | 300 | if (!this.isPresent) return null 301 | 302 | // check if the latest version is compatible 303 | const satisfiesConstraint = !this.hasValidRequestVersion || semver.satisfies(this._sourceInfo.version, this.requestedVersion, semverOptions) 304 | const satisfiesFramework = semver.satisfies(framework, this.frameworkVersion, semverOptions) 305 | if (!this.latestCompatibleSourceVersion && satisfiesFramework) this.latestCompatibleSourceVersion = this.latestSourceVersion 306 | if (satisfiesConstraint && satisfiesFramework) { 307 | return this.latestSourceVersion 308 | } 309 | 310 | if (!this.isVersioned) return null 311 | 312 | // find the highest version that is compatible with the framework and constraint 313 | const searchVersionInfo = async (framework, versionIndex = 0) => { 314 | const versioninfo = await getBowerVersionInfo(this.sourceVersions[versionIndex]) 315 | // give up if there is any failure to obtain version info 316 | if (!this.isPresent) return null 317 | // check that the proposed plugin is compatible with the contraint and installed framework 318 | const satisfiesConstraint = !this.hasValidRequestVersion || semver.satisfies(versioninfo.version, this.requestedVersion, semverOptions) 319 | const satisfiesFramework = semver.satisfies(framework, versioninfo.framework, semverOptions) 320 | if (!this.latestCompatibleSourceVersion && satisfiesFramework) this.latestCompatibleSourceVersion = versioninfo.version 321 | const checkNext = (!satisfiesFramework || !satisfiesConstraint) 322 | const hasNoMoreVersions = (versionIndex + 1 >= this.sourceVersions.length) 323 | if (checkNext && hasNoMoreVersions) return null 324 | if (checkNext) return await searchVersionInfo(framework, versionIndex + 1) 325 | return versioninfo.version 326 | } 327 | return await searchVersionInfo(framework) 328 | } 329 | this.matchedVersion = await getMatchingVersion() 330 | this.canBeUpdated = (this.projectVersion && this.matchedVersion) && (this.projectVersion !== this.matchedVersion) 331 | } 332 | 333 | /** 334 | * @returns {string} 335 | */ 336 | async getType () { 337 | if (this._type) return this._type 338 | const info = await this.getInfo() 339 | const foundAttributeType = PLUGIN_TYPES.find(type => info[type]) 340 | const foundKeywordType = info.keywords 341 | .map(keyword => { 342 | const typematches = PLUGIN_TYPES.filter(type => keyword?.toLowerCase()?.includes(type)) 343 | return typematches.length ? typematches[0] : null 344 | }) 345 | .filter(Boolean)[0] 346 | return (this._type = foundAttributeType || foundKeywordType || PLUGIN_DEFAULT_TYPE) 347 | } 348 | 349 | async getTypeFolder () { 350 | const type = await this.getType() 351 | return PLUGIN_TYPE_FOLDERS[type] 352 | } 353 | 354 | async getInfo () { 355 | if (this._projectInfo) return this._projectInfo 356 | if (!this._sourceInfo) await this.fetchSourceInfo() 357 | return this._sourceInfo 358 | } 359 | 360 | async getRepositoryUrl () { 361 | if (this._repositoryUrl) return this._repositoryUrl 362 | if (this.isLocalSource) return 363 | const url = await new Promise((resolve, reject) => { 364 | bower.commands.lookup(this.packageName, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG }) 365 | .on('end', resolve) 366 | .on('error', reject) 367 | }) 368 | return (this._repositoryUrl = url) 369 | } 370 | 371 | /** @returns {string} */ 372 | toString () { 373 | const isAny = (this.projectVersion === '*' || this.projectVersion === null) 374 | return `${this.packageName}${isAny ? '' : `@${this.projectVersion}`}` 375 | } 376 | 377 | async getSchemaPaths () { 378 | if (this.isLocalSource) await this.fetchLocalSourceInfo() 379 | else if (this.project) await this.fetchProjectInfo() 380 | else throw new Error(`Cannot fetch schemas from remote plugin: ${this.name}`) 381 | const pluginPath = this.projectPath ?? this.sourcePath 382 | return new Promise((resolve, reject) => { 383 | return globs(path.resolve(this.cwd, pluginPath, '**/*.schema.json'), (err, matches) => { 384 | if (err) return reject(err) 385 | resolve(matches) 386 | }) 387 | }) 388 | } 389 | 390 | /** 391 | * Read plugin data from pluginPath 392 | * @param {Object} options 393 | * @param {string} options.pluginPath Path to source directory 394 | * @param {string} [options.projectPath=process.cwd()] Optional path to potential installation project 395 | * @returns {Plugin} 396 | */ 397 | static async fromPath ({ 398 | pluginPath, 399 | projectPath = process.cwd() 400 | }) { 401 | const plugin = new Plugin({ 402 | name: pluginPath, 403 | cwd: projectPath 404 | }) 405 | await plugin.fetchLocalSourceInfo() 406 | return plugin 407 | } 408 | 409 | static get instances () { 410 | return (Plugin._instances = Plugin._instances || []) 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement.js: -------------------------------------------------------------------------------- 1 | import authenticate from './PluginManagement/autenticate.js' 2 | import install from './PluginManagement/install.js' 3 | import register from './PluginManagement/register.js' 4 | import rename from './PluginManagement/rename.js' 5 | import schemas from './PluginManagement/schemas.js' 6 | import search from './PluginManagement/search.js' 7 | import uninstall from './PluginManagement/uninstall.js' 8 | import unregister from './PluginManagement/unregister.js' 9 | import update from './PluginManagement/update.js' 10 | 11 | export { 12 | authenticate, 13 | install, 14 | register, 15 | rename, 16 | schemas, 17 | search, 18 | uninstall, 19 | unregister, 20 | update 21 | } 22 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/autenticate.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import inquirer from 'inquirer' 3 | import fetch from 'node-fetch' 4 | import getBowerRegistryConfig from '../getBowerRegistryConfig.js' 5 | import path from 'path' 6 | 7 | export default async function authenticate ({ 8 | pluginName, 9 | cwd = process.cwd() 10 | } = {}) { 11 | cwd = path.resolve(process.cwd(), cwd) 12 | const BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd }) 13 | // check if github, do github device oauth workflow 14 | // if not github, send request to repo anyway 15 | const questions = [ 16 | { 17 | name: 'username', 18 | message: chalk.cyan('GitHub username') 19 | }, 20 | { 21 | name: 'token', 22 | message: chalk.cyan('GitHub personal access token (with public_repo access)'), 23 | type: 'password', 24 | mask: '*' 25 | } 26 | ] 27 | if (!pluginName) { 28 | questions.unshift({ 29 | name: 'pluginName', 30 | message: chalk.cyan('Plugin name'), 31 | default: pluginName 32 | }) 33 | } 34 | const confirmation = await inquirer.prompt(questions) 35 | if (!pluginName) { 36 | ({ pluginName } = confirmation) 37 | } 38 | const { username, token } = confirmation 39 | const response = await fetch(`${BOWER_REGISTRY_CONFIG.register}authenticate/${username}/${pluginName}?access_token=${token}`, { 40 | headers: { 'User-Agent': 'adapt-cli' }, 41 | followRedirect: false, 42 | method: 'GET' 43 | }) 44 | if (response.status !== 200) throw new Error(`The server responded with ${response.status}`) 45 | const body = await response.json() 46 | return { username, token, pluginName, ...body } 47 | } 48 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/install.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { eachOfSeries } from 'async' 3 | import { createPromptTask } from '../../util/createPromptTask.js' 4 | import { errorPrinter, packageNamePrinter, versionPrinter } from './print.js' 5 | import { eachOfLimitProgress, eachOfSeriesProgress } from '../../util/promises.js' 6 | import Project from '../Project.js' 7 | import Target from '../Target.js' 8 | import bower from 'bower' 9 | import { difference } from 'lodash-es' 10 | import path from 'path' 11 | 12 | export default async function install ({ 13 | plugins, 14 | dev = false, 15 | isInteractive = true, 16 | isDryRun = false, // whether to summarise installation without modifying anything 17 | isCompatibleEnabled = false, 18 | isClean = false, 19 | cwd = process.cwd(), 20 | logger = null 21 | }) { 22 | cwd = path.resolve(process.cwd(), cwd) 23 | isClean && await new Promise(resolve => bower.commands.cache.clean().on('end', resolve)) 24 | const project = new Project({ cwd, logger }) 25 | project.tryThrowInvalidPath() 26 | 27 | logger?.log(chalk.cyan(`${dev ? 'cloning' : 'installing'} adapt dependencies...`)) 28 | 29 | const targets = await getInstallTargets({ logger, project, plugins, isCompatibleEnabled }) 30 | if (!targets?.length) return targets 31 | 32 | await loadPluginData({ logger, project, targets }) 33 | await conflictResolution({ logger, targets, isInteractive, dev }) 34 | if (isDryRun) { 35 | await summariseDryRun({ logger, targets }) 36 | return targets 37 | } 38 | const installTargetsToBeInstalled = targets.filter(target => target.isToBeInstalled) 39 | if (installTargetsToBeInstalled.length) { 40 | await eachOfSeriesProgress( 41 | installTargetsToBeInstalled, 42 | target => target.install({ clone: dev }), 43 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Installing plugins ${percentage}% complete`) 44 | ) 45 | logger?.log(`${chalk.bold.cyan('')} Installing plugins 100% complete`) 46 | const manifestDependencies = await project.getManifestDependencies() 47 | await updateManifest({ logger, project, targets, manifestDependencies, isInteractive }) 48 | } 49 | await summariseInstallation({ logger, targets, dev }) 50 | return targets 51 | } 52 | 53 | /** 54 | * @param {Object} options 55 | * @param {Project} options.project 56 | * @param {[string]} options.plugins 57 | */ 58 | async function getInstallTargets ({ logger, project, plugins, isCompatibleEnabled }) { 59 | if (typeof plugins === 'string') plugins = [plugins] 60 | /** whether adapt.json is being used to compile the list of plugins to install */ 61 | const isEmpty = !plugins?.length 62 | /** a list of plugin name/version pairs */ 63 | const itinerary = isEmpty 64 | ? await project.getManifestDependencies() 65 | : plugins.reduce((itinerary, arg) => { 66 | const [name, version = '*'] = arg.split(/[#@]/) 67 | // Duplicates are removed by assigning to object properties 68 | itinerary[name] = version 69 | return itinerary 70 | }, {}) 71 | const pluginNames = Object.entries(itinerary).map(([name, version]) => `${name}#${version}`) 72 | 73 | /** 74 | * @type {[Target]} 75 | */ 76 | const targets = pluginNames.length 77 | ? pluginNames.map(nameVersion => { 78 | const [name, requestedVersion] = nameVersion.split(/[#@]/) 79 | return new Target({ name, requestedVersion, isCompatibleEnabled, project, logger }) 80 | }) 81 | : await project.getInstallTargets() 82 | return targets 83 | } 84 | 85 | /** 86 | * @param {Object} options 87 | * @param {Project} options.project 88 | * @param {[Target]} options.targets 89 | */ 90 | async function loadPluginData ({ logger, project, targets }) { 91 | const frameworkVersion = project.version 92 | await eachOfLimitProgress( 93 | targets, 94 | target => target.fetchSourceInfo(), 95 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Getting plugin info ${percentage}% complete`) 96 | ) 97 | logger?.log(`${chalk.bold.cyan('')} Getting plugin info 100% complete`) 98 | await eachOfLimitProgress( 99 | targets, 100 | target => target.findCompatibleVersion(frameworkVersion), 101 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Finding compatible source versions ${percentage}% complete`) 102 | ) 103 | logger?.log(`${chalk.bold.cyan('')} Finding compatible source versions 100% complete`) 104 | await eachOfLimitProgress( 105 | targets, 106 | target => target.markInstallable(), 107 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Marking installable ${percentage}% complete`) 108 | ) 109 | logger?.log(`${chalk.bold.cyan('')} Marking installable 100% complete`) 110 | } 111 | 112 | /** 113 | * @param {Object} options 114 | * @param {[Target]} options.targets 115 | */ 116 | async function conflictResolution ({ logger, targets, isInteractive, dev }) { 117 | /** @param {Target} target */ 118 | async function checkVersion (target) { 119 | const canApplyRequested = target.hasValidRequestVersion && 120 | (target.hasFrameworkCompatibleVersion 121 | ? (target.latestCompatibleSourceVersion !== target.matchedVersion) 122 | : (target.latestSourceVersion !== target.matchedVersion)) 123 | if (!isInteractive) { 124 | if (canApplyRequested) return target.markRequestedForInstallation() 125 | return target.markSkipped() 126 | } 127 | const choices = [ 128 | dev && { name: 'master [master]', value: 'm' }, 129 | canApplyRequested && { name: `requested version [${target.matchedVersion}]`, value: 'r' }, 130 | target.hasFrameworkCompatibleVersion 131 | ? { name: `latest compatible version [${target.latestCompatibleSourceVersion}]`, value: 'l' } 132 | : target.latestSourceVersion 133 | ? { name: `latest version [${target.latestSourceVersion}]`, value: 'l' } 134 | : { name: 'master [master]', value: 'm' }, 135 | { name: 'skip', value: 's' } 136 | ].filter(Boolean) 137 | const result = await createPromptTask({ message: chalk.reset(target.packageName), choices, type: 'list', default: 's' }) 138 | const installMasterBranch = (result === 'm') 139 | const installRequested = (result === 'r') 140 | const installLatest = result === 'l' 141 | const skipped = result === 's' 142 | if (installMasterBranch) target.markMasterForInstallation() 143 | if (installRequested) target.markRequestedForInstallation() 144 | if (installLatest && target.hasFrameworkCompatibleVersion) target.markLatestCompatibleForInstallation() 145 | if (installLatest && !target.hasFrameworkCompatibleVersion) target.markLatestForInstallation() 146 | if (skipped) target.markSkipped() 147 | } 148 | function add (list, header, prompt) { 149 | if (!list.length) return 150 | return { 151 | header: chalk.cyan(' ') + header, 152 | list, 153 | prompt 154 | } 155 | } 156 | const allQuestions = [ 157 | add(targets.filter(target => !target.hasFrameworkCompatibleVersion), 'There is no compatible version of the following plugins:', checkVersion), 158 | add(targets.filter(target => target.hasFrameworkCompatibleVersion && !target.hasValidRequestVersion), 'The version requested is invalid, there are newer compatible versions of the following plugins:', checkVersion), 159 | add(targets.filter(target => target.hasFrameworkCompatibleVersion && target.hasValidRequestVersion && !target.isApplyLatestCompatibleVersion), 'There are newer compatible versions of the following plugins:', checkVersion) 160 | ].filter(Boolean) 161 | if (allQuestions.length === 0) return 162 | for (const question of allQuestions) { 163 | logger?.log(question.header) 164 | await eachOfSeries(question.list, question.prompt) 165 | } 166 | } 167 | 168 | /** 169 | * @param {Object} options 170 | * @param {Project} options.project 171 | * @param {[Target]} options.targets 172 | */ 173 | async function updateManifest ({ project, targets, manifestDependencies, isInteractive }) { 174 | if (targets.filter(target => target.isInstallSuccessful).length === 0) return 175 | if (difference(targets.filter(target => target.isInstallSuccessful).map(target => target.packageName), Object.keys(manifestDependencies)).length === 0) return 176 | if (isInteractive) { 177 | const shouldUpdate = await createPromptTask({ 178 | message: chalk.white('Update the manifest (adapt.json)?'), 179 | type: 'confirm', 180 | default: true 181 | }) 182 | if (!shouldUpdate) return 183 | } 184 | targets.forEach(target => target.isInstallSuccessful && project.add(target)) 185 | } 186 | 187 | /** 188 | * @param {Object} options 189 | * @param {[Target]} options.targets 190 | */ 191 | function summariseDryRun ({ logger, targets }) { 192 | const toBeInstalled = targets.filter(target => target.isToBeInstalled) 193 | const toBeSkipped = targets.filter(target => !target.isToBeInstalled || target.isSkipped) 194 | const missing = targets.filter(target => target.isMissing) 195 | summarise(logger, toBeSkipped, packageNamePrinter, 'The following plugins will be skipped:') 196 | summarise(logger, missing, packageNamePrinter, 'There was a problem locating the following plugins:') 197 | summarise(logger, toBeInstalled, versionPrinter, 'The following plugins will be installed:') 198 | } 199 | 200 | /** 201 | * @param {Object} options 202 | * @param {[Target]} options.targets 203 | */ 204 | function summariseInstallation ({ logger, targets, dev }) { 205 | const installSucceeded = targets.filter(target => target.isInstallSuccessful) 206 | const installSkipped = targets.filter(target => !target.isToBeInstalled || target.isSkipped) 207 | const installErrored = targets.filter(target => target.isInstallFailure) 208 | const missing = targets.filter(target => target.isMissing) 209 | const noneInstalled = (installSucceeded.length === 0) 210 | const allInstalledSuccessfully = (installErrored.length === 0 && missing.length === 0) 211 | const someInstalledSuccessfully = (!noneInstalled && !allInstalledSuccessfully) 212 | summarise(logger, installSkipped, packageNamePrinter, 'The following plugins were skipped:') 213 | summarise(logger, missing, packageNamePrinter, 'There was a problem locating the following plugins:') 214 | summarise(logger, installErrored, errorPrinter, 'The following plugins could not be installed:') 215 | if (noneInstalled) logger?.log(chalk.cyanBright('None of the requested plugins could be installed')) 216 | else if (allInstalledSuccessfully) summarise(logger, installSucceeded, dev ? packageNamePrinter : versionPrinter, 'All requested plugins were successfully installed. Summary of installation:') 217 | else if (someInstalledSuccessfully) summarise(logger, installSucceeded, dev ? packageNamePrinter : versionPrinter, 'The following plugins were successfully installed:') 218 | } 219 | 220 | function summarise (logger, list, iterator, header) { 221 | if (!list || !iterator || list.length === 0) return 222 | logger?.log(chalk.cyanBright(header)) 223 | list.forEach(item => iterator(item, logger)) 224 | } 225 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/print.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import semver from 'semver' 3 | import { ADAPT_ALLOW_PRERELEASE } from '../../util/constants.js' 4 | const semverOptions = { includePrerelease: ADAPT_ALLOW_PRERELEASE } 5 | 6 | function highlight (pluginname) { 7 | return ['adapt-contrib', 'adapt-'].reduce((output, prefix) => { 8 | if (output || !pluginname.startsWith(prefix)) return output 9 | return chalk.reset(prefix) + chalk.yellowBright(pluginname.substring(prefix.length)) 10 | }, null) || pluginname 11 | } 12 | 13 | function greenIfEqual (v1, v2) { 14 | if (v1 === '*') return chalk.greenBright(v2) 15 | return semver.satisfies(v1, v2, semverOptions) 16 | ? chalk.greenBright(v2) 17 | : chalk.magentaBright(v2) 18 | } 19 | 20 | export function versionPrinter (plugin, logger) { 21 | const { 22 | versionToApply, 23 | latestCompatibleSourceVersion 24 | } = plugin 25 | logger?.log(highlight(plugin.packageName), latestCompatibleSourceVersion === null 26 | ? '(no version information)' 27 | : `${chalk.greenBright(versionToApply)}${plugin.isLocalSource ? ' (local)' : ` (latest compatible version is ${greenIfEqual(versionToApply, latestCompatibleSourceVersion)})`}` 28 | ) 29 | } 30 | 31 | export function existingVersionPrinter (plugin, logger) { 32 | const { 33 | preUpdateProjectVersion, 34 | projectVersion, 35 | latestCompatibleSourceVersion 36 | } = plugin 37 | const fromTo = preUpdateProjectVersion !== null 38 | ? `from ${chalk.greenBright(preUpdateProjectVersion)} to ${chalk.greenBright(projectVersion)}` 39 | : `${chalk.greenBright(projectVersion)}` 40 | logger?.log(highlight(plugin.packageName), latestCompatibleSourceVersion === null 41 | ? fromTo 42 | : `${fromTo}${plugin.isLocalSource ? ' (local)' : ` (latest compatible version is ${greenIfEqual(projectVersion, latestCompatibleSourceVersion)})`}` 43 | ) 44 | } 45 | 46 | export function errorPrinter (plugin, logger) { 47 | logger?.log(highlight(plugin.packageName), plugin.installError ? '(error: ' + plugin.installError + ')' : '(unknown error)') 48 | } 49 | 50 | export function packageNamePrinter (plugin, logger) { 51 | logger?.log(highlight(plugin.packageName)) 52 | } 53 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/register.js: -------------------------------------------------------------------------------- 1 | 2 | import getBowerRegistryConfig from '../getBowerRegistryConfig.js' 3 | import fs from 'fs-extra' 4 | import path from 'path' 5 | import bower from 'bower' 6 | import fetch from 'node-fetch' 7 | import chalk from 'chalk' 8 | import inquirer from 'inquirer' 9 | import { readValidateJSON } from '../../util/JSONReadValidate.js' 10 | import Plugin from '../Plugin.js' 11 | import semver from 'semver' 12 | import { ADAPT_ALLOW_PRERELEASE } from '../../util/constants.js' 13 | const semverOptions = { includePrerelease: ADAPT_ALLOW_PRERELEASE } 14 | 15 | export default async function register ({ 16 | logger, 17 | cwd = process.cwd() 18 | } = {}) { 19 | cwd = path.resolve(process.cwd(), cwd) 20 | const BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd }) 21 | logger?.warn('Using registry at', BOWER_REGISTRY_CONFIG.register) 22 | try { 23 | const bowerJSONPath = path.join(cwd, 'bower.json') 24 | const hasBowerJSON = fs.existsSync(bowerJSONPath) 25 | 26 | const bowerJSON = { 27 | name: undefined, 28 | repository: undefined, 29 | framework: undefined, 30 | ...(hasBowerJSON ? await readValidateJSON(bowerJSONPath) : {}) 31 | } 32 | const properties = await confirm(bowerJSON) 33 | hasBowerJSON && await fs.writeJSON(bowerJSONPath, properties, { spaces: 2, replacer: null }) 34 | 35 | // given a package name, create two Plugin representations 36 | // if supplied name is adapt-contrib-myPackageName do a check against this name only 37 | // if suppled name is adapt-myPackageName check against this name and adapt-contrib-myPackageName 38 | // becase we don't want to allow adapt-myPackageName if adapt-contrib-myPackageName exists 39 | const plugin = new Plugin({ name: properties.name, logger }) 40 | const contribPlugin = new Plugin({ name: properties.name, isContrib: true, logger }) 41 | const contribExists = await exists(BOWER_REGISTRY_CONFIG, contribPlugin) 42 | const pluginExists = await exists(BOWER_REGISTRY_CONFIG, plugin) 43 | 44 | if (contribExists || pluginExists) { 45 | logger?.warn(plugin.toString(), 'has been previously registered. Plugin names must be unique. Try again with a different name.') 46 | return 47 | } 48 | 49 | const result = await registerWithBowerRepo(BOWER_REGISTRY_CONFIG, plugin, properties.repository) 50 | if (!result) throw new Error('The plugin was unable to register.') 51 | logger?.log(chalk.green(plugin.packageName), 'has been registered successfully.') 52 | } catch (err) { 53 | logger?.error(err) 54 | } 55 | } 56 | 57 | async function confirm (properties) { 58 | const plugin = new Plugin({ name: properties.name }) 59 | const schema = [ 60 | { 61 | name: 'name', 62 | message: chalk.cyan('name'), 63 | validate: v => { 64 | return /^adapt-[\w|-]+?$/.test(v) || 65 | 'Name must prefixed with \'adapt\' and each word separated with a hyphen(-)' 66 | }, 67 | type: 'input', 68 | default: plugin.toString() || 'not specified' 69 | }, 70 | { 71 | name: 'repositoryUrl', 72 | message: chalk.cyan('repository URL'), 73 | validate: v => { 74 | return /https:\/\/([\w.@:/\-~]+)(\.git)(\/)?/.test(v) || 75 | 'Please provide a repository URL of the form https://.git' 76 | }, 77 | type: 'input', 78 | default: properties.repository ? properties.repository.url : undefined 79 | }, 80 | { 81 | name: 'framework', 82 | message: chalk.cyan('framework'), 83 | validate: v => { 84 | return semver.validRange(v, semverOptions) !== null || 85 | 'Please provide a valid semver (see https://semver.org/)' 86 | }, 87 | type: 'input', 88 | default: properties.framework || '>=5.15' 89 | }, 90 | { 91 | name: 'ready', 92 | message: chalk.cyan('Register now?'), 93 | type: 'confirm', 94 | default: true 95 | } 96 | ] 97 | const confirmation = await inquirer.prompt(schema) 98 | if (!confirmation.ready) throw new Error('Aborted. Nothing has been registered.') 99 | properties.name = confirmation.name 100 | properties.repository = { type: 'git', url: confirmation.repositoryUrl } 101 | properties.framework = confirmation.framework 102 | return properties 103 | } 104 | 105 | /** 106 | * @param {Plugin} plugin 107 | * @returns {boolean} 108 | */ 109 | async function exists (BOWER_REGISTRY_CONFIG, plugin) { 110 | const pluginName = plugin.toString().toLowerCase() 111 | return new Promise((resolve, reject) => { 112 | bower.commands.search(pluginName, { 113 | registry: BOWER_REGISTRY_CONFIG.register 114 | }) 115 | .on('end', result => { 116 | const matches = result.filter(({ name }) => name.toLowerCase() === pluginName) 117 | resolve(Boolean(matches.length)) 118 | }) 119 | .on('error', reject) 120 | }) 121 | } 122 | 123 | async function registerWithBowerRepo (BOWER_REGISTRY_CONFIG, plugin, repository) { 124 | const data = { 125 | name: plugin.toString(), 126 | url: repository.url || repository 127 | } 128 | const response = await fetch(`${BOWER_REGISTRY_CONFIG.register}packages`, { 129 | headers: { 130 | 'User-Agent': 'adapt-cli', 131 | 'Content-Type': 'application/json' 132 | }, 133 | followRedirect: false, 134 | method: 'POST', 135 | body: JSON.stringify(data) 136 | }) 137 | return (response?.status === 201) 138 | } 139 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/rename.js: -------------------------------------------------------------------------------- 1 | import getBowerRegistryConfig from '../getBowerRegistryConfig.js' 2 | import authenticate from './autenticate.js' 3 | import bower from 'bower' 4 | import chalk from 'chalk' 5 | import inquirer from 'inquirer' 6 | import fetch from 'node-fetch' 7 | import path from 'path' 8 | import Plugin from '../Plugin.js' 9 | 10 | export default async function rename ({ 11 | logger, 12 | oldName, 13 | newName, 14 | cwd = process.cwd() 15 | } = {}) { 16 | cwd = path.resolve(process.cwd(), cwd) 17 | const BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd }) 18 | if (!oldName || !newName) { 19 | logger?.error('You must call rename with the following arguments: ') 20 | return 21 | } 22 | // use Plugin to standardise name 23 | newName = new Plugin({ name: newName, logger }).packageName 24 | oldName = new Plugin({ name: oldName, logger }).packageName 25 | logger?.warn('Using registry at', BOWER_REGISTRY_CONFIG.register) 26 | logger?.warn(`Plugin will be renamed from ${oldName} to ${newName}`) 27 | try { 28 | const oldExists = await exists(BOWER_REGISTRY_CONFIG, oldName) 29 | if (!oldExists) throw new Error(`Plugin "${oldName}" does not exist`) 30 | const newExists = await exists(BOWER_REGISTRY_CONFIG, newName) 31 | if (newExists) throw new Error(`Name "${newName}" already exists`) 32 | const { username, token, type } = await authenticate({ pluginName: oldName }) 33 | logger?.log(`${username} authenticated as ${type}`) 34 | await confirm() 35 | await renameInBowerRepo({ 36 | username, 37 | token, 38 | oldName, 39 | newName, 40 | BOWER_REGISTRY_CONFIG 41 | }) 42 | logger?.log(chalk.green('The plugin was successfully renamed.')) 43 | } catch (err) { 44 | logger?.error(err) 45 | logger?.error('The plugin was not renamed.') 46 | } 47 | } 48 | 49 | async function confirm () { 50 | const schema = [ 51 | { 52 | name: 'ready', 53 | message: chalk.cyan('Confirm rename now?'), 54 | type: 'confirm', 55 | default: true 56 | } 57 | ] 58 | const confirmation = await inquirer.prompt(schema) 59 | if (!confirmation.ready) throw new Error('Aborted. Nothing has been renamed.') 60 | } 61 | 62 | async function renameInBowerRepo ({ 63 | username, 64 | token, 65 | oldName, 66 | newName, 67 | BOWER_REGISTRY_CONFIG 68 | }) { 69 | const path = 'packages/rename/' + username + '/' + oldName + '/' + newName 70 | const query = '?access_token=' + token 71 | const response = await fetch(BOWER_REGISTRY_CONFIG.register + path + query, { 72 | method: 'GET', 73 | headers: { 'User-Agent': 'adapt-cli' }, 74 | followRedirect: false 75 | }) 76 | if (response.status !== 201) throw new Error(`The server responded with ${response.status}`) 77 | } 78 | 79 | /** 80 | * @param {Plugin} plugin 81 | * @returns {boolean} 82 | */ 83 | async function exists (BOWER_REGISTRY_CONFIG, plugin) { 84 | const pluginName = plugin.toString().toLowerCase() 85 | return new Promise((resolve, reject) => { 86 | bower.commands.search(pluginName, { 87 | registry: BOWER_REGISTRY_CONFIG.register 88 | }) 89 | .on('end', result => { 90 | const matches = result.filter(({ name }) => name.toLowerCase() === pluginName) 91 | resolve(Boolean(matches.length)) 92 | }) 93 | .on('error', reject) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/schemas.js: -------------------------------------------------------------------------------- 1 | import Project from '../Project.js' 2 | import path from 'path' 3 | 4 | export default async function schemas ({ cwd = process.cwd() } = {}) { 5 | cwd = path.resolve(process.cwd(), cwd) 6 | const project = new Project({ cwd }) 7 | return await project.getSchemaPaths({ cwd }) 8 | } 9 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/search.js: -------------------------------------------------------------------------------- 1 | import getBowerRegistryConfig from '../getBowerRegistryConfig.js' 2 | import chalk from 'chalk' 3 | import fetch from 'node-fetch' 4 | import path from 'path' 5 | 6 | export default async function search ({ 7 | logger, 8 | searchTerm, 9 | cwd = process.cwd() 10 | } = {}) { 11 | cwd = path.resolve(process.cwd(), cwd) 12 | const BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd }) 13 | try { 14 | const uniqueResults = {} 15 | for (const serverURI of BOWER_REGISTRY_CONFIG.search) { 16 | try { 17 | const response = await fetch(`${serverURI}packages/search/${searchTerm}`, { 18 | method: 'GET', 19 | headers: { 'User-Agent': 'adapt-cli' }, 20 | followRedirect: false 21 | }) 22 | if (response.status !== 200) throw new Error(`The server responded with ${response.status}`) 23 | const immediateResults = await response.json() 24 | immediateResults?.forEach(result => (uniqueResults[result.name] = uniqueResults[result.name] ?? result)) 25 | } catch (err) {} 26 | } 27 | const results = Object.values(uniqueResults) 28 | if (!results.length) { 29 | logger?.warn(`no plugins found containing: ${searchTerm}`) 30 | } 31 | results.forEach(function (result) { 32 | logger?.log(chalk.cyan(result.name) + ' ' + result.url) 33 | }) 34 | } catch (err) { 35 | logger?.error("Oh dear, something went wrong. I'm terribly sorry.", err) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/uninstall.js: -------------------------------------------------------------------------------- 1 | 2 | import chalk from 'chalk' 3 | import Project from '../Project.js' 4 | import Target from '../Target.js' 5 | import { eachOfLimitProgress } from '../../util/promises.js' 6 | import { createPromptTask } from '../../util/createPromptTask.js' 7 | import { errorPrinter, packageNamePrinter } from './print.js' 8 | import { intersection } from 'lodash-es' 9 | import path from 'path' 10 | 11 | export default async function uninstall ({ 12 | plugins, 13 | isInteractive = true, 14 | cwd = process.cwd(), 15 | logger = null 16 | }) { 17 | cwd = path.resolve(process.cwd(), cwd) 18 | const project = new Project({ cwd, logger }) 19 | project.tryThrowInvalidPath() 20 | 21 | logger?.log(chalk.cyan('uninstalling adapt dependencies...')) 22 | 23 | const targets = await getUninstallTargets({ logger, project, plugins, isInteractive }) 24 | if (!targets?.length) return targets 25 | 26 | await loadPluginData({ logger, targets }) 27 | await eachOfLimitProgress( 28 | targets.filter(target => target.isToBeUninstalled), 29 | target => target.uninstall(), 30 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Uninstalling plugins ${percentage}% complete`) 31 | ) 32 | logger?.log(`${chalk.bold.cyan('')} Uninstalling plugins 100% complete`) 33 | const installedDependencies = await project.getInstalledDependencies() 34 | await updateManifest({ project, targets, installedDependencies, isInteractive }) 35 | await summariseUninstallation({ logger, targets }) 36 | return targets 37 | } 38 | 39 | /** 40 | * @param {Object} options 41 | * @param {Project} options.project 42 | * @param {[Target]} options.targets 43 | */ 44 | async function getUninstallTargets ({ logger, project, plugins, isInteractive }) { 45 | if (typeof plugins === 'string') plugins = [plugins] 46 | /** whether adapt.json is being used to compile the list of targets to install */ 47 | const isEmpty = !plugins?.length 48 | if (isEmpty && isInteractive) { 49 | const shouldContinue = await createPromptTask({ 50 | message: chalk.reset('This command will attempt to uninstall all installed plugins. Do you wish to continue?'), 51 | type: 'confirm' 52 | }) 53 | if (!shouldContinue) return 54 | } 55 | 56 | /** a list of plugin name/version pairs */ 57 | const itinerary = isEmpty 58 | ? await project.getInstalledDependencies() 59 | : plugins.reduce((itinerary, arg) => { 60 | const [name, version = '*'] = arg.split(/[#@]/) 61 | // Duplicates are removed by assigning to object properties 62 | itinerary[name] = version 63 | return itinerary 64 | }, {}) 65 | const pluginNames = Object.entries(itinerary).map(([name, version]) => `${name}@${version}`) 66 | 67 | /** @type {[Target]} */ 68 | const targets = pluginNames 69 | ? pluginNames.map(nameVersion => { 70 | const [name] = nameVersion.split(/[#@]/) 71 | return new Target({ name, project, logger }) 72 | }) 73 | : await project.getUninstallTargets() 74 | return targets 75 | } 76 | 77 | /** 78 | * @param {Object} options 79 | * @param {Project} options.project 80 | * @param {[Target]} options.targets 81 | */ 82 | async function loadPluginData ({ logger, targets }) { 83 | await eachOfLimitProgress( 84 | targets, 85 | target => target.fetchProjectInfo(), 86 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Getting plugin info ${percentage}% complete`) 87 | ) 88 | logger?.log(`${chalk.bold.cyan('')} Getting plugin info 100% complete`) 89 | await eachOfLimitProgress( 90 | targets, 91 | target => target.markUninstallable(), 92 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Marking uninstallable ${percentage}% complete`) 93 | ) 94 | logger?.log(`${chalk.bold.cyan('')} Marking uninstallable 100% complete`) 95 | } 96 | 97 | /** 98 | * @param {Object} options 99 | * @param {Project} options.project 100 | * @param {[Target]} options.targets 101 | * @returns 102 | */ 103 | async function updateManifest ({ project, targets, installedDependencies, isInteractive }) { 104 | if (targets.filter(target => target.isToBeUninstalled).length === 0) return 105 | if (intersection(Object.keys(installedDependencies), targets.map(target => target.packageName)).length) return 106 | if (isInteractive) { 107 | const shouldUpdate = await createPromptTask({ 108 | message: chalk.white('Update the manifest (adapt.json)?'), 109 | type: 'confirm', 110 | default: true 111 | }) 112 | if (!shouldUpdate) return 113 | } 114 | targets.forEach(target => target.isToBeUninstalled && project.remove(target)) 115 | } 116 | 117 | /** 118 | * @param {Object} options 119 | * @param {[Target]} options.targets 120 | */ 121 | function summariseUninstallation ({ logger, targets }) { 122 | const uninstallSucceeded = targets.filter(target => target.isUninstallSuccessful) 123 | const uninstallSkipped = targets.filter(target => !target.isToBeUninstalled || target.isSkipped) 124 | const uninstallErrored = targets.filter(target => target.isUninstallFailure) 125 | const missing = targets.filter(target => target.isMissing) 126 | const noneUninstalled = (uninstallSucceeded.length === 0) 127 | const allUninstalledSuccessfully = (uninstallErrored.length === 0 && missing.length === 0) 128 | const someUninstalledSuccessfully = (!noneUninstalled && !allUninstalledSuccessfully) 129 | summarise(logger, uninstallSkipped, packageNamePrinter, 'The following plugins were skipped:') 130 | summarise(logger, missing, packageNamePrinter, 'There was a problem locating the following plugins:') 131 | summarise(logger, uninstallErrored, errorPrinter, 'The following plugins could not be uninstalled:') 132 | if (noneUninstalled) logger?.log(chalk.cyanBright('None of the requested plugins could be uninstalled')) 133 | else if (allUninstalledSuccessfully) summarise(logger, uninstallSucceeded, packageNamePrinter, 'All requested plugins were successfully uninstalled. Summary of uninstallation:') 134 | else if (someUninstalledSuccessfully) summarise(logger, uninstallSucceeded, packageNamePrinter, 'The following plugins were successfully uninstalled:') 135 | } 136 | 137 | function summarise (logger, list, iterator, header) { 138 | if (!list || !iterator || list.length === 0) return 139 | logger?.log(chalk.cyanBright(header)) 140 | list.forEach(item => iterator(item, logger)) 141 | } 142 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/unregister.js: -------------------------------------------------------------------------------- 1 | import getBowerRegistryConfig from '../getBowerRegistryConfig.js' 2 | import authenticate from './autenticate.js' 3 | import fs from 'fs-extra' 4 | import path from 'path' 5 | import chalk from 'chalk' 6 | import inquirer from 'inquirer' 7 | import { readValidateJSON } from '../../util/JSONReadValidate.js' 8 | import Plugin from '../Plugin.js' 9 | import fetch from 'node-fetch' 10 | 11 | export default async function unregister ({ 12 | logger, 13 | cwd = process.cwd(), 14 | pluginName 15 | } = {}) { 16 | cwd = path.resolve(process.cwd(), cwd) 17 | const BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd }) 18 | logger?.warn('Using registry at', BOWER_REGISTRY_CONFIG.register) 19 | try { 20 | const bowerJSONPath = path.join(cwd, 'bower.json') 21 | const hasBowerJSON = fs.existsSync(bowerJSONPath) 22 | const bowerJSON = hasBowerJSON ? await readValidateJSON(bowerJSONPath) : {} 23 | if (pluginName) bowerJSON.name = pluginName 24 | const props = await confirm(bowerJSON) 25 | pluginName = props.pluginName 26 | const repository = props.repository 27 | const { username, token, type } = await authenticate({ repository, pluginName }) 28 | logger?.log(`${username} authenticated as ${type}`) 29 | await finalConfirm() 30 | await unregisterInBowerRepo({ pluginName, username, token, BOWER_REGISTRY_CONFIG }) 31 | logger?.log(chalk.green('The plugin was successfully unregistered.')) 32 | } catch (err) { 33 | logger?.error(err) 34 | logger?.log('The plugin was not unregistered.') 35 | } 36 | } 37 | 38 | async function confirm (properties) { 39 | const plugin = new Plugin({ name: properties.name }) 40 | const schema = [ 41 | { 42 | name: 'pluginName', 43 | message: chalk.cyan('name'), 44 | validate: v => { 45 | return /^adapt-[\w|-]+?$/.test(v) || 46 | 'Name must prefixed with \'adapt\' and each word separated with a hyphen(-)' 47 | }, 48 | type: 'input', 49 | default: plugin.toString() || 'not specified' 50 | }, 51 | { 52 | name: 'repository', 53 | message: chalk.cyan('repository URL'), 54 | validate: v => { 55 | return /https:\/\/([\w.@:/\-~]+)(\.git)(\/)?/.test(v) || 56 | 'Please provide a repository URL of the form https://.git' 57 | }, 58 | type: 'input', 59 | default: properties.repository ? properties.repository.url : undefined 60 | } 61 | ] 62 | return await inquirer.prompt(schema) 63 | } 64 | 65 | async function finalConfirm () { 66 | const schema = [ 67 | { 68 | name: 'ready', 69 | message: chalk.cyan('Confirm Unregister now?'), 70 | type: 'confirm', 71 | default: true 72 | } 73 | ] 74 | const confirmation = await inquirer.prompt(schema) 75 | if (!confirmation.ready) throw new Error('Aborted. Nothing has been unregistered.') 76 | } 77 | 78 | async function unregisterInBowerRepo ({ 79 | pluginName, 80 | username, 81 | token, 82 | BOWER_REGISTRY_CONFIG 83 | }) { 84 | const uri = `${BOWER_REGISTRY_CONFIG.register}packages/${username}/${pluginName}?access_token=${token}` 85 | const response = await fetch(uri, { 86 | method: 'DELETE', 87 | headers: { 'User-Agent': 'adapt-cli' }, 88 | followRedirect: false 89 | }) 90 | if (response.status !== 204) throw new Error(`The server responded with ${response.status}`) 91 | } 92 | -------------------------------------------------------------------------------- /lib/integration/PluginManagement/update.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { eachOfSeries } from 'async' 3 | import path from 'path' 4 | import Project from '../Project.js' 5 | import { createPromptTask } from '../../util/createPromptTask.js' 6 | import { errorPrinter, packageNamePrinter, existingVersionPrinter } from './print.js' 7 | import { eachOfLimitProgress, eachOfSeriesProgress } from '../../util/promises.js' 8 | /** @typedef {import("../Target.js").default} Target */ 9 | 10 | export default async function update ({ 11 | plugins, 12 | // whether to summarise installed plugins without modifying anything 13 | isDryRun = false, 14 | isInteractive = true, 15 | cwd = process.cwd(), 16 | logger = null 17 | }) { 18 | cwd = path.resolve(process.cwd(), cwd) 19 | const project = new Project({ cwd, logger }) 20 | project.tryThrowInvalidPath() 21 | 22 | logger?.log(chalk.cyan('update adapt dependencies...')) 23 | 24 | const targets = await getUpdateTargets({ logger, project, plugins, isDryRun, isInteractive }) 25 | if (!targets?.length) return targets 26 | 27 | await loadPluginData({ logger, project, targets }) 28 | await conflictResolution({ logger, targets, isInteractive }) 29 | if (isDryRun) { 30 | await summariseDryRun({ logger, targets }) 31 | return targets 32 | } 33 | const updateTargetsToBeUpdated = targets.filter(target => target.isToBeInstalled) 34 | if (updateTargetsToBeUpdated.length) { 35 | await eachOfSeriesProgress( 36 | updateTargetsToBeUpdated, 37 | target => target.update(), 38 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Updating plugins ${percentage}% complete`) 39 | ) 40 | logger?.log(`${chalk.bold.cyan('')} Updating plugins 100% complete`) 41 | } 42 | await summariseUpdates({ logger, targets }) 43 | return targets 44 | } 45 | 46 | /** 47 | * @param {Object} options 48 | * @param {Project} options.project 49 | * @param {[string]} options.plugins 50 | */ 51 | async function getUpdateTargets ({ project, plugins, isDryRun, isInteractive }) { 52 | if (typeof plugins === 'string') plugins = [plugins] 53 | if (!plugins) plugins = [] 54 | const allowedTypes = ['all', 'components', 'extensions', 'menu', 'theme'] 55 | const selectedTypes = [...new Set(plugins.filter(type => allowedTypes.includes(type)))] 56 | const isEmpty = (!plugins.length) 57 | const isAll = (isDryRun || isEmpty || selectedTypes.includes('all')) 58 | const pluginNames = plugins 59 | // remove types 60 | .filter(arg => !allowedTypes.includes(arg)) 61 | // split name/version 62 | .map(arg => { 63 | const [name, version = '*'] = arg.split(/[#@]/) 64 | return [name, version] 65 | }) 66 | // make sure last applies 67 | .reverse() 68 | 69 | /** @type {[Target]} */ 70 | let targets = await project.getUpdateTargets() 71 | for (const target of targets) { 72 | await target.fetchProjectInfo() 73 | } 74 | if (!isDryRun && isEmpty && isInteractive) { 75 | const shouldContinue = await createPromptTask({ 76 | message: chalk.reset('This command will attempt to update all installed plugins. Do you wish to continue?'), 77 | type: 'confirm' 78 | }) 79 | if (!shouldContinue) return 80 | } 81 | if (!isAll) { 82 | const filtered = {} 83 | for (const target of targets) { 84 | const typeFolder = await target.getTypeFolder() 85 | if (!typeFolder) continue 86 | const lastSpecifiedPluginName = pluginNames.find(([name]) => target.isNameMatch(name)) 87 | const isPluginNameIncluded = Boolean(lastSpecifiedPluginName) 88 | const isTypeIncluded = selectedTypes.includes(typeFolder) 89 | if (!isPluginNameIncluded && !isTypeIncluded) continue 90 | // Resolve duplicates 91 | filtered[target.packageName] = target 92 | // Set requested version from name 93 | target.requestedVersion = lastSpecifiedPluginName[1] || '*' 94 | } 95 | targets = Object.values(filtered) 96 | } 97 | return targets 98 | } 99 | 100 | /** 101 | * @param {Object} options 102 | * @param {Project} options.project 103 | * @param {[Target]} options.targets 104 | */ 105 | async function loadPluginData ({ logger, project, targets }) { 106 | const frameworkVersion = project.version 107 | await eachOfLimitProgress( 108 | targets, 109 | target => target.fetchSourceInfo(), 110 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Getting plugin info ${percentage}% complete`) 111 | ) 112 | logger?.log(`${chalk.bold.cyan('')} Getting plugin info 100% complete`) 113 | await eachOfLimitProgress( 114 | targets, 115 | target => target.findCompatibleVersion(frameworkVersion), 116 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Finding compatible source versions ${percentage}% complete`) 117 | ) 118 | logger?.log(`${chalk.bold.cyan('')} Finding compatible source versions 100% complete`) 119 | await eachOfLimitProgress( 120 | targets, 121 | target => target.markUpdateable(), 122 | percentage => logger?.logProgress?.(`${chalk.bold.cyan('')} Marking updateable ${percentage}% complete`) 123 | ) 124 | logger?.log(`${chalk.bold.cyan('')} Marking updateable 100% complete`) 125 | } 126 | 127 | /** 128 | * @param {Object} options 129 | * @param {[Target]} options.targets 130 | */ 131 | async function conflictResolution ({ logger, targets, isInteractive }) { 132 | /** @param {Target} target */ 133 | async function checkVersion (target) { 134 | const canApplyRequested = target.hasValidRequestVersion && 135 | (target.hasFrameworkCompatibleVersion 136 | ? (target.latestCompatibleSourceVersion !== target.matchedVersion) 137 | : (target.latestSourceVersion !== target.matchedVersion)) 138 | if (!isInteractive) { 139 | if (target.canApplyRequested) return target.markRequestedForInstallation() 140 | return target.markSkipped() 141 | } 142 | const choices = [ 143 | canApplyRequested && { name: `requested version [${target.matchedVersion}]`, value: 'r' }, 144 | target.hasFrameworkCompatibleVersion 145 | ? { name: `latest compatible version [${target.latestCompatibleSourceVersion}]`, value: 'l' } 146 | : { name: `latest version [${target.latestSourceVersion}]`, value: 'l' }, 147 | { name: 'skip', value: 's' } 148 | ].filter(Boolean) 149 | const result = await createPromptTask({ message: chalk.reset(target.packageName), choices, type: 'list', default: 's' }) 150 | const installRequested = (result === 'r') 151 | const installLatest = result === 'l' 152 | const skipped = result === 's' 153 | if (installRequested) target.markRequestedForInstallation() 154 | if (installLatest && target.hasFrameworkCompatibleVersion) target.markLatestCompatibleForInstallation() 155 | if (installLatest && !target.hasFrameworkCompatibleVersion) target.markLatestForInstallation() 156 | if (skipped) target.markSkipped() 157 | } 158 | function add (list, header, prompt) { 159 | if (!list.length) return 160 | return { 161 | header: chalk.bold.cyan(' ') + header, 162 | list, 163 | prompt 164 | } 165 | } 166 | const preFilteredPlugins = targets.filter(target => !target.isLocalSource) 167 | const allQuestions = [ 168 | add(preFilteredPlugins.filter(target => !target.hasFrameworkCompatibleVersion && target.latestSourceVersion), 'There is no compatible version of the following plugins:', checkVersion), 169 | add(preFilteredPlugins.filter(target => target.hasFrameworkCompatibleVersion && !target.hasValidRequestVersion), 'The version requested is invalid, there are newer compatible versions of the following plugins:', checkVersion), 170 | add(preFilteredPlugins.filter(target => target.hasFrameworkCompatibleVersion && target.hasValidRequestVersion && !target.isApplyLatestCompatibleVersion), 'There are newer compatible versions of the following plugins:', checkVersion) 171 | ].filter(Boolean) 172 | if (allQuestions.length === 0) return 173 | for (const question of allQuestions) { 174 | logger?.log(question.header) 175 | await eachOfSeries(question.list, question.prompt) 176 | } 177 | } 178 | 179 | /** 180 | * @param {Object} options 181 | * @param {[Target]} options.targets 182 | */ 183 | function summariseDryRun ({ logger, targets }) { 184 | const preFilteredPlugins = targets.filter(target => !target.isLocalSource) 185 | const localSources = targets.filter(target => target.isLocalSource) 186 | const toBeInstalled = preFilteredPlugins.filter(target => target.isToBeUpdated) 187 | const toBeSkipped = preFilteredPlugins.filter(target => !target.isToBeUpdated || target.isSkipped) 188 | const missing = preFilteredPlugins.filter(target => target.isMissing) 189 | summarise(logger, localSources, packageNamePrinter, 'The following plugins were installed from a local source and cannot be updated:') 190 | summarise(logger, toBeSkipped, packageNamePrinter, 'The following plugins will be skipped:') 191 | summarise(logger, missing, packageNamePrinter, 'There was a problem locating the following plugins:') 192 | summarise(logger, toBeInstalled, existingVersionPrinter, 'The following plugins will be updated:') 193 | } 194 | 195 | /** 196 | * @param {Object} options 197 | * @param {[Target]} options.targets 198 | */ 199 | function summariseUpdates ({ logger, targets }) { 200 | const preFilteredPlugins = targets.filter(target => !target.isLocalSource) 201 | const localSources = targets.filter(target => target.isLocalSource) 202 | const installSucceeded = preFilteredPlugins.filter(target => target.isUpdateSuccessful) 203 | const installSkipped = preFilteredPlugins.filter(target => target.isSkipped) 204 | const noUpdateAvailable = preFilteredPlugins.filter(target => !target.isToBeUpdated && !target.isSkipped) 205 | const installErrored = preFilteredPlugins.filter(target => target.isUpdateFailure) 206 | const missing = preFilteredPlugins.filter(target => target.isMissing) 207 | const noneInstalled = (installSucceeded.length === 0) 208 | const allInstalledSuccessfully = (installErrored.length === 0 && missing.length === 0) 209 | const someInstalledSuccessfully = (!noneInstalled && !allInstalledSuccessfully) 210 | summarise(logger, localSources, existingVersionPrinter, 'The following plugins were installed from a local source and cannot be updated:') 211 | summarise(logger, installSkipped, existingVersionPrinter, 'The following plugins were skipped:') 212 | summarise(logger, noUpdateAvailable, existingVersionPrinter, 'The following plugins had no update available:') 213 | summarise(logger, missing, packageNamePrinter, 'There was a problem locating the following plugins:') 214 | summarise(logger, installErrored, errorPrinter, 'The following plugins could not be updated:') 215 | if (noneInstalled) logger?.log(chalk.cyanBright('None of the requested plugins could be updated')) 216 | else if (allInstalledSuccessfully) summarise(logger, installSucceeded, existingVersionPrinter, 'All requested plugins were successfully updated. Summary of installation:') 217 | else if (someInstalledSuccessfully) summarise(logger, installSucceeded, existingVersionPrinter, 'The following plugins were successfully updated:') 218 | } 219 | 220 | function summarise (logger, list, iterator, header) { 221 | if (!list || !iterator || list.length === 0) return 222 | logger?.log(chalk.cyanBright(header)) 223 | list.forEach(item => iterator(item, logger)) 224 | } 225 | -------------------------------------------------------------------------------- /lib/integration/Project.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'path' 3 | import globs from 'globs' 4 | import { readValidateJSON, readValidateJSONSync } from '../util/JSONReadValidate.js' 5 | import Plugin from './Plugin.js' 6 | import Target from './Target.js' 7 | export const MANIFEST_FILENAME = 'adapt.json' 8 | export const FRAMEWORK_FILENAME = 'package.json' 9 | 10 | /** 11 | * A representation of the target Adapt Framework project 12 | */ 13 | export default class Project { 14 | constructor ({ 15 | cwd = process.cwd(), 16 | logger 17 | } = {}) { 18 | this.logger = logger 19 | this.cwd = cwd 20 | this.manifestFilePath = path.resolve(this.cwd, MANIFEST_FILENAME) 21 | this.frameworkPackagePath = path.resolve(this.cwd, FRAMEWORK_FILENAME) 22 | } 23 | 24 | /** @returns {boolean} */ 25 | get isAdaptDirectory () { 26 | try { 27 | // are we inside an existing adapt_framework project. 28 | const packageJSON = fs.readJSONSync(this.cwd + '/package.json') 29 | return (packageJSON.name === 'adapt_framework') 30 | } catch (err) { 31 | // don't worry, we're not inside a framework directory. 32 | } 33 | return false 34 | } 35 | 36 | /** @returns {boolean} */ 37 | get containsManifestFile () { 38 | if (!this.isAdaptDirectory) return false 39 | return fs.existsSync(this.manifestFilePath) 40 | } 41 | 42 | /** @returns {string} */ 43 | get version () { 44 | try { 45 | return readValidateJSONSync(this.frameworkPackagePath).version 46 | } catch (ex) { 47 | return null 48 | } 49 | } 50 | 51 | tryThrowInvalidPath () { 52 | if (this.containsManifestFile) return 53 | this.logger?.error('Fatal error: please run above commands in adapt course directory.') 54 | throw new Error('Fatal error: please run above commands in adapt course directory.') 55 | } 56 | 57 | /** @returns {[Target]} */ 58 | async getInstallTargets () { 59 | return Object.entries(await this.getManifestDependencies()).map(([name, requestedVersion]) => new Target({ name, requestedVersion, project: this, logger: this.logger })) 60 | } 61 | 62 | /** @returns {[string]} */ 63 | async getManifestDependencies () { 64 | const manifest = await readValidateJSON(this.manifestFilePath) 65 | return manifest.dependencies 66 | } 67 | 68 | /** @returns {[Plugin]} */ 69 | async getInstalledPlugins () { 70 | return Object.entries(await this.getInstalledDependencies()).map(([name]) => new Plugin({ name, project: this, logger: this.logger })) 71 | } 72 | 73 | /** @returns {[Target]} */ 74 | async getUninstallTargets () { 75 | return Object.entries(await this.getInstalledDependencies()).map(([name]) => new Target({ name, project: this, logger: this.logger })) 76 | } 77 | 78 | /** @returns {[Target]} */ 79 | async getUpdateTargets () { 80 | return Object.entries(await this.getInstalledDependencies()).map(([name]) => new Target({ name, project: this, logger: this.logger })) 81 | } 82 | 83 | async getInstalledDependencies () { 84 | const getDependencyBowerJSONs = async () => { 85 | const glob = `${this.cwd.replace(/\\/g, '/')}/src/**/bower.json` 86 | const bowerJSONPaths = await new Promise((resolve, reject) => { 87 | globs(glob, (err, matches) => { 88 | if (err) return reject(err) 89 | resolve(matches) 90 | }) 91 | }) 92 | const bowerJSONs = [] 93 | for (const bowerJSONPath of bowerJSONPaths) { 94 | try { 95 | bowerJSONs.push(await fs.readJSON(bowerJSONPath)) 96 | } catch (err) {} 97 | } 98 | return bowerJSONs 99 | } 100 | const dependencies = (await getDependencyBowerJSONs()) 101 | .filter(bowerJSON => bowerJSON?.name && bowerJSON?.version) 102 | .reduce((dependencies, bowerJSON) => { 103 | dependencies[bowerJSON.name] = bowerJSON.version 104 | return dependencies 105 | }, {}) 106 | return dependencies 107 | } 108 | 109 | async getSchemaPaths () { 110 | const glob = `${this.cwd.replace(/\\/g, '/')}/src/**/*.schema.json` 111 | const bowerJSONPaths = await new Promise((resolve, reject) => { 112 | globs(glob, (err, matches) => { 113 | if (err) return reject(err) 114 | resolve(matches) 115 | }) 116 | }) 117 | return bowerJSONPaths 118 | } 119 | 120 | /** 121 | * @param {Plugin} plugin 122 | */ 123 | add (plugin) { 124 | if (typeof Plugin !== 'object' && !(plugin instanceof Plugin)) { 125 | plugin = new Plugin({ name: plugin }) 126 | } 127 | let manifest = { version: '0.0.0', dependencies: {} } 128 | if (this.containsManifestFile) { 129 | manifest = readValidateJSONSync(this.manifestFilePath) 130 | } 131 | manifest.dependencies[plugin.packageName] = plugin.sourcePath || plugin.requestedVersion || plugin.version 132 | fs.writeJSONSync(this.manifestFilePath, manifest, { spaces: 2, replacer: null }) 133 | } 134 | 135 | /** 136 | * @param {Plugin} plugin 137 | */ 138 | remove (plugin) { 139 | if (typeof Plugin !== 'object' && !(plugin instanceof Plugin)) { 140 | plugin = new Plugin({ name: plugin }) 141 | } 142 | const manifest = readValidateJSONSync(this.manifestFilePath) 143 | delete manifest.dependencies[plugin.packageName] 144 | fs.writeJSONSync(this.manifestFilePath, manifest, { spaces: 2, replacer: null }) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /lib/integration/Target.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import bower from 'bower' 3 | import { exec } from 'child_process' 4 | import semver from 'semver' 5 | import fs from 'fs-extra' 6 | import path from 'path' 7 | import { ADAPT_ALLOW_PRERELEASE } from '../util/constants.js' 8 | import Plugin from './Plugin.js' 9 | /** @typedef {import("./Project.js").default} Project */ 10 | const semverOptions = { includePrerelease: ADAPT_ALLOW_PRERELEASE } 11 | 12 | export default class Target extends Plugin { 13 | /** 14 | * @param {Object} options 15 | * @param {string} options.name 16 | * @param {string} options.requestedVersion 17 | * @param {boolean} options.isContrib 18 | * @param {boolean} options.isCompatibleEnabled whether to target the latest compatible version for all plugin installations (overrides requestedVersion) 19 | * @param {Project} options.project 20 | * @param {string} options.cwd 21 | * @param {Object} options.logger 22 | */ 23 | constructor ({ 24 | name, 25 | requestedVersion = '*', 26 | isContrib = false, 27 | isCompatibleEnabled = false, 28 | project, 29 | cwd = (project?.cwd ?? process.cwd()), 30 | logger 31 | } = {}) { 32 | super({ 33 | name, 34 | requestedVersion, 35 | isContrib, 36 | isCompatibleEnabled, 37 | project, 38 | cwd, 39 | logger 40 | }) 41 | // The version to be installed 42 | this.versionToApply = null 43 | // Keep the project version preupdate 44 | this.preUpdateProjectVersion = null 45 | // Was explicitly skipped by the user 46 | this._isSkipped = null 47 | // Marks that this target was uninstalled, true, false and null 48 | this._wasUninstalled = null 49 | } 50 | 51 | /** 52 | * Was explicitly skipped by the user 53 | * @returns {boolean} 54 | */ 55 | get isSkipped () { 56 | return Boolean(this._isSkipped) 57 | } 58 | 59 | get isNoApply () { 60 | return (this.isPresent && this.versionToApply === null) 61 | } 62 | 63 | /** @returns {boolean} */ 64 | get hasProposedVersion () { 65 | return (this.matchedVersion !== null) 66 | } 67 | 68 | /** @returns {boolean} */ 69 | get isToBeInstalled () { 70 | return (this.versionToApply !== null && !this._isSkipped) 71 | } 72 | 73 | /** @returns {boolean} */ 74 | get isInstallSuccessful () { 75 | return (this.isToBeInstalled && this.isUpToDate) 76 | } 77 | 78 | /** @returns {boolean} */ 79 | get isInstallFailure () { 80 | return (this.isToBeInstalled && !this.isUpToDate) 81 | } 82 | 83 | /** @returns {boolean} */ 84 | get isToBeUninstalled () { 85 | return (this.versionToApply !== null && !this._isSkipped) 86 | } 87 | 88 | /** @returns {boolean} */ 89 | get isUninstallSuccessful () { 90 | return (this.isToBeUninstalled && this._wasUninstalled) 91 | } 92 | 93 | /** @returns {boolean} */ 94 | get isUninstallFailure () { 95 | return (this.isToBeUninstalled && !this._wasUninstalled) 96 | } 97 | 98 | /** @returns {boolean} */ 99 | get isToBeUpdated () { 100 | return (this.versionToApply !== null && !this._isSkipped) 101 | } 102 | 103 | /** @returns {boolean} */ 104 | get isUpdateSuccessful () { 105 | return (this.isToBeUpdated && this.isUpToDate) 106 | } 107 | 108 | /** @returns {boolean} */ 109 | get isUpdateFailure () { 110 | return (this.isToBeUpdated && !this.isUpToDate) 111 | } 112 | 113 | /** @returns {boolean} */ 114 | get isApplyLatestCompatibleVersion () { 115 | return (this.hasFrameworkCompatibleVersion && 116 | semver.satisfies(this.latestCompatibleSourceVersion, this.matchedVersion, semverOptions)) 117 | } 118 | 119 | markSkipped () { 120 | this._isSkipped = true 121 | } 122 | 123 | markInstallable () { 124 | if (!this.isApplyLatestCompatibleVersion && !(this.isLocalSource && this.latestSourceVersion)) return 125 | this.versionToApply = this.matchedVersion 126 | } 127 | 128 | markUpdateable () { 129 | if (!this.isPresent || this.isSkipped || !this.canBeUpdated) return 130 | if (this.projectVersion === this.matchedVersion) return 131 | this.versionToApply = this.matchedVersion 132 | } 133 | 134 | markMasterForInstallation () { 135 | this.versionToApply = 'master' 136 | } 137 | 138 | markRequestedForInstallation () { 139 | this.matchedVersion = this.matchedVersion ?? semver.maxSatisfying(this.sourceVersions, this.requestedVersion, semverOptions) 140 | if (this.projectVersion === this.matchedVersion) return 141 | this.versionToApply = this.matchedVersion 142 | } 143 | 144 | markLatestCompatibleForInstallation () { 145 | if (this.projectVersion === this.latestCompatibleSourceVersion) return 146 | this.versionToApply = this.latestCompatibleSourceVersion 147 | } 148 | 149 | markLatestForInstallation () { 150 | if (this.projectVersion === this.latestSourceVersion) return 151 | this.versionToApply = this.latestSourceVersion 152 | } 153 | 154 | markUninstallable () { 155 | if (!this.isPresent) return 156 | this.versionToApply = this.projectVersion 157 | } 158 | 159 | async install ({ clone = false } = {}) { 160 | const logger = this.logger 161 | const pluginTypeFolder = await this.getTypeFolder() 162 | if (this.isLocalSource) { 163 | await fs.ensureDir(path.resolve(this.cwd, 'src', pluginTypeFolder)) 164 | const pluginPath = path.resolve(this.cwd, 'src', pluginTypeFolder, this.packageName) 165 | await fs.rm(pluginPath, { recursive: true, force: true }) 166 | await fs.copy(this.sourcePath, pluginPath, { recursive: true }) 167 | const bowerJSON = await fs.readJSON(path.join(pluginPath, 'bower.json')) 168 | bowerJSON._source = this.sourcePath 169 | bowerJSON._wasInstalledFromPath = true 170 | await fs.writeJSON(path.join(pluginPath, '.bower.json'), bowerJSON, { spaces: 2, replacer: null }) 171 | this._projectInfo = null 172 | await this.fetchProjectInfo() 173 | return 174 | } 175 | if (clone) { 176 | // clone install 177 | const repoDetails = await this.getRepositoryUrl() 178 | if (!repoDetails) throw new Error('Error: Plugin repository url could not be found.') 179 | await fs.ensureDir(path.resolve(this.cwd, 'src', pluginTypeFolder)) 180 | const pluginPath = path.resolve(this.cwd, 'src', pluginTypeFolder, this.packageName) 181 | await fs.rm(pluginPath, { recursive: true, force: true }) 182 | const url = repoDetails.url.replace(/^git:\/\//, 'https://') 183 | try { 184 | const exitCode = await new Promise((resolve, reject) => { 185 | try { 186 | exec(`git clone ${url} "${pluginPath}"`, resolve) 187 | } catch (err) { 188 | reject(err) 189 | } 190 | }) 191 | if (exitCode) throw new Error(`The plugin was found but failed to download and install. Exit code ${exitCode}`) 192 | } catch (error) { 193 | throw new Error(`The plugin was found but failed to download and install. Error ${error}`) 194 | } 195 | if (this.versionToApply !== '*') { 196 | try { 197 | await new Promise(resolve => exec(`git -C "${pluginPath}" checkout v${this.versionToApply}`, resolve)) 198 | logger?.log(chalk.green(this.packageName), `is on branch "${this.versionToApply}".`) 199 | } catch (err) { 200 | throw new Error(chalk.yellow(this.packageName), `could not checkout branch "${this.versionToApply}".`) 201 | } 202 | } 203 | this._projectInfo = null 204 | await this.fetchProjectInfo() 205 | return 206 | } 207 | // bower install 208 | const outputPath = path.join(this.cwd, 'src', pluginTypeFolder) 209 | const pluginPath = path.join(outputPath, this.packageName) 210 | try { 211 | await fs.rm(pluginPath, { recursive: true, force: true }) 212 | } catch (err) { 213 | throw new Error(`There was a problem writing to the target directory ${pluginPath}`) 214 | } 215 | await new Promise((resolve, reject) => { 216 | const pluginNameVersion = `${this.packageName}@${this.versionToApply}` 217 | bower.commands.install([pluginNameVersion], null, { 218 | directory: outputPath, 219 | cwd: this.cwd, 220 | registry: this.BOWER_REGISTRY_CONFIG 221 | }) 222 | .on('end', resolve) 223 | .on('error', err => { 224 | err = new Error(`Bower reported ${err}`) 225 | this._error = err 226 | reject(err) 227 | }) 228 | }) 229 | const bowerJSON = await fs.readJSON(path.join(pluginPath, 'bower.json')) 230 | bowerJSON.version = bowerJSON.version ?? this.versionToApply; 231 | await fs.writeJSON(path.join(pluginPath, '.bower.json'), bowerJSON, { spaces: 2, replacer: null }) 232 | this._projectInfo = null 233 | await this.fetchProjectInfo() 234 | } 235 | 236 | async update () { 237 | if (!this.isToBeUpdated) throw new Error() 238 | const typeFolder = await this.getTypeFolder() 239 | const outputPath = path.join(this.cwd, 'src', typeFolder) 240 | const pluginPath = path.join(outputPath, this.name) 241 | try { 242 | await fs.rm(pluginPath, { recursive: true, force: true }) 243 | } catch (err) { 244 | throw new Error(`There was a problem writing to the target directory ${pluginPath}`) 245 | } 246 | await new Promise((resolve, reject) => { 247 | const pluginNameVersion = `${this.packageName}@${this.matchedVersion}` 248 | bower.commands.install([pluginNameVersion], null, { 249 | directory: outputPath, 250 | cwd: this.cwd, 251 | registry: this.BOWER_REGISTRY_CONFIG 252 | }) 253 | .on('end', resolve) 254 | .on('error', err => { 255 | err = new Error(`Bower reported ${err}`) 256 | this._error = err 257 | reject(err) 258 | }) 259 | }) 260 | this.preUpdateProjectVersion = this.projectVersion 261 | this._projectInfo = null 262 | await this.fetchProjectInfo() 263 | } 264 | 265 | async uninstall () { 266 | try { 267 | if (!this.isToBeUninstalled) throw new Error() 268 | await fs.rm(this.projectPath, { recursive: true, force: true }) 269 | this._wasUninstalled = true 270 | } catch (err) { 271 | this._wasUninstalled = false 272 | throw new Error(`There was a problem writing to the target directory ${this.projectPath}`) 273 | } 274 | } 275 | 276 | isNameMatch (name) { 277 | const tester = new RegExp(`${name}$`, 'i') 278 | return tester.test(this.packageName) 279 | } 280 | 281 | /** 282 | * Read plugin data from pluginPath 283 | * @param {Object} options 284 | * @param {string} options.pluginPath Path to source directory 285 | * @param {string} [options.projectPath=process.cwd()] Optional path to potential installation project 286 | * @returns {Target} 287 | */ 288 | static async fromPath ({ 289 | pluginPath, 290 | projectPath = process.cwd() 291 | }) { 292 | const target = new Target({ 293 | name: pluginPath, 294 | cwd: projectPath 295 | }) 296 | await target.fetchLocalSourceInfo() 297 | return target 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /lib/integration/getBowerRegistryConfig.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import { findUpSync } from 'find-up' 3 | 4 | export default function getBowerRegistryConfig ({ cwd = process.cwd() } = {}) { 5 | function getConfig () { 6 | if (process.env.ADAPT_REGISTRY) { 7 | return process.env.ADAPT_REGISTRY 8 | } 9 | const configPath = findUpSync('.bowerrc', { cwd }) 10 | if (configPath) { 11 | // a manifest exists, load it 12 | const config = fs.readJSONSync(configPath) 13 | return config.registry 14 | } 15 | // use the default Adapt registry 16 | return 'http://adapt-bower-repository.herokuapp.com/' 17 | } 18 | // normalize to https://github.com/bower/spec/blob/master/config.md 19 | const config = getConfig() 20 | let normalized = {} 21 | switch (typeof config) { 22 | case 'string': 23 | normalized = { 24 | register: config, 25 | search: [config] 26 | } 27 | break 28 | case 'object': 29 | Object.assign(normalized, config) 30 | break 31 | } 32 | if (typeof normalized.search === 'string') normalized.search = [normalized.search].filter(Boolean) 33 | return normalized 34 | } 35 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | import readline from 'readline' 2 | import chalk from 'chalk' 3 | 4 | export default { 5 | isLoggingProgress: false, 6 | logProgress (...args) { 7 | this.isLoggingProgress = true 8 | readline.cursorTo(process.stdout, 0) 9 | this.write(args.join(' ')) 10 | }, 11 | warn (...args) { 12 | this.log(chalk.yellow(...args)) 13 | }, 14 | error (...args) { 15 | this.log(chalk.red(...args)) 16 | }, 17 | log (...args) { 18 | if (this.isLoggingProgress) { 19 | this.isLoggingProgress = false 20 | readline.cursorTo(process.stdout, 0) 21 | this.write(args.join(' ')) 22 | this.write('\n') 23 | return 24 | } 25 | console.log.apply(console, args) 26 | }, 27 | write: process.stdout.write.bind(process.stdout) 28 | } 29 | -------------------------------------------------------------------------------- /lib/util/JSONReadValidate.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import JSONLint from 'json-lint' 3 | 4 | /** 5 | * @param {string} filepath 6 | * @param {function} [done] 7 | * @returns {Promise} 8 | */ 9 | export async function readValidateJSON (filepath, done) { 10 | try { 11 | const data = await fs.readFile(filepath, 'utf8') 12 | validateJSON(data, filepath) 13 | done?.(null, JSON.parse(data)) 14 | return JSON.parse(data) 15 | } catch (err) { 16 | done?.(err.message) 17 | } 18 | } 19 | 20 | export function readValidateJSONSync (filepath) { 21 | const data = fs.readFileSync(filepath, 'utf-8') 22 | validateJSON(data, filepath) 23 | return JSON.parse(data) 24 | } 25 | 26 | function validateJSON (jsonData, filepath) { 27 | const lint = JSONLint(jsonData) 28 | if (!lint.error) return 29 | let errorMessage = 'JSON parsing error: ' + lint.error + ', line: ' + lint.line + ', character: ' + lint.character 30 | if (filepath) { 31 | errorMessage += ', file: \'' + filepath + '\'' 32 | } 33 | throw new Error(errorMessage) 34 | } 35 | -------------------------------------------------------------------------------- /lib/util/constants.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | 3 | export const ADAPT_ALLOW_PRERELEASE = process.env.ADAPT_ALLOW_PRERELEASE !== 'false' 4 | 5 | export const ADAPT_FRAMEWORK = process.env.ADAPT_FRAMEWORK || 'https://github.com/adaptlearning/adapt_framework' 6 | 7 | export const ADAPT_COMPONENT = process.env.ADAPT_COMPONENT || 'https://github.com/adaptlearning/adapt-component' 8 | 9 | export const ADAPT_QUESTION = process.env.ADAPT_QUESTION || 'https://github.com/adaptlearning/adapt-questionComponent' 10 | 11 | export const ADAPT_EXTENSION = process.env.ADAPT_EXTENSION || 'https://github.com/adaptlearning/adapt-extension' 12 | 13 | export const ADAPT_DEFAULT_USER_AGENT = process.env.ADAPT_DEFAULT_USER_AGENT || 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36' 14 | 15 | export const HOME_DIRECTORY = [ 16 | process.env.HOME, 17 | (process.env.HOMEDRIVE + process.env.HOMEPATH), 18 | process.env.USERPROFILE, 19 | '/tmp', 20 | '/temp' 21 | ].filter(fs.existsSync)[0] 22 | 23 | /** @type {string} */ 24 | export const PLUGIN_TYPES = [ 25 | 'component', 26 | 'extension', 27 | 'menu', 28 | 'theme' 29 | ] 30 | 31 | /** @type {Object} */ 32 | export const PLUGIN_TYPE_FOLDERS = { 33 | component: 'components', 34 | extension: 'extensions', 35 | menu: 'menu', 36 | theme: 'theme' 37 | } 38 | 39 | /** @type {string} */ 40 | export const PLUGIN_DEFAULT_TYPE = PLUGIN_TYPES[0] 41 | -------------------------------------------------------------------------------- /lib/util/createPromptTask.js: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer' 2 | 3 | export async function createPromptTask (question) { 4 | question = Object.assign({}, { name: 'question' }, question) 5 | const confirmation = await inquirer.prompt([question]) 6 | return confirmation.question 7 | } 8 | -------------------------------------------------------------------------------- /lib/util/download.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { v4 as uuid } from 'uuid' 3 | import fs from 'fs-extra' 4 | import path from 'path' 5 | import urljoin from 'url-join' 6 | import fetch from 'download' 7 | import { HOME_DIRECTORY } from './constants.js' 8 | import gh from 'parse-github-url' 9 | 10 | export default async function download ({ 11 | repository, 12 | branch, 13 | tmp, 14 | cwd, 15 | logger 16 | } = {}) { 17 | if (!branch && !repository) throw new Error('Repository details are required.') 18 | const repositoryName = gh(repository).name 19 | logger?.write(chalk.cyan(`downloading ${repositoryName} to ${cwd}\t`)) 20 | tmp = (tmp || path.join(HOME_DIRECTORY, '.adapt', 'tmp', uuid())) 21 | const downloadFileName = await new Promise((resolve, reject) => { 22 | let downloadFileName = '' 23 | const url = urljoin(repository, 'archive', branch + '.zip') 24 | fetch(url, tmp, { 25 | extract: true 26 | }) 27 | .on('response', response => { 28 | const disposition = response.headers['content-disposition'] 29 | if (disposition?.indexOf('attachment') === -1) return 30 | const regex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/ 31 | const matches = regex.exec(disposition) 32 | if (!matches?.[1]) return 33 | downloadFileName = matches[1].replace(/['"]/g, '') 34 | }) 35 | .on('error', reject) 36 | .then(() => resolve(downloadFileName)) 37 | }) 38 | const sourceFileName = downloadFileName 39 | ? path.parse(downloadFileName).name 40 | : `${repositoryName}-${branch}` 41 | const sourcePath = path.join(tmp, sourceFileName) 42 | await fs.copy(sourcePath, cwd) 43 | await fs.rm(tmp, { recursive: true }) 44 | logger?.log(' ', 'done!') 45 | } 46 | -------------------------------------------------------------------------------- /lib/util/errors.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ERROR_COURSE_DIR: { 3 | code: 0, 4 | message: 'Commands must be run in an Adapt project directory' 5 | }, 6 | ERROR_INCOMPATIBLE_VALID_REQUEST: { 7 | code: 1, 8 | message: 'No compatible version exists (requested version is valid)' 9 | }, 10 | ERROR_INCOMPATIBLE_BAD_REQUEST: { 11 | code: 2, 12 | message: 'No compatible version exists (requested version is invalid)' 13 | }, 14 | ERROR_INCOMPATIBLE: { 15 | code: 3, 16 | message: 'No compatible version exists' 17 | }, 18 | ERROR_COMPATIBLE_INC_REQUEST: { 19 | code: 4, 20 | message: 'Incompatible version requested (compatible version exists)' 21 | }, 22 | ERROR_COMPATIBLE_BAD_REQUEST: { 23 | code: 5, 24 | message: 'Requested version is invalid' 25 | }, 26 | ERROR_UNINSTALL: { 27 | code: 6, 28 | message: 'The plugin could not be uninstalled' 29 | }, 30 | ERROR_NOT_FOUND: { 31 | code: 7, 32 | message: 'The plugin could not be found' 33 | }, 34 | ERROR_NOTHING_TO_UPDATE: { 35 | code: 8, 36 | message: 'Could not resolve any plugins to update' 37 | }, 38 | ERROR_UPDATE_INCOMPATIBLE: { 39 | code: 9, 40 | message: 'Incompatible update requested' 41 | }, 42 | ERROR_INSTALL_ERROR: { 43 | code: 10, 44 | message: 'Unknown installation error' 45 | }, 46 | ERROR_UPDATE_ERROR: { 47 | code: 11, 48 | message: 'Unknown update error' 49 | }, 50 | ERROR_NO_RELEASES: { 51 | code: 12, 52 | message: 'No published releases' 53 | }, 54 | ERROR_NO_UPDATE: { 55 | code: 13, 56 | message: 'No update available' 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/util/extract.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import path from 'path' 3 | import decompress from 'decompress' 4 | import { HOME_DIRECTORY } from './constants.js' 5 | 6 | export default async function extract ({ 7 | sourcePath, 8 | cwd 9 | } = {}) { 10 | const rootPath = path.join(HOME_DIRECTORY, '.adapt', 'tmp', uuid()).replace(/\\/g, '/') 11 | const files = await decompress(path.join(cwd, sourcePath), rootPath, { 12 | filter: file => !file.path.endsWith('/') 13 | }) 14 | const rootDirectories = Object.keys(files.reduce((memo, file) => { memo[file.path.split(/\\|\//g)[0]] = true; return memo }, {})) 15 | let copyPath = rootPath 16 | if (rootDirectories.length === 1) { 17 | const rootDirectory = files[0].path.split(/\\|\//g)[0] 18 | copyPath = path.join(rootPath, rootDirectory) 19 | } 20 | return { 21 | rootPath, 22 | copyPath 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/util/getDirNameFromImportMeta.js: -------------------------------------------------------------------------------- 1 | export default function getDirNameFromImportMeta (importMeta) { 2 | const __dirname = (process.platform === 'win32') 3 | ? new URL('.', importMeta.url).pathname.slice(1) 4 | : new URL('.', import.meta.url).pathname 5 | return __dirname 6 | } 7 | -------------------------------------------------------------------------------- /lib/util/promises.js: -------------------------------------------------------------------------------- 1 | import { eachOfLimit, eachOfSeries } from 'async' 2 | /** 3 | * Execute all promises in parallel, call progress at each promise to allow 4 | * progress reporting 5 | * @param {Array} array 6 | * @param {function} iterator 7 | * @param {function} progress 8 | * @returns 9 | */ 10 | export async function eachOfLimitProgress (array, iterator, progress) { 11 | let currentIndex = 0 12 | progress(0) 13 | await eachOfLimit(array, 8, async item => { 14 | currentIndex++ 15 | progress(parseInt((currentIndex * 100) / array.length)) 16 | return iterator(item) 17 | }) 18 | } 19 | 20 | /** 21 | * Execute iterator against each item in the array waiting for the iterator returned 22 | * to finish before continuing, calling progress as each stage 23 | * @param {Array} array 24 | * @param {function} iterator 25 | * @param {function} progress 26 | * @returns 27 | */ 28 | export async function eachOfSeriesProgress (array, iterator, progress) { 29 | let currentIndex = 0 30 | progress(0) 31 | await eachOfSeries(array, async item => { 32 | currentIndex++ 33 | progress(parseInt((currentIndex * 100) / array.length)) 34 | return iterator(item) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adapt-cli", 3 | "version": "3.3.2", 4 | "description": "Command line tools for Adapt", 5 | "main": "./lib/api.js", 6 | "type": "module", 7 | "dependencies": { 8 | "async": "^3.2.3", 9 | "bower": "^1.8.13", 10 | "bower-endpoint-parser": "^0.2.2", 11 | "chalk": "^2.4.1", 12 | "decompress": "^4.2.1", 13 | "download": "^8.0.0", 14 | "find-up": "^6.2.0", 15 | "fs-extra": "^10.0.0", 16 | "globs": "^0.1.4", 17 | "inquirer": "^7.3.3", 18 | "json-lint": "^0.1.0", 19 | "lodash-es": "^4.17.21", 20 | "node-fetch": "^3.2.10", 21 | "parse-github-url": "^1.0.2", 22 | "semver": "^7.3.5", 23 | "speakingurl": "^14.0.1", 24 | "url-join": "^4.0.0", 25 | "uuid": "^8.3.2" 26 | }, 27 | "license": "GPL-3.0", 28 | "preferGlobal": true, 29 | "bin": { 30 | "adapt": "./bin/adapt.js" 31 | }, 32 | "devDependencies": { 33 | "eslint": "^7.31.0", 34 | "eslint-config-standard": "^16.0.3", 35 | "eslint-plugin-import": "^2.23.4", 36 | "eslint-plugin-node": "^11.1.0", 37 | "eslint-plugin-promise": "^5.1.0", 38 | "@semantic-release/commit-analyzer": "^9.0.2", 39 | "@semantic-release/git": "^10.0.1", 40 | "@semantic-release/github": "^8.0.5", 41 | "@semantic-release/npm": "^9.0.1", 42 | "@semantic-release/release-notes-generator": "^10.0.3", 43 | "conventional-changelog-eslint": "^3.0.9", 44 | "semantic-release": "^19.0.3" 45 | }, 46 | "release": { 47 | "plugins": [ 48 | [ 49 | "@semantic-release/commit-analyzer", 50 | { 51 | "preset": "eslint" 52 | } 53 | ], 54 | [ 55 | "@semantic-release/release-notes-generator", 56 | { 57 | "preset": "eslint" 58 | } 59 | ], 60 | "@semantic-release/npm", 61 | "@semantic-release/github", 62 | [ 63 | "@semantic-release/git", 64 | { 65 | "assets": [ 66 | "package.json", 67 | "bower.json" 68 | ], 69 | "message": "Chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 70 | } 71 | ] 72 | ] 73 | } 74 | } 75 | --------------------------------------------------------------------------------