├── .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 | [](https://www.npmjs.com/package/@azure/ng-deploy)
4 | [](https://dev.azure.com/devrel/chris-noring-test/_build/latest?definitionId=19&branchName=master)
5 | [](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 |
--------------------------------------------------------------------------------