├── .github ├── CODEOWNERS └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── pull-request.md ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── License.txt ├── README.md ├── SECURITY.md ├── azure-pipelines.yml ├── builders.json ├── collection.json ├── collection.test.json ├── package.json ├── pull_request_template.md ├── scripts └── test.sh ├── src ├── __mocks__ │ └── inquirer.js ├── builders │ ├── actions │ │ └── deploy.ts │ ├── deploy.builder.ts │ ├── deploy.schema.json │ ├── logout.builder.ts │ └── logout.schema.json ├── ng-add │ ├── index.spec.ts │ ├── index.ts │ └── schema.json └── util │ ├── azure │ ├── __mocks__ │ │ ├── account.ts │ │ ├── auth.ts │ │ ├── resource-group-helper.ts │ │ ├── resource-group.ts │ │ └── subscription.ts │ ├── account.ts │ ├── auth.spec.ts │ ├── auth.ts │ ├── locations.spec.ts │ ├── locations.ts │ ├── resource-group-helper.ts │ ├── resource-group.spec.ts │ ├── resource-group.ts │ ├── subscription.spec.ts │ └── subscription.ts │ ├── prompt │ ├── __mocks__ │ │ └── name-generator.ts │ ├── confirm.ts │ ├── list.ts │ ├── name-generator.ts │ └── spinner.ts │ ├── shared │ └── types.ts │ └── workspace │ ├── angular-json.ts │ └── azure-json.ts └── tsconfig.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in 5 | # the repo. Unless a later match takes precedence, 6 | # @global-owner1 and @global-owner2 will be requested for 7 | # review when someone opens a pull request. 8 | * @shmool @softchris @manekinekko @sinedied -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 13 | 14 | ## Type of change 15 | 16 | Please delete options that are not relevant. 17 | 18 | - [ ] Bug fix (non-breaking change which fixes an issue) 19 | - [ ] New feature (non-breaking change which adds functionality) 20 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 21 | - [ ] This change requires a documentation update 22 | 23 | ## Closing issues 24 | 25 | Put closes #XXXX in your comment to auto-close the issue that your PR fixes (if such). 26 | 27 | ## Assignee 28 | 29 | Please add yourself as the assignee 30 | 31 | ## Projects 32 | 33 | Please add relevant projects so this issue can be properly tracked. 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.sln.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | 332 | 333 | # Outputs 334 | src/**/*.js 335 | !src/__mocks__/*.js 336 | src/**/*.js.map 337 | src/**/*.d.ts 338 | lib/**/* 339 | 340 | # IDEs 341 | .idea/ 342 | jsconfig.json 343 | .vscode/ 344 | 345 | # Misc 346 | node_modules/ 347 | npm-debug.log* 348 | yarn-error.log* 349 | package-lock.json 350 | yarn.lock 351 | 352 | # Mac OSX Finder files. 353 | **/.DS_Store 354 | .DS_Store 355 | 356 | out/ 357 | .e2e-tests/ 358 | coverage/ 359 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.spec.ts 2 | *.test.json 3 | __mocks__ 4 | __tests__ 5 | coverage/ 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the code will be documented in this file. 4 | 5 | ## 0.1.0 6 | 7 | Features 8 | 9 | - tbd 10 | 11 | Bug Fixes 12 | 13 | - tbd 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to 4 | agree to a Contributor License Agreement (CLA) declaring that you have the right to, 5 | and actually do, grant us the rights to use your contribution. For details, visit 6 | https://cla.microsoft.com. 7 | 8 | ## Local development 9 | 10 | If you want to try the latest package locally without installing it from npm, use the following instructions. This may be useful when you want to try the latest non published version of this library or you want to make a contribution. 11 | 12 | Follow the instructions for [checking and updating the Angular CLI version](#angular-cli). Also, verify your of TypeScript is version 3.4.5 or greater. 13 | 14 | ### npm link 15 | 16 | Use the following instructions to make ng-deploy-azure available locally via `npm link`. 17 | 18 | 1. Clone the project 19 | 20 | ```sh 21 | git clone git@github.com:Azure/ng-deploy-azure.git 22 | cd ng-deploy-azure 23 | ``` 24 | 25 | 1. Install the dependencies 26 | 27 | ```sh 28 | npm install 29 | ``` 30 | 31 | 1. Build the project: 32 | 33 | ```sh 34 | npm run build 35 | ``` 36 | 37 | 1. Create a local npm link: 38 | 39 | ```sh 40 | npm link 41 | ``` 42 | 43 | ### Adding to an Angular project - ng add 44 | 45 | Once you have completed the previous steps to npm link the local copy of ng-deploy-azure, follow these steps to use it in a local angular project. 46 | 47 | 1. Enter the project's directory 48 | 49 | ```sh 50 | cd your-angular-project 51 | ``` 52 | 53 | 1. To add the local version of @azure/ng-deploy, link ng-deploy-azure. 54 | 55 | ```sh 56 | npm link ng-deploy-azure 57 | ``` 58 | 59 | 1. You may be prompted you to sign in to Azure, providing a link to open in your browser and a code to paste in the login page. 60 | 61 | 1. Then, running `ng add @azure/ng-deploy` will use the locally linked version. 62 | 63 | ```sh 64 | ng add @azure/ng-deploy 65 | ``` 66 | 67 | 1. Now you can deploy your angular app to azure. 68 | 69 | ```sh 70 | ng run your-angular-project:deploy 71 | ``` 72 | 73 | > You can remove the link later by running `npm unlink` 74 | 75 | ### Testing 76 | 77 | Testing is done with [Jest](https://jestjs.io/). To run the tests: 78 | 79 | ```sh 80 | npm run test:jest 81 | ``` 82 | 83 | ### Commits message 84 | 85 | This project follows the [Conventional Commits convention](https://www.conventionalcommits.org), meaning that your commits message should be structured as follows: 86 | 87 | ``` 88 | [optional scope]: 89 | 90 | [optional body] 91 | 92 | [optional footer] 93 | ``` 94 | 95 | The commit should contains the following structural elements: 96 | 97 | - `fix:` a commit of the type fix patches a bug in your codebase (this correlates with PATCH in semantic versioning). 98 | - `feat:` a commit of the type feat introduces a new feature to the codebase (this correlates with MINOR in semantic versioning). 99 | - `BREAKING CHANGE:` a commit that has the text `BREAKING CHANGE:` at the beginning of its optional body or footer section introduces a breaking API change (correlating with MAJOR in semantic versioning). A BREAKING CHANGE can be part of commits of any type. 100 | - Others: commit types other than `fix:` and `feat:` are allowed such as `chore:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`. 101 | 102 | If you are new to this convention you can use `npm run commit` instead of `git commit` and follow the guided instructions. 103 | 104 | ### Pull requests 105 | 106 | When you submit a pull request, a CLA-bot will automatically determine whether you need 107 | to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the 108 | instructions provided by the bot. You will only need to do this once across all repositories using our CLA. 109 | 110 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 111 | 112 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 113 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Microsoft Corporation. All rights reserved. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @azure/ng-deploy 2 | 3 | [![npm version](https://badge.fury.io/js/%40azure%2Fng-deploy.svg)](https://www.npmjs.com/package/@azure/ng-deploy) 4 | [![Build Status](https://dev.azure.com/devrel/chris-noring-test/_apis/build/status/Azure.ng-deploy-azure?branchName=master)](https://dev.azure.com/devrel/chris-noring-test/_build/latest?definitionId=19&branchName=master) 5 | [![The MIT License](https://img.shields.io/badge/license-MIT-orange.svg?color=blue&style=flat-square)](http://opensource.org/licenses/MIT) 6 | 7 | **Deploy Angular apps to Azure using the Angular CLI** 8 | 9 | `@azure/ng-deploy` helps you deploy your Angular app to Azure Static Hosting using the [Angular CLI](https://angular.io/cli). 10 | 11 | ## Quick-start 12 | 13 | 1. Install the Angular CLI and create a new Angular project. 14 | 15 | ```sh 16 | npm install -g @angular/cli 17 | ng new hello-world --defaults 18 | cd hello-world 19 | ``` 20 | 21 | 2. Add `ng-deploy` to your project and create your Azure blob storage resources. 22 | 23 | ```sh 24 | ng add @azure/ng-deploy 25 | ``` 26 | 27 | 3. You may be prompted you to sign in to Azure, providing a link to open in your browser and a code to paste in the login page. 28 | 29 | 4. Deploy your project to Azure. 30 | 31 | ```sh 32 | ng run hello-world:deploy 33 | ``` 34 | 35 | The project will be built with the production configuration (like running `ng build -c=production`). 36 | 37 | You will see output similar to the following. Browse to the link and view your site running in Azure blob storage! 38 | 39 | ```sh 40 | see your deployed site at https://helloworldstatic52.z22.web.core.windows.net/ 41 | ``` 42 | 43 | ## Requirements 44 | 45 | You will need the Angular CLI, an Angular project, and an Azure Subscription to deploy to Azure. Details of these requirements are in this section. 46 | 47 | ### Azure 48 | 49 | If you don't have an Azure subscription, [create your Azure free account from this link](https://azure.microsoft.com/en-us/free/?WT.mc_id=ng_deploy_azure-github-cxa). 50 | 51 | ### Angular CLI 52 | 53 | 1. Install the Angular CLI. 54 | 55 | ```sh 56 | npm install -g @angular/cli 57 | ``` 58 | 59 | 2. Run `ng --version`, make sure you have angular CLI version v14 or greater. 60 | 61 | 3. If need instructions to update the CLI, [follow these upgrade instructions](https://www.npmjs.com/package/@angular/cli#updating-angular-cli). 62 | 63 | 4. Update your project using the command: 64 | 65 | ```sh 66 | ng update @angular/cli @angular/core 67 | ``` 68 | 69 | ### An Angular App Created by the Angular CLI 70 | 71 | You will need an Angular app created and managed by the Angular CLI. For help getting started with a new Angular app, check out the [Angular CLI](https://cli.angular.io/). 72 | 73 | A simple app can be created with `ng new hello-world --defaults` 74 | 75 | Verify you have TypeScript version 3.4.5 or greater in your `package.json` file of your angular project 76 | 77 | ## Details of ng-azure-deploy 78 | 79 | ### How to add and configure @azure/ng-deploy 80 | 81 | Add _@azure/ng-deploy_ to your project by running: 82 | 83 | ```sh 84 | ng add @azure/ng-deploy 85 | ``` 86 | 87 | This command will install the package to your project. 88 | 89 | Once done, it will prompt you to sign in to Azure, providing a link to open in your browser and a code to paste in the login page. 90 | 91 | After you sign in, it will create the needed resources in your Azure account (resource group and storage account) and configure them for static hosting. To manually configure the resources that will be used, refer to [additional options](#additional options). 92 | 93 | _Note: If you have several Azure subscriptions you will be asked to choose one._ 94 | 95 | The command will create the file `azure.json` with the deployment configuration and modify `angular.json` with the deploy commands. 96 | 97 | _Note: at the moment, the command will fail if an `azure.json` file already exists. Please remove the file before running the command._ 98 | 99 | ### deploy 100 | 101 | You can deploy your application to the selected storage account by running the following command. 102 | 103 | ```sh 104 | ng deploy 105 | ``` 106 | 107 | By default, the project will be built with the production option (similar to running `ng build -c=production`). 108 | The files will be taken from the path configured in the `build` command in `angular.json`. 109 | 110 | Follow [these instructions](#build-target) if you want to set up a different path and/or build target. 111 | 112 | You may be asked to sign in to Azure again. Then, the project will be deployed to the storage account specified in `azure.json`. The link to the deployed app will be presented. 113 | 114 | ### Logging out from Azure 115 | 116 | To clear the cached credentials run: 117 | 118 | ```sh 119 | ng run :azureLogout 120 | ``` 121 | 122 | This command is available only after signing in to Azure. 123 | 124 | ## Data/Telemetry 125 | 126 | This project collects usage data and sends it to Microsoft to help improve our products and services. 127 | 128 | Read Microsoft's [privacy statement](https://privacy.microsoft.com/en-gb/privacystatement/?WT.mc_id=ng_deploy_azure-github-cxa) to learn more. 129 | 130 | To turn off telemetry, add the telemetry flag (`--telemetry` or `-t`) with the `false` value when running `ng add`, like this: 131 | 132 | ```sh 133 | ng add ng-deploy-azure --telemetry=false 134 | ``` 135 | 136 | or 137 | 138 | ```sh 139 | ng add ng-deploy-azure -t=false 140 | ``` 141 | 142 | ### Additional options 143 | 144 | #### Manual configurations 145 | 146 | To manually select and/or create the resources needed for deployment, 147 | use the `--manual` (or `-m`) option: 148 | 149 | ```sh 150 | ng add @azure/ng-deploy --manual 151 | ``` 152 | 153 | You will be prompted to select or create the resource group and the storage account 154 | in which the app will be deployed. If you choose to create a resource group 155 | you will be asked to select the geographical location. 156 | 157 | #### Passing configuration options 158 | 159 | You can pass the names of the resources you'd like to use when running the command. 160 | Resources that don't already exist will be created. 161 | If using `--manual` you will be prompted to select the remaining configuration options. 162 | Otherwise, defaults will be used. 163 | 164 | The available options are: 165 | 166 | - `--subscriptionId` (`-i`) - subscription ID under which to select and/or create new resources 167 | - `--subscriptionName` (`-n`) - subscription name under which to select and/or create new resources 168 | - `--resourceGroup` (`-g`) - name of the Azure Resource Group to deploy to 169 | - `--account` (`-a`) - name of the Azure Storage Account to deploy to 170 | - `--location` (`-l`) - location where to create storage account e.g. `"West US"` or `westus` 171 | - `--telemetry` (`-t`) - see [Data/Telemetry](#telemetry) 172 | 173 | Example: 174 | 175 | ```sh 176 | ng add @azure/ng-deploy -m -l="East US" -a=myangularapp 177 | ``` 178 | 179 | #### Name validation 180 | 181 | When creating a new storage account, the provided name will be validated. 182 | 183 | The requirements for these names are: 184 | 185 | - between 3 and 24 characters 186 | - lower case letters and numbers only 187 | - unique across Azure 188 | 189 | If the validation fails, the tool will suggest a valid name. You will be able to select it or try another one. 190 | 191 | #### Changing the build target 192 | 193 | By default, the project is built using the `build` target with the `production` configuration, 194 | as configured in `angular.json`. 195 | 196 | You can change this by editing the `target` and/or `configuration` in `azure.json` (after completing `@azure/ng-add`). 197 | Change it to a target that exists for the project in `angular.json` and optionally with one of its configurations. 198 | Make sure the target specifies an `outputPath`. 199 | 200 | For example, if one of the targets under `projects.hello-world.architect` in `angular.json` is `special-build` 201 | with an optional configuration named `staging`, you can specify it as the target this way: 202 | 203 | ```json 204 | // azure.json 205 | { 206 | "hosting": [ 207 | { 208 | "app": { 209 | "project": "hello-world", 210 | "target": "special-build", 211 | "configuration": "staging" 212 | }, 213 | "azureHosting": { 214 | ... 215 | } 216 | } 217 | ] 218 | } 219 | ``` 220 | 221 | Another option is to skip build, and deploy directly from a specific location. 222 | To do this, delete the `target` and `configuration` from `azure.json`, 223 | and provide a `path` with a value relative to the root of the project. 224 | 225 | For example, if the files you with to deploy exist in `public/static/hello-world`, 226 | change the configuration this way: 227 | 228 | ```json 229 | // azure.json 230 | { 231 | "hosting": [ 232 | { 233 | "app": { 234 | "project": "hello-world", 235 | "path": "public/static/hello-world" 236 | }, 237 | "azureHosting": { 238 | ... 239 | } 240 | } 241 | ] 242 | } 243 | ``` 244 | 245 | In the future we'll add an option to change this through the command line. 246 | 247 | ## Continuous Integration Mode 248 | 249 | When deploying from a CI environment, we switch to a non-interactive login process that requires 250 | you to provide [Service Principal](https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals?WT.mc_id=ng_deploy_azure-github-cxa) credentials as environment variables. 251 | A Service Principal is an application within [Azure Active Directory](https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-whatis?WT.mc_id=ng_deploy_azure-github-cxa) 252 | that we can use to perform unattended resource and service level operations. 253 | 254 | ### Creating a Service Principal 255 | 256 | In order to create and get the Service Principal application credentials, you can either use the 257 | [Azure Portal](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal?WT.mc_id=ng_deploy_azure-github-cxa) 258 | or use the [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest?WT.mc_id=ng_deploy_azure-github-cxa). 259 | 260 | We recommend using the Azure CLI and running the following command: 261 | 262 | ```sh 263 | AZURE_SUBSCRIPTION_ID="" 264 | SP_NAME='' 265 | az ad sp create-for-rbac --role="Contributor" --scopes="/subscriptions/$AZURE_SUBSCRIPTION_ID" --name="$SP_NAME" 266 | ``` 267 | 268 | This command will output the following values: 269 | 270 | ```json 271 | { 272 | "appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", 273 | "displayName": "", 274 | "name": "http://", 275 | "password": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", 276 | "tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 277 | } 278 | ``` 279 | 280 | You can use the Azure CLI to test that these values work and you can log in: 281 | 282 | ```sh 283 | az login --service-principal -u $CLIENT_ID -p $CLIENT_SECRET --tenant $TENANT_ID 284 | ``` 285 | 286 | ### Configuring the environment variables 287 | 288 | We will need to set the following environment variables BEFORE adding `@azure/ng-deploy` or running the deploy command: 289 | 290 | - `CI`: this must be set to `1`. This will enable the CI mode. 291 | - `CLIENT_ID`: is the `appId` created above. 292 | - `CLIENT_SECRET`: is the `password` created above. 293 | - `TENANT_ID`: is the `tenant` created above. 294 | - `AZURE_SUBSCRIPTION_ID`: is your valid subscription ID. 295 | 296 | Here is a simple shell example: 297 | 298 | ```sh 299 | export CI=1 300 | export CLIENT_ID='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' 301 | export CLIENT_SECRET='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' 302 | export TENANT_ID='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' 303 | export AZURE_SUBSCRIPTION_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 304 | ng run :deploy 305 | ``` 306 | 307 | > For security reasons, we highly recommend to create and provide these environment variables through a different method, 308 | > eg. [Github Secrets](https://help.github.com/en/articles/virtual-environments-for-github-actions#environment-variables) 309 | > or [Azure DevOps Secrets](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/variables?view=azure-devops&tabs=yaml%2Cbatch#secret-variables?WT.mc_id=ng_deploy_azure-github-cxa). 310 | 311 | ## Reporting Security Issues 312 | 313 | Security issues and bugs should be reported privately, via email, to the Microsoft Security Response Center (MSRC) at [secure@microsoft.com](mailto:secure@microsoft.com). 314 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. 315 | Further information, including the [MSRC PGP](https://technet.microsoft.com/en-us/security/dn606155/?WT.mc_id=ng_deploy_azure-github-cxa) key, 316 | can be found in the [Security TechCenter](https://technet.microsoft.com/en-us/security/default/?WT.mc_id=ng_deploy_azure-github-cxa). 317 | 318 | ## Contributing 319 | 320 | Please refer to [CONTRIBUTING](CONTRIBUTING.md) for CLA guidance. 321 | 322 | ## Thank You 323 | 324 | - [Minko Gechev](https://twitter.com/mgechev) for guiding us through the new Angular CLI Architect API, which enables adding commands. 325 | 326 | - [Brian Holt](https://twitter.com/holtbt) for creating [azez](https://github.com/btholt/azez), which provided us an (az)easy start. 327 | 328 | - [John Papa](https://twitter.com/john_papa) for guiding through and supporting the development, publish and release. 329 | 330 | ## Related Resources 331 | 332 | - Learn more about Azure Static Hosting in this [blog post announcing Static websites on Azure Storage](https://azure.microsoft.com/en-us/blog/static-websites-on-azure-storage-now-generally-available/?WT.mc_id=ng_deploy_azure-github-cxa) 333 | - Install this [VS Code extension for Azure Storage](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurestorage&WT.mc_id=ng_deploy_azure-github-cxa) 334 | - Follow this tutorial to [deploy a static website to Azure](https://code.visualstudio.com/tutorials/static-website/getting-started?WT.mc_id=ng_deploy_azure-github-cxa) 335 | 336 | [azure-cli]: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest?WT.mc_id=ng_deploy_azure-github-cxa 337 | [active-directory]: https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-whatis?WT.mc_id=ng_deploy_azure-github-cxa 338 | [principal-service]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals?WT.mc_id=ng_deploy_azure-github-cxa 339 | [principal-service-portal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal?WT.mc_id=ng_deploy_azure-github-cxa 340 | [azure-devops-secrets]: https://docs.microsoft.com/en-us/azure/devops/pipelines/process/variables?view=azure-devops&tabs=yaml%2Cbatch#secret-variables?WT.mc_id=ng_deploy_azure-github-cxa 341 | [github-secrets]: https://help.github.com/en/articles/virtual-environments-for-github-actions#environment-variables 342 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Node.js 2 | # Build a general Node.js project with npm. 3 | # Add steps that analyze code, save build artifacts, deploy, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript 5 | 6 | strategy: 7 | matrix: 8 | linux-node12: 9 | imageName: 'ubuntu-latest' 10 | nodeVersion: '12.x' 11 | linux-node10: 12 | imageName: 'ubuntu-latest' 13 | nodeVersion: '10.x' 14 | mac-node12: 15 | imageName: 'macos-latest' 16 | nodeVersion: '12.x' 17 | mac-node10: 18 | imageName: 'macos-latest' 19 | nodeVersion: '10.x' 20 | windows-node12: 21 | imageName: 'windows-latest' 22 | nodeVersion: '12.x' 23 | windows-node10: 24 | imageName: 'windows-latest' 25 | nodeVersion: '10.x' 26 | 27 | pool: 28 | vmImage: $(imageName) 29 | 30 | trigger: 31 | branches: 32 | include: 33 | - master 34 | - greenkeeper/* 35 | pr: 36 | - master 37 | 38 | steps: 39 | - task: NodeTool@0 40 | inputs: 41 | versionSpec: $(nodeVersion) 42 | displayName: 'Install Node.js' 43 | 44 | - script: npm install && npm install -g @angular/cli 45 | displayName: 'Install dependencies' 46 | 47 | - script: npm run build 48 | displayName: 'Compile TypeScript and Build' 49 | 50 | - script: npm run test:jest 51 | displayName: 'Run jest unit tests' 52 | # TODO: manage login to @azure/ng-deploy and az cli before enabling 53 | #- script: bash -c "bash -c \"scripts/test.sh\"" 54 | # displayName: 'Run e2e tests' 55 | -------------------------------------------------------------------------------- /builders.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "@angular-devkit/architect/src/builders-schema.json", 3 | "builders": { 4 | "deploy": { 5 | "implementation": "./out/builders/deploy.builder", 6 | "schema": "./out/builders/deploy.schema.json", 7 | "description": "Deploy to Azure builder" 8 | }, 9 | "logout": { 10 | "implementation": "./out/builders/logout.builder", 11 | "schema": "./out/builders/logout.schema.json", 12 | "description": "Logout from Azure builder" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@angular-devkit/schematics/collection-schema.json", 3 | "schematics": { 4 | "ng-add": { 5 | "description": "Adds Angular Deploy Azure to the application without affecting any templates", 6 | "factory": "./out/ng-add/index#ngAdd", 7 | "schema": "./out/ng-add/schema.json", 8 | "aliases": ["install"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /collection.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@angular-devkit/schematics/collection-schema.json", 3 | "schematics": { 4 | "ng-add": { 5 | "description": "Adds Angular Deploy Azure to the application without affecting any templates", 6 | "factory": "./src/ng-add/index#ngAdd", 7 | "schema": "./src/ng-add/schema.json", 8 | "aliases": ["install"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@azure/ng-deploy", 3 | "version": "0.2.3", 4 | "main": "out/ng-add/index.js", 5 | "files": [ 6 | "out/", 7 | "builders.json", 8 | "collection.json" 9 | ], 10 | "description": "@azure/ng-deploy - Deploy Angular apps to Azure using the Angular CLI", 11 | "scripts": { 12 | "commit": "git-cz", 13 | "build": "tsc -p tsconfig.json && npm run copy:builders:json && npm run copy:ngadd:json && tsc -p tsconfig.json", 14 | "start": "npm run build:watch", 15 | "build:watch": "npm run build -s -- -w", 16 | "format": "npm run format:check -s -- --write", 17 | "format:check": "prettier -l \"./src/**/*.{json,ts}\"", 18 | "test": "jest --verbose", 19 | "test:jest": "jest", 20 | "test:jest:watch": "jest --watch", 21 | "test:e2e": "./scripts/test.sh", 22 | "test:coverage": "jest --coverage", 23 | "copy:builders:json": "cp ./src/builders/*.json ./out/builders", 24 | "copy:ngadd:json": "cp ./src/ng-add/*.json ./out/ng-add" 25 | }, 26 | "keywords": [ 27 | "schematics", 28 | "angular", 29 | "azure", 30 | "deploy" 31 | ], 32 | "author": { 33 | "name": "Shmuela Jacobs", 34 | "url": "https://twitter.com/ShmuelaJ" 35 | }, 36 | "contributors": [ 37 | { 38 | "name": "Shmuela Jacobs", 39 | "url": "https://twitter.com/ShmuelaJ" 40 | }, 41 | { 42 | "name": "Chris Noring", 43 | "url": "https://twitter.com/chris_noring" 44 | }, 45 | { 46 | "name": "Yohan Lasorsa", 47 | "url": "https://twitter.com/sinedied" 48 | }, 49 | { 50 | "name": "Wassim Chegham", 51 | "url": "https://twitter.com/manekinekko" 52 | } 53 | ], 54 | "homepage": "https://github.com/Azure/ng-deploy-azure/", 55 | "repository": { 56 | "type": "git", 57 | "url": "git@github.com:Azure/ng-deploy-azure.git" 58 | }, 59 | "bugs": { 60 | "url": "https://github.com/Azure/ng-deploy-azure/issues" 61 | }, 62 | "engines": { 63 | "node": ">=10" 64 | }, 65 | "license": "MIT", 66 | "builders": "./builders.json", 67 | "schematics": "./collection.json", 68 | "ng-add": { 69 | "save": "devDependencies" 70 | }, 71 | "dependencies": { 72 | "@angular-devkit/architect": "^0.1400.1", 73 | "@angular-devkit/core": "^14.0.1", 74 | "@angular-devkit/schematics": "^14.0.1", 75 | "@azure/arm-resources": "^2.1.0", 76 | "@azure/arm-storage": "^14.0.0", 77 | "@azure/ms-rest-azure-env": "^2.0.0", 78 | "@azure/ms-rest-nodeauth": "^3.0.3", 79 | "@azure/storage-blob": "^12.1.1", 80 | "adal-node": "^0.2.1", 81 | "chalk": "^4.0.0", 82 | "conf": "^10.1.1", 83 | "fuzzy": "^0.1.3", 84 | "glob": "^7.1.6", 85 | "inquirer": "^7.1.0", 86 | "inquirer-autocomplete-prompt": "^1.0.2", 87 | "mime-types": "^2.1.27", 88 | "node-fetch": "^2.6.0", 89 | "ora": "^4.0.4", 90 | "progress": "^2.0.3", 91 | "promise-limit": "^2.7.0", 92 | "typescript": "~4.7.2" 93 | }, 94 | "devDependencies": { 95 | "@commitlint/cli": "^17.0.2", 96 | "@commitlint/config-conventional": "^17.0.2", 97 | "@schematics/angular": "^14.0.1", 98 | "@types/conf": "^3.0.0", 99 | "@types/glob": "^7.1.1", 100 | "@types/inquirer": "^6.5.0", 101 | "@types/jest": "^25.2.3", 102 | "@types/mime-types": "^2.1.0", 103 | "@types/node": "^14.15.0", 104 | "@types/progress": "^2.0.3", 105 | "commitizen": "^4.2.4", 106 | "cz-conventional-changelog": "^3.0.1", 107 | "husky": "^4.2.5", 108 | "jest": "^28.1.1", 109 | "prettier": "^2.0.5", 110 | "pretty-quick": "^2.0.1", 111 | "ts-jest": "^28.0.4" 112 | }, 113 | "jest": { 114 | "roots": [ 115 | "/src" 116 | ], 117 | "transform": { 118 | "^.+\\.tsx?$": "ts-jest" 119 | } 120 | }, 121 | "prettier": { 122 | "singleQuote": true, 123 | "printWidth": 120 124 | }, 125 | "husky": { 126 | "hooks": { 127 | "pre-commit": "pretty-quick --staged", 128 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 129 | } 130 | }, 131 | "commitlint": { 132 | "extends": [ 133 | "@commitlint/config-conventional" 134 | ] 135 | }, 136 | "config": { 137 | "commitizen": { 138 | "path": "cz-conventional-changelog" 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request Template 2 | 3 | ## Description 4 | 5 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | ## How to Test 17 | 18 | - Replace this with instructions on how to test this PR 19 | 20 | ## Closing issues 21 | 22 | Put closes #XXXX in your comment to auto-close the issue that your PR fixes (if such). 23 | 24 | ## Assignee 25 | 26 | Please add yourself as the assignee 27 | 28 | ## Projects 29 | 30 | Please add relevant projects so this issue can be properly tracked. 31 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Simple e2e testing script 4 | # Usage: ./test.sh [subscription_name] 5 | 6 | set -e 7 | 8 | AZURE_SUBSCRIPTION=${1:'ca-yolasors-demo-test'} 9 | AZURE_RESOURCE_GROUP='ci-azure-ng-deploy' 10 | AZURE_STORAGE='ciazurengdeploy' 11 | CWD=`pwd` 12 | TEST_FOLDER="$CWD/.e2e-tests" 13 | 14 | function cleanup() { 15 | cd "$CWD" 16 | rm -rf "$TEST_FOLDER" 17 | } 18 | 19 | # Cleanup test folder in case of error 20 | trap cleanup ERR 21 | 22 | mkdir -p "$TEST_FOLDER" 23 | cd "$TEST_FOLDER" 24 | 25 | echo 26 | echo ------------------------------------------------------------------------------- 27 | echo Creating new Angular project to deploy on Azure 28 | echo ------------------------------------------------------------------------------- 29 | echo 30 | 31 | # TODO: manage @azure/ng-deploy + az cli login on CI 32 | 33 | npm pack .. 34 | ng new sample-app --routing true --style css 35 | cd sample-app 36 | npm i -D ../azure-ng-deploy*.tgz 37 | ng add @azure/ng-deploy -m true -n $AZURE_SUBSCRIPTION -g $AZURE_RESOURCE_GROUP -a $AZURE_STORAGE -l "westus" --telemetry false 38 | ng build -c=production 39 | ng run sample-app:deploy 40 | cd "$CWD" 41 | rm -rf "$TEST_FOLDER" 42 | 43 | # Cleanup resource group using az cli 44 | az.cmd group delete -n $AZURE_RESOURCE_GROUP -y 45 | -------------------------------------------------------------------------------- /src/__mocks__/inquirer.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | const inquirerMock = jest.genMockFromModule('inquirer'); 6 | 7 | inquirerMock.prompt = jest.fn(() => { 8 | return { 9 | sub: 'subMock' 10 | }; 11 | }); 12 | 13 | module.exports = inquirerMock; -------------------------------------------------------------------------------- /src/builders/actions/deploy.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as fs from 'fs'; 6 | import * as path from 'path'; 7 | import * as glob from 'glob'; 8 | import { lookup, charset } from 'mime-types'; 9 | import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob'; 10 | import * as promiseLimit from 'promise-limit'; 11 | import * as ProgressBar from 'progress'; 12 | import { BuilderContext, Target } from '@angular-devkit/architect'; 13 | import { AzureHostingConfig } from '../../util/workspace/azure-json'; 14 | import { StorageManagementClient } from '@azure/arm-storage'; 15 | import { getAccountKey } from '../../util/azure/account'; 16 | import * as chalk from 'chalk'; 17 | import { loginToAzure, loginToAzureWithCI } from '../../util/azure/auth'; 18 | import { AuthResponse } from '@azure/ms-rest-nodeauth'; 19 | 20 | export default async function deploy( 21 | context: BuilderContext, 22 | projectRoot: string, 23 | azureHostingConfig?: AzureHostingConfig 24 | ) { 25 | if (!context.target) { 26 | throw new Error('Cannot run target deploy. Context is missing a target object.'); 27 | } 28 | 29 | if (!azureHostingConfig) { 30 | throw new Error('Cannot find Azure hosting config for your app in azure.json'); 31 | } 32 | 33 | if ( 34 | !azureHostingConfig.app || 35 | !azureHostingConfig.azureHosting || 36 | !azureHostingConfig.azureHosting.subscription || 37 | !azureHostingConfig.azureHosting.resourceGroupName || 38 | !azureHostingConfig.azureHosting.account 39 | ) { 40 | throw new Error('Azure hosting config is missing some details. Please run "ng add @azure/ng-deploy"'); 41 | } 42 | 43 | let auth = {} as AuthResponse; 44 | if (process.env['CI']) { 45 | context.logger.info(`CI mode detected`); 46 | auth = await loginToAzureWithCI(context.logger); 47 | } else { 48 | auth = await loginToAzure(context.logger); 49 | } 50 | const credentials = await auth.credentials; 51 | 52 | context.logger.info('Preparing for deployment'); 53 | 54 | let filesPath = null; 55 | 56 | if (azureHostingConfig.app.target) { 57 | // build the project 58 | 59 | const target: Target = { 60 | target: azureHostingConfig.app.target, 61 | project: context.target.project, 62 | }; 63 | if (azureHostingConfig.app.configuration) { 64 | target.configuration = azureHostingConfig.app.configuration; 65 | } 66 | context.logger.info(`📦 Running "${azureHostingConfig.app.target}" on "${context.target.project}"`); 67 | 68 | const run = await context.scheduleTarget(target); 69 | const targetResult = await run.result; 70 | if (!targetResult.success) { 71 | throw new Error(`Target failed: ${targetResult.error}`); 72 | } 73 | filesPath = targetResult.outputPath as string; 74 | 75 | if (!filesPath) { 76 | if (azureHostingConfig.app.path) { 77 | context.logger.warn(`Target was executed but does not provide a result file path. 78 | Fetching files from the path configured in azure.json: ${azureHostingConfig.app.path}`); 79 | filesPath = path.join(projectRoot, azureHostingConfig.app.path); 80 | console.log(filesPath); 81 | } 82 | } 83 | } else if (azureHostingConfig.app.path) { 84 | context.logger.info(`Fetching files from the path configured in azure.json: ${azureHostingConfig.app.path}`); 85 | filesPath = path.join(projectRoot, azureHostingConfig.app.path); 86 | } 87 | 88 | if (!filesPath) { 89 | throw new Error('No path is configured for the files to deploy.'); 90 | } 91 | 92 | const files = await getFiles(context, filesPath, projectRoot); 93 | if (files.length === 0) { 94 | throw new Error('Target did not produce any files, or the path is incorrect.'); 95 | } 96 | 97 | const client = new StorageManagementClient(credentials, azureHostingConfig.azureHosting.subscription); 98 | const accountKey = await getAccountKey( 99 | azureHostingConfig.azureHosting.account, 100 | client, 101 | azureHostingConfig.azureHosting.resourceGroupName 102 | ); 103 | 104 | const sharedKeyCredential = new StorageSharedKeyCredential(azureHostingConfig.azureHosting.account, accountKey); 105 | 106 | const blobServiceClient = new BlobServiceClient( 107 | `https://${azureHostingConfig.azureHosting.account}.blob.core.windows.net`, 108 | sharedKeyCredential 109 | ); 110 | 111 | await uploadFilesToAzure(blobServiceClient, context, filesPath, files); 112 | 113 | const accountProps = await client.storageAccounts.getProperties( 114 | azureHostingConfig.azureHosting.resourceGroupName, 115 | azureHostingConfig.azureHosting.account 116 | ); 117 | const endpoint = accountProps.primaryEndpoints && accountProps.primaryEndpoints.web; 118 | 119 | context.logger.info(chalk.green(`see your deployed site at ${endpoint}`)); 120 | // TODO: log url for account at Azure portal 121 | } 122 | 123 | function getFiles(context: BuilderContext, filesPath: string, _projectRoot: string) { 124 | return glob.sync(`**`, { 125 | ignore: ['.git', '.azez.json'], 126 | cwd: filesPath, 127 | nodir: true, 128 | }); 129 | } 130 | 131 | export async function uploadFilesToAzure( 132 | serviceClient: BlobServiceClient, 133 | context: BuilderContext, 134 | filesPath: string, 135 | files: string[] 136 | ): Promise { 137 | context.logger.info('preparing static deploy'); 138 | const containerClient = serviceClient.getContainerClient('$web'); 139 | 140 | const bar = new ProgressBar('[:bar] :current/:total files uploaded | :percent done | :elapseds | eta: :etas', { 141 | total: files.length, 142 | }); 143 | 144 | bar.tick(0); 145 | 146 | await promiseLimit(5).map(files, async function (file: string) { 147 | const blockBlobClient = containerClient.getBlockBlobClient(file); 148 | 149 | const blobContentType = lookup(file) || ''; 150 | const blobContentEncoding = charset(blobContentType) || ''; 151 | 152 | await blockBlobClient.uploadStream(fs.createReadStream(path.join(filesPath, file)), 4 * 1024 * 1024, 20, { 153 | blobHTTPHeaders: { 154 | blobContentType, 155 | blobContentEncoding, 156 | }, 157 | onProgress: (_progress) => bar.tick(1), 158 | }); 159 | }); 160 | 161 | bar.terminate(); 162 | context.logger.info('deploying static site'); 163 | } 164 | -------------------------------------------------------------------------------- /src/builders/deploy.builder.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; 6 | import { NodeJsSyncHost } from '@angular-devkit/core/node'; 7 | import { normalize, workspaces } from '@angular-devkit/core'; 8 | import { join } from 'path'; 9 | import { readFileSync } from 'fs'; 10 | import { AzureHostingConfig, AzureJSON } from '../util/workspace/azure-json'; 11 | import deploy from './actions/deploy'; 12 | 13 | export default createBuilder(async (builderConfig: any, context: BuilderContext): Promise => { 14 | // get the root directory of the project 15 | const root = normalize(context.workspaceRoot); 16 | // NodeJsSyncHost - An implementation of the Virtual FS using Node as the backend, synchronously. 17 | const host = workspaces.createWorkspaceHost(new NodeJsSyncHost()); 18 | const { workspace } = await workspaces.readWorkspace(root, host); 19 | 20 | if (!context.target) { 21 | throw new Error('Cannot deploy the application without a target'); 22 | } 23 | 24 | const project = workspace.projects.get(context.target.project); 25 | if (!project) { 26 | throw new Error(`Cannot find project ${context.target.project} in the workspace.`); 27 | } 28 | 29 | const azureProject = getAzureHostingConfig(context.workspaceRoot, context.target.project, builderConfig.config); 30 | if (!azureProject) { 31 | throw new Error(`Configuration for project ${context.target.project} was not found in azure.json.`); 32 | } 33 | 34 | try { 35 | await deploy(context, join(context.workspaceRoot, project.root), azureProject); 36 | } catch (e) { 37 | context.logger.error('Error when trying to deploy: '); 38 | context.logger.error(e.message); 39 | return { success: false }; 40 | } 41 | return { success: true }; 42 | }); 43 | 44 | export function getAzureHostingConfig( 45 | projectRoot: string, 46 | target: string, 47 | azureConfigFile: string 48 | ): AzureHostingConfig | undefined { 49 | const azureJson: AzureJSON = JSON.parse(readFileSync(join(projectRoot, azureConfigFile), 'utf-8')); 50 | if (!azureJson) { 51 | throw new Error(`Cannot read configuration file "${azureConfigFile}"`); 52 | } 53 | const projects = azureJson.hosting; 54 | return projects.find((project) => project.app.project === target); 55 | } 56 | -------------------------------------------------------------------------------- /src/builders/deploy.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "AzureDeploySchema", 3 | "title": "Azure Deploy", 4 | "description": "Deploy the app to static hosting (storage account) at Azure", 5 | "properties": {} 6 | } 7 | -------------------------------------------------------------------------------- /src/builders/logout.builder.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; 6 | import { clearCreds } from '../util/azure/auth'; 7 | 8 | export default createBuilder( 9 | async (builderConfig: any, context: BuilderContext): Promise => { 10 | await clearCreds(); 11 | context.logger.info('Cleared Azure credentials from cache.'); 12 | return { success: true }; 13 | } 14 | ); 15 | -------------------------------------------------------------------------------- /src/builders/logout.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "AzureLogoutSchema", 3 | "title": "Logout from Azure", 4 | "description": "Clears Azure credentials from cache", 5 | "properties": {} 6 | } 7 | -------------------------------------------------------------------------------- /src/ng-add/index.spec.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { Tree } from '@angular-devkit/schematics'; 6 | import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; 7 | import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema'; 8 | import { Schema as ApplicationOptions } from '@schematics/angular/application/schema'; 9 | import { confirm } from '../util/prompt/confirm'; 10 | 11 | jest.mock('../util/azure/auth'); 12 | jest.mock('../util/azure/subscription'); 13 | jest.mock('../util/azure/resource-group'); 14 | jest.mock('../util/azure/account'); 15 | jest.mock('../util/prompt/confirm'); 16 | import * as AuthModule from '../util/azure/auth'; 17 | 18 | const collectionPath = require.resolve('../../collection.test.json'); 19 | 20 | const workspaceOptions: WorkspaceOptions = { 21 | name: 'workspace', 22 | newProjectRoot: 'tests', 23 | version: '9.1.4', 24 | }; 25 | 26 | const appOptions: ApplicationOptions = { name: 'test-app' }; 27 | const schemaOptions: any = { 28 | name: 'foo', 29 | project: 'test-app', 30 | }; 31 | 32 | describe('ng add @azure/ng-deploy', () => { 33 | const testRunner = new SchematicTestRunner('schematics', collectionPath); 34 | 35 | async function initAngularProject(): Promise { 36 | const appTree = await testRunner 37 | .runExternalSchematicAsync('@schematics/angular', 'workspace', workspaceOptions) 38 | .toPromise(); 39 | return await testRunner 40 | .runExternalSchematicAsync('@schematics/angular', 'application', appOptions, appTree) 41 | .toPromise(); 42 | } 43 | 44 | it('fails with a missing tree', async () => { 45 | await expect(testRunner.runSchematicAsync('ng-add', schemaOptions, Tree.empty()).toPromise()).rejects.toThrow(); 46 | }); 47 | 48 | it('adds azure deploy to an existing project', async () => { 49 | let appTree = await initAngularProject(); 50 | appTree = await testRunner.runSchematicAsync('ng-add', schemaOptions, appTree).toPromise(); 51 | const angularJson = JSON.parse(appTree.readContent('/angular.json')); 52 | 53 | expect(angularJson.projects[appOptions.name].architect.deploy).toBeDefined(); 54 | expect(angularJson.projects[appOptions.name].architect.azureLogout).toBeDefined(); 55 | expect(appTree.files).toContain('/azure.json'); 56 | 57 | const azureJson = JSON.parse(appTree.readContent('/azure.json')); 58 | expect(azureJson).toEqual({ 59 | hosting: [ 60 | { 61 | app: { 62 | configuration: 'production', 63 | path: 'dist/test-app', 64 | project: 'test-app', 65 | target: 'build', 66 | }, 67 | azureHosting: { 68 | account: 'fakeStorageAccount', 69 | resourceGroupName: 'fake-resource-group', 70 | subscription: 'fake-subscription-1234', 71 | }, 72 | }, 73 | ], 74 | }); 75 | }); 76 | 77 | it('should overwrite existing hosting config', async () => { 78 | // Simulate existing app setup 79 | let appTree = await initAngularProject(); 80 | appTree = await testRunner.runSchematicAsync('ng-add', schemaOptions, appTree).toPromise(); 81 | appTree.overwrite('/azure.json', appTree.readContent('azure.json').replace(/fake/g, 'existing')); 82 | 83 | const confirmMock = confirm as jest.Mock; 84 | confirmMock.mockClear(); 85 | confirmMock.mockImplementationOnce(() => Promise.resolve(true)); 86 | 87 | // Run ng add @azure/deploy on existing project 88 | appTree = await testRunner.runSchematicAsync('ng-add', schemaOptions, appTree).toPromise(); 89 | 90 | expect(confirm).toHaveBeenCalledTimes(1); 91 | expect(appTree.files).toContain('/azure.json'); 92 | 93 | const azureJson = JSON.parse(appTree.readContent('/azure.json')); 94 | expect(azureJson).toEqual({ 95 | hosting: [ 96 | { 97 | app: { 98 | configuration: 'production', 99 | path: 'dist/test-app', 100 | project: 'test-app', 101 | target: 'build', 102 | }, 103 | azureHosting: { 104 | account: 'fakeStorageAccount', 105 | resourceGroupName: 'fake-resource-group', 106 | subscription: 'fake-subscription-1234', 107 | }, 108 | }, 109 | ], 110 | }); 111 | }); 112 | 113 | it('should keep existing hosting config', async () => { 114 | // Simulate existing app setup 115 | let appTree = await initAngularProject(); 116 | appTree = await testRunner.runSchematicAsync('ng-add', schemaOptions, appTree).toPromise(); 117 | appTree.overwrite('/azure.json', appTree.readContent('azure.json').replace(/fake/g, 'existing')); 118 | 119 | const confirmMock = confirm as jest.Mock; 120 | confirmMock.mockClear(); 121 | confirmMock.mockImplementationOnce(() => Promise.resolve(false)); 122 | 123 | // Run ng add @azure/deploy on existing project 124 | appTree = await testRunner.runSchematicAsync('ng-add', schemaOptions, appTree).toPromise(); 125 | 126 | expect(confirm).toHaveBeenCalledTimes(1); 127 | expect(appTree.files).toContain('/azure.json'); 128 | 129 | const azureJson = JSON.parse(appTree.readContent('/azure.json')); 130 | expect(azureJson).toEqual({ 131 | hosting: [ 132 | { 133 | app: { 134 | configuration: 'production', 135 | path: 'dist/test-app', 136 | project: 'test-app', 137 | target: 'build', 138 | }, 139 | azureHosting: { 140 | account: 'existingStorageAccount', 141 | resourceGroupName: 'existing-resource-group', 142 | subscription: 'existing-subscription-1234', 143 | }, 144 | }, 145 | ], 146 | }); 147 | }); 148 | describe('when CI=1 is detected', () => { 149 | it('should call loginToAzureWithCI()', async () => { 150 | process.env.CI = '1'; 151 | const loginToAzureWithCI = jest.spyOn(AuthModule, 'loginToAzureWithCI'); 152 | 153 | let appTree = await initAngularProject(); 154 | appTree = await testRunner.runSchematicAsync('ng-add', schemaOptions, appTree).toPromise(); 155 | 156 | expect(loginToAzureWithCI).toHaveBeenCalled(); 157 | }); 158 | it('should NOT call loginToAzure()', async () => { 159 | process.env.CI = '1'; 160 | const loginToAzure = jest.spyOn(AuthModule, 'loginToAzure'); 161 | 162 | let appTree = await initAngularProject(); 163 | appTree = await testRunner.runSchematicAsync('ng-add', schemaOptions, appTree).toPromise(); 164 | 165 | expect(loginToAzure).not.toHaveBeenCalled(); 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/ng-add/index.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; 6 | import { confirm } from '../util/prompt/confirm'; 7 | import { loginToAzure, loginToAzureWithCI } from '../util/azure/auth'; 8 | import { DeviceTokenCredentials, AuthResponse } from '@azure/ms-rest-nodeauth'; 9 | import { selectSubscription } from '../util/azure/subscription'; 10 | import { getResourceGroup } from '../util/azure/resource-group'; 11 | import { getAccount, getAzureStorageClient } from '../util/azure/account'; 12 | import { AngularWorkspace } from '../util/workspace/angular-json'; 13 | import { generateAzureJson, readAzureJson, getAzureHostingConfig } from '../util/workspace/azure-json'; 14 | import { AddOptions } from '../util/shared/types'; 15 | 16 | export function ngAdd(_options: AddOptions): Rule { 17 | return (tree: Tree, _context: SchematicContext) => { 18 | return chain([addDeployAzure(_options)])(tree, _context); 19 | }; 20 | } 21 | 22 | export function addDeployAzure(_options: AddOptions): Rule { 23 | return async (tree: Tree, _context: SchematicContext) => { 24 | const project = new AngularWorkspace(tree); 25 | await project.getWorkspaceData(_options); 26 | const azureJson = readAzureJson(tree); 27 | const hostingConfig = azureJson ? getAzureHostingConfig(azureJson, project.projectName) : null; 28 | 29 | if (!hostingConfig || (await confirm(`Overwrite existing Azure config for ${project.projectName}?`))) { 30 | let auth = {} as AuthResponse; 31 | let subscription = ''; 32 | if (process.env['CI']) { 33 | _context.logger.info(`CI mode detected`); 34 | auth = await loginToAzureWithCI(_context.logger); 35 | // the AZURE_SUBSCRIPTION_ID variable is validated inside the loginToAzureWithCI 36 | // so we have the guarrantee that the value is not empty. 37 | subscription = process.env.AZURE_SUBSCRIPTION_ID as string; 38 | 39 | // make sure the project property is set correctly 40 | // this is needed when creating a storage account 41 | _options = { 42 | ..._options, 43 | project: project.projectName, 44 | }; 45 | } else { 46 | auth = await loginToAzure(_context.logger); 47 | subscription = await selectSubscription(auth.subscriptions, _options, _context.logger); 48 | } 49 | 50 | const credentials = auth.credentials as DeviceTokenCredentials; 51 | const resourceGroup = await getResourceGroup(credentials, subscription, _options, _context.logger); 52 | const client = getAzureStorageClient(credentials, subscription); 53 | const account = await getAccount(client, resourceGroup, _options, _context.logger); 54 | 55 | const appDeployConfig = { 56 | project: project.projectName, 57 | target: project.target, 58 | configuration: project.configuration, 59 | path: project.path, 60 | }; 61 | 62 | const azureDeployConfig = { 63 | subscription, 64 | resourceGroupName: resourceGroup.name, 65 | account, 66 | }; 67 | 68 | // TODO: log url for account at Azure portal 69 | generateAzureJson(tree, appDeployConfig, azureDeployConfig); 70 | } 71 | 72 | await project.addLogoutArchitect(); 73 | await project.addDeployArchitect(); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/ng-add/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "azure-deploy-schematic-ng-add", 4 | "title": "Azure Deploy ng-add schematic", 5 | "type": "object", 6 | "properties": { 7 | "project": { 8 | "type": "string", 9 | "description": "The name of the project.", 10 | "$default": { 11 | "$source": "projectName" 12 | } 13 | }, 14 | "manual": { 15 | "type": "boolean", 16 | "default": false, 17 | "alias": "m", 18 | "description": "Manually configure (select or create) the resource group and storage account. Default - false: creates a resource group and storage account with arbitrary names." 19 | }, 20 | "subscriptionId": { 21 | "type": "string", 22 | "default": "", 23 | "description": "subscription ID under which to select and/or create new resources", 24 | "alias": "i" 25 | }, 26 | "subscriptionName": { 27 | "type": "string", 28 | "default": "", 29 | "description": "subscription name under which to select and/or create new resources", 30 | "alias": "n" 31 | }, 32 | "resourceGroup": { 33 | "type": "string", 34 | "default": "", 35 | "description": "name of the Azure Resource Group to deploy to", 36 | "alias": "g" 37 | }, 38 | "account": { 39 | "type": "string", 40 | "default": "", 41 | "description": "name of the Azure Storage Account to deploy to", 42 | "alias": "a" 43 | }, 44 | "location": { 45 | "type": "string", 46 | "default": "", 47 | "description": "location where to create storage account e.g. \"West US\"", 48 | "alias": "l" 49 | }, 50 | "telemetry": { 51 | "type": "boolean", 52 | "default": true, 53 | "description": "Send usage reports to Microsoft.", 54 | "alias": "t" 55 | } 56 | }, 57 | "required": [], 58 | "additionalProperties": true 59 | } 60 | -------------------------------------------------------------------------------- /src/util/azure/__mocks__/account.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | export const getAccount = () => 'fakeStorageAccount'; 7 | 8 | export const getAzureStorageClient = () => null; 9 | -------------------------------------------------------------------------------- /src/util/azure/__mocks__/auth.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | export const loginToAzure = () => 7 | Promise.resolve({ 8 | credentials: null, 9 | subscriptions: [], 10 | }); 11 | 12 | export const loginToAzureWithCI = () => 13 | Promise.resolve({ 14 | credentials: null, 15 | subscriptions: [], 16 | }); 17 | -------------------------------------------------------------------------------- /src/util/azure/__mocks__/resource-group-helper.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | export async function getResourceGroups() { 7 | return Promise.resolve([ 8 | { 9 | id: '1', 10 | name: 'mock', 11 | location: 'location', 12 | }, 13 | { 14 | id: '2', 15 | name: 'mock2', 16 | location: 'location', 17 | }, 18 | { 19 | id: '3', 20 | name: 'mock3', 21 | location: 'location', 22 | }, 23 | ]); 24 | } 25 | 26 | export const createResourceGroup = jest.fn((name: string) => Promise.resolve({ name })); 27 | -------------------------------------------------------------------------------- /src/util/azure/__mocks__/resource-group.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | export const getResourceGroup = () => 7 | Promise.resolve({ 8 | id: '4321', 9 | name: 'fake-resource-group', 10 | location: 'westus', 11 | }); 12 | -------------------------------------------------------------------------------- /src/util/azure/__mocks__/subscription.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | export const selectSubscription = () => Promise.resolve('fake-subscription-1234'); 7 | -------------------------------------------------------------------------------- /src/util/azure/account.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { StorageManagementClient } from '@azure/arm-storage'; 6 | import { newItemPrompt } from '../prompt/list'; 7 | import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob'; 8 | import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth'; 9 | import { AddOptions, Logger } from '../shared/types'; 10 | import { SchematicsException } from '@angular-devkit/schematics'; 11 | import { ResourceGroup } from './resource-group'; 12 | import { generateName } from '../prompt/name-generator'; 13 | import { spinner } from '../prompt/spinner'; 14 | 15 | const newAccountPromptOptions = { 16 | id: 'newAccount', 17 | message: 'Enter a name for the new storage account:', 18 | name: 'Create a new storage account', 19 | default: '', 20 | defaultGenerator: (_name: string) => Promise.resolve(''), 21 | validate: (_name: string) => Promise.resolve(true), 22 | }; 23 | 24 | export function getAzureStorageClient(credentials: DeviceTokenCredentials, subscriptionId: string) { 25 | return new StorageManagementClient(credentials, subscriptionId); 26 | } 27 | 28 | export async function getAccount( 29 | client: StorageManagementClient, 30 | resourceGroup: ResourceGroup, 31 | options: AddOptions, 32 | logger: Logger 33 | ) { 34 | let accountName = options.account || ''; 35 | let needToCreateAccount = false; 36 | 37 | spinner.start('Fetching storage accounts'); 38 | const accounts = await client.storageAccounts; 39 | spinner.stop(); 40 | 41 | function getInitialAccountName() { 42 | const normalizedProjectNameArray = options.project.match(/[a-zA-Z0-9]/g); 43 | let normalizedProjectName = normalizedProjectNameArray ? normalizedProjectNameArray.join('') : ''; 44 | 45 | /* 46 | ensures project name + 'static' does not overshoot 24 characters (which is the Azure requirement on an account name) 47 | additionally it needs to be lowercase (in case we have Angular project like e.g `ExampleApp`) 48 | */ 49 | normalizedProjectName = normalizedProjectName.toLowerCase().substring(0, 18); 50 | return `ngd${normalizedProjectName}cxa`; 51 | } 52 | 53 | const initialName = getInitialAccountName(); 54 | const generateDefaultAccountName = accountNameGenerator(client); 55 | const validateAccountName = checkNameAvailability(client, true); 56 | 57 | newAccountPromptOptions.default = initialName; 58 | newAccountPromptOptions.defaultGenerator = generateDefaultAccountName; 59 | newAccountPromptOptions.validate = validateAccountName; 60 | 61 | if (accountName) { 62 | const result = await accounts.checkNameAvailability(accountName); 63 | 64 | if (!result.nameAvailable) { 65 | // account exists 66 | // TODO: check account configuration 67 | logger.info(`Using existing account ${accountName}`); 68 | } else { 69 | // create account with this name, if valid 70 | const valid = await validateAccountName(accountName); 71 | if (!valid) { 72 | accountName = (await newItemPrompt(newAccountPromptOptions)).newAccount; 73 | } 74 | needToCreateAccount = true; 75 | } 76 | } else { 77 | // no account flag 78 | 79 | if (!options.manual) { 80 | // quickstart - create w/ default name 81 | 82 | accountName = await generateDefaultAccountName(initialName); 83 | const availableResult = await client.storageAccounts.checkNameAvailability(accountName); 84 | 85 | if (!availableResult.nameAvailable) { 86 | logger.info(`Account ${accountName} already exist on subscription, using existing account`); 87 | } else { 88 | needToCreateAccount = true; 89 | } 90 | } 91 | } 92 | 93 | if (needToCreateAccount) { 94 | spinner.start(`creating ${accountName}`); 95 | await createAccount(accountName, client, resourceGroup.name, resourceGroup.location); 96 | spinner.succeed(); 97 | } 98 | 99 | return accountName; 100 | } 101 | 102 | function checkNameAvailability(client: StorageManagementClient, warn?: boolean) { 103 | return async (account: string) => { 104 | spinner.start(); 105 | const availability = await client.storageAccounts.checkNameAvailability(account); 106 | if (!availability.nameAvailable && warn) { 107 | spinner.fail(availability.message || 'chosen name is not available'); 108 | return false; 109 | } else { 110 | spinner.stop(); 111 | return true; 112 | } 113 | }; 114 | } 115 | 116 | function accountNameGenerator(client: StorageManagementClient) { 117 | return async (name: string) => { 118 | return await generateName(name, checkNameAvailability(client, false)); 119 | }; 120 | } 121 | 122 | export async function getAccountKey(account: any, client: StorageManagementClient, resourceGroup: any) { 123 | const accountKeysRes = await client.storageAccounts.listKeys(resourceGroup, account); 124 | const accountKey = (accountKeysRes.keys || []).filter((key) => (key.permissions || '').toUpperCase() === 'FULL')[0]; 125 | if (!accountKey || !accountKey.value) { 126 | process.exit(1); 127 | return ''; 128 | } 129 | return accountKey.value; 130 | } 131 | 132 | export async function createAccount( 133 | account: string, 134 | client: StorageManagementClient, 135 | resourceGroupName: string, 136 | location: string 137 | ) { 138 | const poller = await client.storageAccounts.beginCreate(resourceGroupName, account, { 139 | kind: 'StorageV2', 140 | location, 141 | sku: { name: 'Standard_LRS' }, 142 | }); 143 | await poller.pollUntilFinished(); 144 | 145 | spinner.start('Retrieving account keys'); 146 | const accountKey = await getAccountKey(account, client, resourceGroupName); 147 | if (!accountKey) { 148 | throw new SchematicsException('no keys retrieved for storage account'); 149 | } 150 | spinner.succeed(); 151 | 152 | spinner.start('Creating web container'); 153 | const sharedKeyCredential = new StorageSharedKeyCredential(account, accountKey); 154 | await createWebContainer(client, resourceGroupName, account, sharedKeyCredential); 155 | spinner.succeed(); 156 | } 157 | 158 | export async function createWebContainer( 159 | client: StorageManagementClient, 160 | resourceGroup: any, 161 | account: any, 162 | sharedKeyCredential: StorageSharedKeyCredential 163 | ) { 164 | const blobServiceClient = new BlobServiceClient(`https://${account}.blob.core.windows.net`, sharedKeyCredential); 165 | 166 | await blobServiceClient.setProperties({ 167 | staticWebsite: { 168 | enabled: true, 169 | indexDocument: 'index.html', 170 | errorDocument404Path: 'index.html', 171 | }, 172 | }); 173 | } 174 | -------------------------------------------------------------------------------- /src/util/azure/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { loginToAzureWithCI } from './auth'; 2 | 3 | const loggerMock = { 4 | debug: jest.fn(), 5 | info: jest.fn(), 6 | warn: jest.fn(), 7 | error: jest.fn(), 8 | fatal: jest.fn(), 9 | }; 10 | 11 | const AuthResponseMock = { 12 | credentials: {}, 13 | subscriptions: [], 14 | }; 15 | 16 | jest.mock('conf'); 17 | jest.mock('@azure/ms-rest-nodeauth', () => { 18 | return { 19 | loginWithServicePrincipalSecretWithAuthResponse: jest.fn(() => { 20 | return AuthResponseMock; 21 | }), 22 | }; 23 | }); 24 | 25 | describe('Auth', () => { 26 | afterAll(() => { 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | describe('calling loginToAzureWithCI', () => { 31 | it('should throw if CLIENT_ID is not provided', () => { 32 | expect(loginToAzureWithCI(loggerMock)).rejects.toThrow('CLIENT_ID is required in CI mode'); 33 | }); 34 | it('should throw if CLIENT_SECRET is not provided', () => { 35 | process.env.CLIENT_ID = 'fake'; 36 | expect(loginToAzureWithCI(loggerMock)).rejects.toThrow('CLIENT_SECRET is required in CI mode'); 37 | }); 38 | it('should throw if TENANT_ID is not provided', () => { 39 | process.env.CLIENT_ID = 'fake'; 40 | process.env.CLIENT_SECRET = 'fake'; 41 | expect(loginToAzureWithCI(loggerMock)).rejects.toThrow('TENANT_ID is required in CI mode'); 42 | }); 43 | it('should throw if AZURE_SUBSCRIPTION_ID is not provided', () => { 44 | process.env.CLIENT_ID = 'fake'; 45 | process.env.CLIENT_SECRET = 'fake'; 46 | process.env.TENANT_ID = 'fake'; 47 | expect(loginToAzureWithCI(loggerMock)).rejects.toThrow('AZURE_SUBSCRIPTION_ID is required in CI mode'); 48 | }); 49 | 50 | it('should resolves if all env variables are provided', async () => { 51 | process.env.CLIENT_ID = 'fake'; 52 | process.env.CLIENT_SECRET = 'fake'; 53 | process.env.TENANT_ID = 'fake'; 54 | process.env.AZURE_SUBSCRIPTION_ID = 'fake'; 55 | expect(await loginToAzureWithCI(loggerMock)).toBe(AuthResponseMock); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/util/azure/auth.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { 6 | interactiveLoginWithAuthResponse, 7 | DeviceTokenCredentials, 8 | AuthResponse, 9 | loginWithServicePrincipalSecretWithAuthResponse, 10 | } from '@azure/ms-rest-nodeauth'; 11 | import { MemoryCache, TokenResponse } from 'adal-node'; 12 | import { Environment } from '@azure/ms-rest-azure-env'; 13 | const Conf = require('conf'); 14 | import { Logger } from '../shared/types'; 15 | import { buildTenantList } from '@azure/ms-rest-nodeauth/dist/lib/subscriptionManagement/subscriptionUtils'; 16 | 17 | const AUTH = 'auth'; 18 | 19 | export type TokenCredentials = DeviceTokenCredentials & { tokenCache: { _entries: TokenResponse[] } }; 20 | 21 | export const globalConfig = new Conf({ 22 | defaults: { 23 | auth: null, 24 | }, 25 | configName: 'ng-azure', 26 | }); 27 | 28 | export async function clearCreds() { 29 | return globalConfig.set(AUTH, null); 30 | } 31 | 32 | /** 33 | * safe guard if things get wrong and we don't get an AUTH object. 34 | * we exit if: 35 | * - auth is not valid 36 | * - auth.credentials doesn't exist 37 | * - auth.credentials.getToken is not a function 38 | */ 39 | function safeCheckForValidAuthSignature(auth: AuthResponse) { 40 | const isEmpty = (o: object) => Object.entries(o).length === 0; 41 | if ( 42 | auth === null || 43 | (auth && isEmpty(auth.credentials)) || 44 | (auth && auth.credentials && typeof auth.credentials.getToken !== 'function') 45 | ) { 46 | throw new Error( 47 | `There was an issue during the login process.\n 48 | Make sure to delete "${globalConfig.path}" and try again.` 49 | ); 50 | } 51 | } 52 | 53 | export async function loginToAzure(logger: Logger): Promise { 54 | // a retry login helper function 55 | const retryLogin = async (_auth: AuthResponse | null, tenant: string = ''): Promise => { 56 | _auth = await interactiveLoginWithAuthResponse(!!tenant ? { domain: tenant } : {}); 57 | safeCheckForValidAuthSignature(_auth); 58 | if (!tenant && (!_auth.subscriptions || _auth.subscriptions.length === 0)) { 59 | logger.info(`Due to an issue regarding authentication with the wrong tenant, we ask you to log in again.`); 60 | const tenants = await buildTenantList(_auth.credentials); 61 | _auth = await retryLogin(_auth, tenants[0]); 62 | } 63 | _auth.credentials = _auth.credentials as TokenCredentials; 64 | globalConfig.set(AUTH, _auth); 65 | return _auth; 66 | }; 67 | 68 | // check old AUTH config from cache 69 | let auth = (await globalConfig.get(AUTH)) as AuthResponse | null; 70 | 71 | // if old AUTH config is not found, we trigger a new login flow 72 | if (auth === null) { 73 | auth = await retryLogin(auth, process.env.TENANT_ID); 74 | } else { 75 | const creds = auth.credentials as TokenCredentials; 76 | const { clientId, domain, username, tokenAudience, environment } = creds; 77 | 78 | // if old AUTH config was found, we extract and check if the required fields are valid 79 | if (creds && clientId && domain && username && tokenAudience && environment) { 80 | const cache = new MemoryCache(); 81 | cache.add(creds.tokenCache._entries, () => {}); 82 | 83 | // we need to regenerate a proper object from the saved credentials 84 | auth.credentials = new DeviceTokenCredentials( 85 | clientId, 86 | domain, 87 | username, 88 | tokenAudience, 89 | new Environment(environment), 90 | cache 91 | ); 92 | 93 | const token = await auth.credentials.getToken(); 94 | // if extracted token has expired, we request a new login flow 95 | if (new Date(token.expiresOn).getTime() < Date.now()) { 96 | logger.info(`Your stored credentials have expired; you'll have to log in again`); 97 | 98 | auth = await retryLogin(auth); 99 | } 100 | } else { 101 | // if old AUTH config was found, but the required fields are NOT valid, we trigger a new login flow 102 | auth = await retryLogin(auth); 103 | } 104 | } 105 | 106 | return auth as AuthResponse; 107 | } 108 | 109 | export async function loginToAzureWithCI(logger: Logger): Promise { 110 | logger.info(`Checking for configuration...`); 111 | const { CLIENT_ID, CLIENT_SECRET, TENANT_ID, AZURE_SUBSCRIPTION_ID } = process.env; 112 | 113 | if (CLIENT_ID) { 114 | logger.info(`Using CLIENT_ID=${CLIENT_ID}`); 115 | } else { 116 | throw new Error('CLIENT_ID is required in CI mode'); 117 | } 118 | 119 | if (CLIENT_SECRET) { 120 | logger.info(`Using CLIENT_SECRET=${CLIENT_SECRET.replace(/\w/g, '*')}`); 121 | } else { 122 | throw new Error('CLIENT_SECRET is required in CI mode'); 123 | } 124 | 125 | if (TENANT_ID) { 126 | logger.info(`Using TENANT_ID=${TENANT_ID}`); 127 | } else { 128 | throw new Error('TENANT_ID is required in CI mode'); 129 | } 130 | 131 | if (AZURE_SUBSCRIPTION_ID) { 132 | logger.info(`Using AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID}`); 133 | } else { 134 | throw new Error('AZURE_SUBSCRIPTION_ID is required in CI mode'); 135 | } 136 | logger.info(`Configuration OK`); 137 | 138 | return await loginWithServicePrincipalSecretWithAuthResponse(CLIENT_ID, CLIENT_SECRET, TENANT_ID); 139 | } 140 | -------------------------------------------------------------------------------- /src/util/azure/locations.spec.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { getLocation } from './locations'; 6 | 7 | describe('location', () => { 8 | test('should return undefined when locationName is undefined', () => { 9 | const actual = getLocation(undefined); 10 | expect(actual).toBeUndefined(); 11 | }); 12 | 13 | test('should return matched location', () => { 14 | const actual = getLocation('southafricanorth'); 15 | expect(actual && actual.id).toBe('southafricanorth'); 16 | expect(actual && actual.name).toBe('South Africa North'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/util/azure/locations.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export interface StorageLocation { 6 | id: string; 7 | name: string; 8 | } 9 | 10 | export const defaultLocation = { 11 | id: 'westus', 12 | name: 'West US', 13 | }; 14 | 15 | export const locations = [ 16 | { 17 | id: 'eastasia', 18 | name: 'East Asia', 19 | }, 20 | { 21 | id: 'southeastasia', 22 | name: 'Southeast Asia', 23 | }, 24 | { 25 | id: 'centralus', 26 | name: 'Central US', 27 | }, 28 | { 29 | id: 'eastus', 30 | name: 'East US', 31 | }, 32 | { 33 | id: 'eastus2', 34 | name: 'East US 2', 35 | }, 36 | { 37 | id: 'westus', 38 | name: 'West US', 39 | }, 40 | { 41 | id: 'northcentralus', 42 | name: 'North Central US', 43 | }, 44 | { 45 | id: 'southcentralus', 46 | name: 'South Central US', 47 | }, 48 | { 49 | id: 'northeurope', 50 | name: 'North Europe', 51 | }, 52 | { 53 | id: 'westeurope', 54 | name: 'West Europe', 55 | }, 56 | { 57 | id: 'japanwest', 58 | name: 'Japan West', 59 | }, 60 | { 61 | id: 'japaneast', 62 | name: 'Japan East', 63 | }, 64 | { 65 | id: 'brazilsouth', 66 | name: 'Brazil South', 67 | }, 68 | { 69 | id: 'australiaeast', 70 | name: 'Australia East', 71 | }, 72 | { 73 | id: 'australiasoutheast', 74 | name: 'Australia Southeast', 75 | }, 76 | { 77 | id: 'southindia', 78 | name: 'South India', 79 | }, 80 | { 81 | id: 'centralindia', 82 | name: 'Central India', 83 | }, 84 | { 85 | id: 'westindia', 86 | name: 'West India', 87 | }, 88 | { 89 | id: 'canadacentral', 90 | name: 'Canada Central', 91 | }, 92 | { 93 | id: 'canadaeast', 94 | name: 'Canada East', 95 | }, 96 | { 97 | id: 'uksouth', 98 | name: 'UK South', 99 | }, 100 | { 101 | id: 'ukwest', 102 | name: 'UK West', 103 | }, 104 | { 105 | id: 'westcentralus', 106 | name: 'West Central US', 107 | }, 108 | { 109 | id: 'westus2', 110 | name: 'West US 2', 111 | }, 112 | { 113 | id: 'koreacentral', 114 | name: 'Korea Central', 115 | }, 116 | { 117 | id: 'koreasouth', 118 | name: 'Korea South', 119 | }, 120 | { 121 | id: 'francecentral', 122 | name: 'France Central', 123 | }, 124 | { 125 | id: 'southafricanorth', 126 | name: 'South Africa North', 127 | }, 128 | ]; 129 | 130 | export function getLocation(locationName: string | undefined) { 131 | if (!locationName) { 132 | return; 133 | } 134 | return locations.find((location) => { 135 | return location.id === locationName || location.name === locationName; 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /src/util/azure/resource-group-helper.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { ResourceManagementClient } from '@azure/arm-resources'; 6 | import { ListItem } from '../prompt/list'; 7 | import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth'; 8 | import { ResourceGroupsCreateOrUpdateResponse } from '@azure/arm-resources/esm/models'; 9 | 10 | export interface ResourceGroupDetails extends ListItem { 11 | id: string; 12 | name: string; 13 | properties?: any; 14 | location: string; 15 | } 16 | 17 | export async function getResourceGroups(creds: DeviceTokenCredentials, subscription: string) { 18 | const client = new ResourceManagementClient(creds, subscription); 19 | const resourceGroupList = (await client.resourceGroups.list()) as ResourceGroupDetails[]; 20 | return resourceGroupList; 21 | } 22 | 23 | export async function createResourceGroup( 24 | name: string, 25 | subscription: string, 26 | creds: DeviceTokenCredentials, 27 | location: string 28 | ): Promise { 29 | // TODO: throws an error here if the subscription is wrong 30 | const client = new ResourceManagementClient(creds, subscription); 31 | const resourceGroupRes = await client.resourceGroups.createOrUpdate(name, { 32 | location, 33 | }); 34 | return resourceGroupRes; 35 | } 36 | -------------------------------------------------------------------------------- /src/util/azure/resource-group.spec.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { getResourceGroup, ResourceGroup } from './resource-group'; 6 | import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth'; 7 | import { AddOptions } from '../shared/types'; 8 | 9 | const RESOURCE_GROUP = 'GROUP'; 10 | 11 | const credentials = {}; 12 | const options = { 13 | resourceGroup: RESOURCE_GROUP, 14 | }; 15 | const logger = { 16 | debug: jest.fn(), 17 | info: jest.fn(), 18 | warn: jest.fn(), 19 | error: jest.fn(), 20 | fatal: jest.fn(), 21 | }; 22 | 23 | jest.mock('./resource-group-helper'); 24 | jest.mock('../prompt/name-generator'); 25 | jest.mock('../prompt/spinner'); 26 | 27 | import { createResourceGroup } from './resource-group-helper'; 28 | const createResourceGroupMock: jest.Mock = >createResourceGroup; 29 | 30 | describe('resource group', () => { 31 | beforeEach(() => { 32 | logger.info.mockClear(); 33 | createResourceGroupMock.mockClear(); 34 | }); 35 | 36 | test.only('should create resource group', async () => { 37 | const subscription = ''; 38 | await getResourceGroup(credentials, subscription, options, logger); 39 | 40 | expect(createResourceGroupMock.mock.calls[0][0]).toBe(RESOURCE_GROUP); 41 | }); 42 | 43 | test('should use existing resource group and return it', async () => { 44 | // there needs to be a match towards resource group list 45 | const subscription = ''; 46 | const existingMockResourceGroup = 'mock2'; 47 | const optionsWithMatch = { 48 | ...options, 49 | resourceGroup: existingMockResourceGroup, 50 | }; 51 | const resourceGroup: ResourceGroup = await getResourceGroup(credentials, subscription, optionsWithMatch, logger); 52 | 53 | expect(createResourceGroupMock.mock.calls.length).toBe(0); 54 | 55 | expect(logger.info.mock.calls.length).toBe(1); 56 | expect(logger.info.mock.calls[0][0]).toBe(`Using existing resource group ${existingMockResourceGroup}`); 57 | expect(resourceGroup.name).toBe(existingMockResourceGroup); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/util/azure/resource-group.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth'; 6 | import { filteredList } from '../prompt/list'; 7 | import { getLocation, locations, StorageLocation } from './locations'; 8 | import { AddOptions, Logger } from '../shared/types'; 9 | import { generateName } from '../prompt/name-generator'; 10 | import { getResourceGroups, ResourceGroupDetails, createResourceGroup } from './resource-group-helper'; 11 | import { spinner } from '../prompt/spinner'; 12 | 13 | const defaultLocation = { 14 | id: 'westus', 15 | name: 'West US', 16 | }; 17 | 18 | export interface ResourceGroup { 19 | id: string; 20 | name: string; 21 | location: string; 22 | } 23 | 24 | const resourceGroupsPromptOptions = { 25 | id: 'resourceGroup', 26 | message: 'Under which resource group should we put this static site?', 27 | }; 28 | 29 | const newResourceGroupsPromptOptions = { 30 | id: 'newResourceGroup', 31 | message: 'Enter a name for the new resource group:', 32 | name: 'Create a new resource group', 33 | default: '', 34 | }; 35 | 36 | const locationPromptOptions = { 37 | id: 'location', 38 | message: 'In which location should the storage account be created?', 39 | }; 40 | 41 | export async function getResourceGroup( 42 | creds: DeviceTokenCredentials, 43 | subscription: string, 44 | options: AddOptions, 45 | logger: Logger 46 | ): Promise { 47 | let resourceGroupName = options.resourceGroup || ''; 48 | let location = getLocation(options.location); 49 | 50 | spinner.start('Fetching resource groups'); 51 | const resourceGroupList = await getResourceGroups(creds, subscription); 52 | spinner.stop(); 53 | let result; 54 | 55 | const initialName = `ngdeploy-${options.project}-cxa`; 56 | const defaultResourceGroupName = await resourceGroupNameGenerator(initialName, resourceGroupList); 57 | 58 | if (!options.manual) { 59 | // quickstart 60 | resourceGroupName = resourceGroupName || defaultResourceGroupName; 61 | location = location || defaultLocation; 62 | } 63 | 64 | if (!!resourceGroupName) { 65 | // provided or quickstart + default 66 | result = resourceGroupList.find((rg) => rg.name === resourceGroupName); 67 | if (!!result) { 68 | logger.info(`Using existing resource group ${resourceGroupName}`); 69 | } 70 | } else { 71 | // not provided + manual 72 | 73 | // TODO: default name can be assigned later, only if creating a new resource group. 74 | // TODO: check availability of the default name 75 | newResourceGroupsPromptOptions.default = defaultResourceGroupName; 76 | 77 | result = await filteredList(resourceGroupList, resourceGroupsPromptOptions, newResourceGroupsPromptOptions); 78 | 79 | // TODO: add check whether the new resource group doesn't already exist. 80 | // Currently throws an error of exists in a different location: 81 | // Invalid resource group location 'westus'. The Resource group already exists in location 'eastus2'. 82 | 83 | result = result.resourceGroup || result; 84 | resourceGroupName = result.newResourceGroup || result.name; 85 | } 86 | 87 | if (!result || result.newResourceGroup) { 88 | location = location || (await askLocation()); // if quickstart - location defined above 89 | spinner.start(`Creating resource group ${resourceGroupName} at ${location.name} (${location.id})`); 90 | result = await createResourceGroup(resourceGroupName, subscription, creds, location.id); 91 | spinner.succeed(); 92 | } 93 | 94 | return result; 95 | } 96 | 97 | export async function askLocation(): Promise { 98 | const res = await filteredList(locations, locationPromptOptions); 99 | return res.location; 100 | } 101 | 102 | function resourceGroupExists(resourceGroupList: ResourceGroupDetails[]) { 103 | return async (name: string) => { 104 | return Promise.resolve(!resourceGroupList.find((rg) => rg.name === name)); 105 | }; 106 | } 107 | 108 | async function resourceGroupNameGenerator(initialName: string, resourceGroupList: ResourceGroupDetails[]) { 109 | return await generateName(initialName, resourceGroupExists(resourceGroupList)); 110 | } 111 | -------------------------------------------------------------------------------- /src/util/azure/subscription.spec.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { selectSubscription } from './subscription'; 6 | import { LinkedSubscription } from '@azure/ms-rest-nodeauth'; 7 | import { AddOptions } from '../shared/types'; 8 | 9 | jest.mock('inquirer'); 10 | 11 | // AddOptions, Logger 12 | 13 | const SUBID = '124'; 14 | const SUBNAME = 'name'; 15 | 16 | const optionsMock = { 17 | subscriptionId: SUBID, 18 | subscriptionName: SUBNAME, 19 | }; 20 | 21 | // const optionsMockEmpty = {}; 22 | 23 | const loggerMock = { 24 | debug: jest.fn(), 25 | info: jest.fn(), 26 | warn: jest.fn(), 27 | error: jest.fn(), 28 | fatal: jest.fn(), 29 | }; 30 | 31 | // TODO check loggerMack for calls and args, need to reset mock before every test though 32 | // mockReset() 33 | 34 | describe('subscription', () => { 35 | beforeEach(() => { 36 | loggerMock.warn.mockClear(); 37 | }); 38 | 39 | test('should throw error when input is an EMPTY array', async () => { 40 | const errorMessage = 41 | "You don't have any active subscriptions. " + 42 | 'Head to https://azure.com/free and sign in. From there you can create a new subscription ' + 43 | 'and then you can come back and try again.'; 44 | 45 | expect(selectSubscription([], optionsMock, loggerMock)).rejects.toEqual(new Error(errorMessage)); 46 | }); 47 | 48 | test('provided sub id DOES NOT match when provided in options', async () => { 49 | const subs = >[ 50 | { 51 | id: '456', 52 | name: 'a sub', 53 | }, 54 | ]; 55 | 56 | selectSubscription(subs, optionsMock, loggerMock); 57 | 58 | const warnCalledTwice = loggerMock.warn.mock.calls.length === 2; 59 | 60 | expect(loggerMock.warn.mock.calls[0][0]).toBe(`The provided subscription ID does not exist.`); 61 | expect(loggerMock.warn.mock.calls[1][0]).toBe(`Using subscription ${subs[0].name} - ${subs[0].id}`); 62 | expect(warnCalledTwice).toBeTruthy(); 63 | }); 64 | 65 | test('should return first subscriptions id, if only ONE subscription', async () => { 66 | const singleSubscription = { id: SUBID, name: SUBNAME }; 67 | 68 | const subs = >[singleSubscription]; 69 | const actual = await selectSubscription(subs, optionsMock, loggerMock); 70 | const warnNotCalled = loggerMock.warn.mock.calls.length === 0; 71 | 72 | expect(warnNotCalled).toBeTruthy(); 73 | expect(actual).toEqual(singleSubscription.id); 74 | }); 75 | 76 | test('should throw error when input is undefined', async () => { 77 | const errorMessage = 78 | 'API returned no subscription IDs. It should. ' + 79 | "Log in to https://portal.azure.com and see if there's something wrong with your account."; 80 | 81 | // this one looks a bit weird because method is `async`, otherwise throwError() helper should be used 82 | expect(selectSubscription(undefined, optionsMock, loggerMock)).rejects.toEqual(new Error(errorMessage)); 83 | }); 84 | 85 | test('should prompt user to select a subscription if more than one subscription', async () => { 86 | const expected = 'subMock'; // check inquirer.js at __mocks__ at root level 87 | 88 | const subs = >[ 89 | { id: 'abc', name: 'subMock' }, 90 | { id: '123', name: 'sub2' }, 91 | ]; 92 | const actual = await selectSubscription(subs, optionsMock, loggerMock); 93 | 94 | // TODO verify that prompt is being invoked 95 | 96 | expect(actual).toEqual(expected); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/util/azure/subscription.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { LinkedSubscription } from '@azure/ms-rest-nodeauth'; 6 | import { prompt } from 'inquirer'; 7 | import { AddOptions, Logger } from '../shared/types'; 8 | 9 | export async function selectSubscription( 10 | subs: LinkedSubscription[] | undefined, 11 | options: AddOptions, 12 | logger: Logger 13 | ): Promise { 14 | if (Array.isArray(subs)) { 15 | if (subs.length === 0) { 16 | throw new Error( 17 | "You don't have any active subscriptions. " + 18 | 'Head to https://azure.com/free and sign in. From there you can create a new subscription ' + 19 | 'and then you can come back and try again.' 20 | ); 21 | } 22 | 23 | const subProvided = !!options.subscriptionId || !!options.subscriptionName; 24 | const foundSub = subs.find((sub) => { 25 | // TODO: provided id and name might be of different subscriptions or one with typo 26 | return sub.id === options.subscriptionId || sub.name === options.subscriptionName; 27 | }); 28 | 29 | if (foundSub) { 30 | return foundSub.id; 31 | } else if (subProvided) { 32 | logger.warn(`The provided subscription ID does not exist.`); 33 | } 34 | 35 | if (subs.length === 1) { 36 | if (subProvided) { 37 | logger.warn(`Using subscription ${subs[0].name} - ${subs[0].id}`); 38 | } 39 | return subs[0].id; 40 | } else { 41 | const { sub } = await prompt<{ sub: any }>([ 42 | { 43 | type: 'list', 44 | name: 'sub', 45 | choices: subs.map((choice) => ({ 46 | name: `${choice.name} – ${choice.id}`, 47 | value: choice.id, 48 | })), 49 | message: 'Under which subscription should we put this static site?', 50 | }, 51 | ]); 52 | return sub; 53 | } 54 | } 55 | 56 | throw new Error( 57 | 'API returned no subscription IDs. It should. ' + 58 | "Log in to https://portal.azure.com and see if there's something wrong with your account." 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/util/prompt/__mocks__/name-generator.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export function generateName() { 6 | return Promise.resolve('mockname'); 7 | } 8 | -------------------------------------------------------------------------------- /src/util/prompt/confirm.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { prompt } from 'inquirer'; 6 | 7 | export async function confirm(message: string, confirmByDefault: boolean = false): Promise { 8 | const { ok } = await prompt<{ ok: any }>([ 9 | { 10 | type: 'confirm', 11 | name: 'ok', 12 | default: confirmByDefault, 13 | message, 14 | }, 15 | ]); 16 | return ok; 17 | } 18 | -------------------------------------------------------------------------------- /src/util/prompt/list.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as inquirer from 'inquirer'; 6 | 7 | const fuzzy = require('fuzzy'); 8 | 9 | export interface PromptOptions { 10 | name?: string; 11 | message: string; 12 | default?: string; 13 | defaultGenerator?: (name: string) => Promise; 14 | title?: string; 15 | validate?: any; 16 | id: string; 17 | } 18 | 19 | export interface ListItem { 20 | name: string; // display name 21 | id?: string; 22 | } 23 | 24 | inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt')); 25 | 26 | export async function filteredList(list: ListItem[], listOptions: PromptOptions, newItemOptions?: PromptOptions) { 27 | if (!list || list.length === 0) { 28 | return newItemOptions && newItemPrompt(newItemOptions); 29 | } 30 | 31 | const displayedList = newItemOptions ? [newItemOptions, ...list] : list; 32 | const result = await listPrompt(displayedList as ListItem[], listOptions.id, listOptions.message); 33 | 34 | if (newItemOptions && newItemOptions.id && result[listOptions.id].id === newItemOptions.id) { 35 | return newItemPrompt(newItemOptions); 36 | } 37 | return result; 38 | } 39 | 40 | export async function newItemPrompt(newItemOptions: PromptOptions) { 41 | let item, 42 | valid = true; 43 | const defaultValue = newItemOptions.defaultGenerator 44 | ? await newItemOptions.defaultGenerator(newItemOptions.default || '') 45 | : newItemOptions.default; 46 | do { 47 | item = await (inquirer as any).prompt({ 48 | type: 'input', 49 | name: newItemOptions.id, 50 | default: defaultValue, 51 | message: newItemOptions.message, 52 | }); 53 | 54 | if (newItemOptions.validate) { 55 | valid = await newItemOptions.validate(item[newItemOptions.id]); 56 | } 57 | } while (!valid); 58 | 59 | return item; 60 | } 61 | 62 | export function listPrompt(list: ListItem[], name: string, message: string) { 63 | return (inquirer as any).prompt({ 64 | type: 'autocomplete', 65 | name, 66 | source: searchList(list), 67 | message, 68 | }); 69 | } 70 | 71 | const isListItem = (elem: ListItem | { original: ListItem }): elem is ListItem => { 72 | return (<{ original: ListItem }>elem).original === undefined; 73 | }; 74 | 75 | function searchList(list: ListItem[]) { 76 | return (_: any, input: string) => { 77 | return Promise.resolve( 78 | fuzzy 79 | .filter(input, list, { 80 | extract(el: ListItem) { 81 | return el.name; 82 | }, 83 | }) 84 | .map((result: ListItem | { original: ListItem }) => { 85 | let original: ListItem; 86 | if (isListItem(result)) { 87 | original = result; 88 | } else { 89 | original = result.original; 90 | } 91 | return { name: original.name, value: original }; 92 | }) 93 | ); 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /src/util/prompt/name-generator.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export async function generateName(name: string, validate: (name: string) => Promise) { 6 | let valid = false; 7 | do { 8 | valid = await validate(name); 9 | if (!valid) { 10 | name = `${name}${Math.ceil(Math.random() * 100)}`; 11 | } 12 | } while (!valid); 13 | return name; 14 | } 15 | -------------------------------------------------------------------------------- /src/util/prompt/spinner.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as chalk from 'chalk'; 6 | const ora = require('ora'); 7 | 8 | export const spinner = ora({ 9 | text: 'Rounding up all the reptiles', 10 | spinner: { 11 | frames: [chalk.red('▌'), chalk.green('▀'), chalk.yellow('▐'), chalk.blue('▄')], 12 | interval: 100, 13 | }, 14 | }); 15 | 16 | export function spin(msg?: string) { 17 | return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { 18 | const originalMethod = descriptor.value; 19 | descriptor.value = async function () { 20 | spinner.start(msg); 21 | let result; 22 | try { 23 | result = await originalMethod.apply(this, arguments); 24 | } catch (e) { 25 | spinner.fail(e); 26 | } 27 | spinner.succeed(); 28 | return result; 29 | }; 30 | return descriptor; 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/util/shared/types.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { JsonObject } from '@angular-devkit/core'; 6 | 7 | export interface Logger { 8 | debug(message: string, metadata?: JsonObject): void; 9 | info(message: string, metadata?: JsonObject): void; 10 | warn(message: string, metadata?: JsonObject): void; 11 | error(message: string, metadata?: JsonObject): void; 12 | fatal(message: string, metadata?: JsonObject): void; 13 | } 14 | 15 | export interface AddOptions { 16 | project: string; 17 | manual?: boolean; 18 | subscriptionId?: string; 19 | subscriptionName?: string; 20 | resourceGroup?: string; 21 | account?: string; 22 | location?: string; 23 | 'resource-allocation'?: boolean; 24 | config?: boolean; 25 | dry?: boolean; 26 | telemetry?: boolean; 27 | '--'?: string[]; 28 | } 29 | -------------------------------------------------------------------------------- /src/util/workspace/angular-json.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { SchematicsException, Tree } from '@angular-devkit/schematics'; 6 | import { virtualFs, workspaces } from '@angular-devkit/core'; 7 | import { ProjectDefinition } from '@angular-devkit/core/src/workspace/definitions'; 8 | 9 | export function createHost(tree: Tree): workspaces.WorkspaceHost { 10 | return { 11 | async readFile(path: string): Promise { 12 | const data = tree.read(path); 13 | if (!data) { 14 | throw new SchematicsException('File not found.'); 15 | } 16 | return virtualFs.fileBufferToString(data); 17 | }, 18 | async writeFile(path: string, data: string): Promise { 19 | return tree.overwrite(path, data); 20 | }, 21 | async isDirectory(path: string): Promise { 22 | return !tree.exists(path) && tree.getDir(path).subfiles.length > 0; 23 | }, 24 | async isFile(path: string): Promise { 25 | return tree.exists(path); 26 | }, 27 | }; 28 | } 29 | 30 | export async function getWorkspace(tree: Tree, host: workspaces.WorkspaceHost, path = '/') { 31 | const { workspace } = await workspaces.readWorkspace(path, host); 32 | return workspace; 33 | } 34 | 35 | export class AngularWorkspace { 36 | tree: Tree; 37 | workspace: workspaces.WorkspaceDefinition; 38 | host: workspaces.WorkspaceHost; 39 | schema: workspaces.WorkspaceDefinition; 40 | content: string; 41 | projectName: string; 42 | project: ProjectDefinition; 43 | target: string; 44 | configuration: string; 45 | path: string; 46 | 47 | constructor(tree: Tree) { 48 | this.tree = tree; 49 | this.target = 'build'; // TODO allow configuration of other options 50 | this.configuration = 'production'; 51 | } 52 | 53 | async getWorkspaceData(options: any) { 54 | this.host = createHost(this.tree); 55 | this.workspace = await getWorkspace(this.tree, this.host); 56 | this.projectName = this.getProjectName(options); 57 | this.project = this.getProject(options); 58 | this.path = this.getOutputPath(options); 59 | } 60 | 61 | getProjectName(options: any) { 62 | let projectName = options.project; 63 | 64 | if (!options.project && typeof this.workspace.extensions.defaultProject === 'string') { 65 | options.project = this.workspace.extensions.defaultProject; 66 | } 67 | 68 | if (!projectName) { 69 | throw new SchematicsException('No project selected and no default project name available in the workspace.'); 70 | } 71 | return projectName; 72 | } 73 | 74 | getProject(options: any) { 75 | const project = this.workspace.projects.get(this.projectName); 76 | if (!project) { 77 | throw new SchematicsException(`Project "${this.projectName}" is not defined in this workspace`); 78 | } 79 | 80 | if (project.extensions.projectType !== 'application') { 81 | throw new SchematicsException(`Cannot set up deployment for a project that is not of type "application"`); 82 | } 83 | 84 | return project; 85 | } 86 | 87 | getOutputPath(options: any): string { 88 | const buildTarget = this.project.targets.get('build'); 89 | if (!buildTarget) { 90 | throw new SchematicsException(`Build target does not exist.`); 91 | } 92 | 93 | const outputPath = 94 | typeof buildTarget.options?.outputPath === 'string' 95 | ? buildTarget?.options?.outputPath 96 | : `dist/${this.projectName}`; 97 | return outputPath; 98 | } 99 | 100 | getArchitect() { 101 | return this.project.targets; 102 | } 103 | 104 | async updateTree() { 105 | await workspaces.writeWorkspace(this.workspace, this.host); 106 | } 107 | 108 | async addLogoutArchitect() { 109 | this.getArchitect().set('azureLogout', { 110 | builder: '@azure/ng-deploy:logout', 111 | }); 112 | 113 | await this.updateTree(); 114 | } 115 | 116 | async addDeployArchitect() { 117 | this.getArchitect().set('deploy', { 118 | builder: '@azure/ng-deploy:deploy', 119 | options: { 120 | host: 'Azure', 121 | type: 'static', 122 | config: 'azure.json', 123 | }, 124 | }); 125 | 126 | await this.updateTree(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/util/workspace/azure-json.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { SchematicsException, Tree } from '@angular-devkit/schematics'; 6 | 7 | const azureJsonFile = 'azure.json'; 8 | 9 | export interface AzureDeployConfig { 10 | subscription: string; 11 | resourceGroupName: string; 12 | account: string; 13 | } 14 | 15 | export interface AppDeployConfig { 16 | project: string; 17 | target: string; 18 | path: string; 19 | configuration?: string; 20 | } 21 | 22 | export interface AzureHostingConfig { 23 | azureHosting: AzureDeployConfig; 24 | app: AppDeployConfig; 25 | } 26 | 27 | export interface AzureJSON { 28 | hosting: AzureHostingConfig[]; 29 | } 30 | 31 | export function readAzureJson(tree: Tree): AzureJSON | null { 32 | return tree.exists(azureJsonFile) ? safeReadJSON(azureJsonFile, tree) : null; 33 | } 34 | 35 | export function generateAzureJson(tree: Tree, appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) { 36 | const azureJson: AzureJSON = readAzureJson(tree) || emptyAzureJson(); 37 | const existingHostingConfigIndex = getAzureHostingConfigIndex(azureJson, appDeployConfig.project); 38 | const hostingConfig = generateHostingConfig(appDeployConfig, azureDeployConfig); 39 | 40 | if (existingHostingConfigIndex >= 0) { 41 | azureJson.hosting[existingHostingConfigIndex] = hostingConfig; 42 | } else { 43 | azureJson.hosting.push(hostingConfig); 44 | } 45 | 46 | overwriteIfExists(tree, azureJsonFile, stringifyFormatted(azureJson)); 47 | } 48 | 49 | export function getAzureHostingConfig(azureJson: AzureJSON, projectName: string): AzureHostingConfig | undefined { 50 | return azureJson.hosting.find((config) => config.app.project === projectName); 51 | } 52 | 53 | function getAzureHostingConfigIndex(azureJson: AzureJSON, project: string): number { 54 | return azureJson.hosting.findIndex((config) => config.app.project === project); 55 | } 56 | 57 | const overwriteIfExists = (tree: Tree, path: string, content: string) => { 58 | if (tree.exists(path)) { 59 | tree.overwrite(path, content); 60 | } else { 61 | tree.create(path, content); 62 | } 63 | }; 64 | 65 | const stringifyFormatted = (obj: any) => JSON.stringify(obj, null, 2); 66 | 67 | function emptyAzureJson() { 68 | return { 69 | hosting: [], 70 | }; 71 | } 72 | 73 | function safeReadJSON(path: string, tree: Tree) { 74 | try { 75 | const json = tree.read(path); 76 | if (!json) { 77 | throw new Error(); 78 | } 79 | return JSON.parse(json.toString()); 80 | } catch (e) { 81 | throw new SchematicsException(`Error when parsing ${path}: ${e.message}`); 82 | } 83 | } 84 | 85 | function generateHostingConfig(appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) { 86 | return { 87 | app: appDeployConfig, 88 | azureHosting: azureDeployConfig, 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "tsconfig", 4 | "lib": ["es2018", "dom"], 5 | "outDir": "out", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "declaration": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitAny": true, 14 | "noImplicitThis": true, 15 | "noUnusedParameters": false, 16 | "noUnusedLocals": true, 17 | "rootDir": "src/", 18 | "skipDefaultLibCheck": true, 19 | "skipLibCheck": true, 20 | "sourceMap": true, 21 | "strictNullChecks": true, 22 | "target": "es6", 23 | "types": ["jest", "node"] 24 | }, 25 | "include": ["src/**/*"], 26 | "exclude": ["**/*.spec.ts", "src/*/files/**/*", "**/__mocks__/*", "**/__tests__/*"] 27 | } 28 | --------------------------------------------------------------------------------