├── .azure-pipelines ├── azure-pipeline.yml ├── common-steps.yml └── release-pipeline.yml ├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS └── workflows │ └── autoAssignABTT.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── SECURITY.md ├── app ├── app.ts ├── exec │ ├── build │ │ ├── default.ts │ │ ├── list.ts │ │ ├── queue.ts │ │ ├── show.ts │ │ └── tasks │ │ │ ├── _resources │ │ │ ├── icon.png │ │ │ ├── sample.js │ │ │ └── sample.ps1 │ │ │ ├── create.ts │ │ │ ├── default.ts │ │ │ ├── delete.ts │ │ │ ├── list.ts │ │ │ └── upload.ts │ ├── default.ts │ ├── extension │ │ ├── _lib │ │ │ ├── extension-composer-factory.ts │ │ │ ├── extension-composer.ts │ │ │ ├── extensioninfo.ts │ │ │ ├── interfaces.ts │ │ │ ├── loc.ts │ │ │ ├── manifest.ts │ │ │ ├── merger.ts │ │ │ ├── publish.ts │ │ │ ├── targets │ │ │ │ ├── Microsoft.TeamFoundation.Server.Integration │ │ │ │ │ └── composer.ts │ │ │ │ ├── Microsoft.TeamFoundation.Server │ │ │ │ │ └── composer.ts │ │ │ │ ├── Microsoft.VisualStudio.Offer │ │ │ │ │ └── composer.ts │ │ │ │ ├── Microsoft.VisualStudio.Services.Cloud.Integration │ │ │ │ │ └── composer.ts │ │ │ │ ├── Microsoft.VisualStudio.Services.Cloud │ │ │ │ │ └── composer.ts │ │ │ │ ├── Microsoft.VisualStudio.Services.Integration │ │ │ │ │ └── composer.ts │ │ │ │ └── Microsoft.VisualStudio.Services │ │ │ │ │ ├── composer.ts │ │ │ │ │ └── vso-manifest-builder.ts │ │ │ ├── utils.ts │ │ │ ├── vsix-manifest-builder.ts │ │ │ └── vsix-writer.ts │ │ ├── create.ts │ │ ├── default.ts │ │ ├── init.ts │ │ ├── install.ts │ │ ├── isvalid.ts │ │ ├── publish.ts │ │ ├── resources │ │ │ ├── create.ts │ │ │ └── default.ts │ │ ├── share.ts │ │ ├── show.ts │ │ ├── unpublish.ts │ │ └── unshare.ts │ ├── login.ts │ ├── logout.ts │ ├── reset.ts │ ├── version.ts │ └── workitem │ │ ├── create.ts │ │ ├── default.ts │ │ ├── query.ts │ │ ├── show.ts │ │ └── update.ts ├── lib │ ├── arguments.ts │ ├── command.ts │ ├── common.ts │ ├── connection.ts │ ├── credstore.ts │ ├── diskcache.ts │ ├── dynamicVersion.ts │ ├── errorhandler.ts │ ├── fsUtils.ts │ ├── jsonvalidate.ts │ ├── loader.ts │ ├── outputs.ts │ ├── promiseUtils.ts │ ├── qread.ts │ ├── tfcommand.ts │ ├── trace.ts │ └── version.ts └── tfx-cli.js ├── docs ├── basicAuthEnabled.png ├── builds.md ├── buildtasks.md ├── configureBasicAuth.md ├── configureBasicAuthFeature.png ├── contributions.md ├── extensions.md ├── help-screen.png ├── tfsAuth.png └── workitems.md ├── gulp.cmd ├── package-lock.json ├── package.json ├── tests └── commandline.ts ├── tsconfig.json ├── tsd.json └── typings ├── archiver └── archiver.d.ts ├── json-in-place └── json-in-place.d.ts ├── onecolor └── onecolor.d.ts └── prompt └── prompt.d.ts /.azure-pipelines/azure-pipeline.yml: -------------------------------------------------------------------------------- 1 | # This Yaml Document has been converted by ESAI Yaml Pipeline Conversion Tool. 2 | # This pipeline will be extended to the OneESPT template 3 | # CI and PR build script 4 | # 5 | # There should be no deep magic here. The developer experience and CI experience 6 | # must remain as close to one another as possible. 7 | 8 | trigger: 9 | - master 10 | 11 | variables: 12 | - group: npm-tokens 13 | resources: 14 | repositories: 15 | - repository: 1ESPipelineTemplates 16 | type: git 17 | name: 1ESPipelineTemplates/1ESPipelineTemplates 18 | ref: refs/tags/release 19 | extends: 20 | template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates 21 | parameters: 22 | settings: 23 | skipBuildTagsForGitHubPullRequests: true 24 | # featureFlags: 25 | # autoBaseline: false 26 | # sdl: 27 | # baseline: 28 | # baselineSet: default 29 | # baselineFile: $(Build.SourcesDirectory)/.gdn/.gdnbaselines 30 | pool: 31 | name: 1ES-ABTT-Shared-Pool 32 | image: abtt-windows-2022 33 | os: windows 34 | stages: 35 | - stage: stage 36 | jobs: 37 | - job: job 38 | templateContext: 39 | outputs: 40 | - output: pipelineArtifact 41 | displayName: 'Publish tfx-cli package to pipeline artifacts' 42 | targetPath: '$(Build.ArtifactStagingDirectory)' 43 | artifactType: 'pipeline' 44 | artifactName: 'tfx-cli-package' 45 | steps: 46 | - template: /.azure-pipelines/common-steps.yml@self 47 | 48 | # Install node 16 for CodeQL 3000 49 | - task: NodeTool@0 50 | displayName: Use node 16 51 | inputs: 52 | versionSpec: "16.x" 53 | -------------------------------------------------------------------------------- /.azure-pipelines/common-steps.yml: -------------------------------------------------------------------------------- 1 | # Common steps template 2 | # 3 | # Things which happen regardless of CI, PR, or release builds 4 | steps: 5 | - checkout: self 6 | clean: true 7 | 8 | - task: NodeTool@0 9 | displayName: Use node 10 10 | inputs: 11 | versionSpec: "10.x" 12 | 13 | - task: NpmAuthenticate@0 14 | inputs: 15 | workingFile: .npmrc 16 | 17 | - script: npm i -g npm@6.14.12 --force 18 | displayName: Use npm version 6.14.12 19 | 20 | - bash: | 21 | npm ci 22 | npm run build 23 | displayName: Build TFX CLI 24 | 25 | # Generate a pipeline artifact so we can publish the package manually if there are issues with automation 26 | - bash: | 27 | npm pack 28 | cp *.tgz '$(Build.ArtifactStagingDirectory)' 29 | displayName: Run npm-pack and copy to ArtifactStagingDirectory 30 | -------------------------------------------------------------------------------- /.azure-pipelines/release-pipeline.yml: -------------------------------------------------------------------------------- 1 | # This Yaml Document has been converted by ESAI Yaml Pipeline Conversion Tool. 2 | # This pipeline will be extended to the OneESPT template 3 | 4 | trigger: none 5 | 6 | variables: 7 | - group: npm-tokens 8 | resources: 9 | repositories: 10 | - repository: 1esPipelines 11 | type: git 12 | name: 1ESPipelineTemplates/1ESPipelineTemplates 13 | ref: refs/tags/release 14 | extends: 15 | template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines 16 | parameters: 17 | settings: 18 | skipBuildTagsForGitHubPullRequests: true 19 | 20 | pool: 21 | name: 1ES-ABTT-Shared-Pool 22 | image: abtt-windows-2022 23 | os: windows 24 | stages: 25 | - stage: Build 26 | displayName: Build the tfx-cli npm package 27 | jobs: 28 | - job: build 29 | templateContext: 30 | outputs: 31 | - output: pipelineArtifact 32 | displayName: 'Publish tfx-cli package to pipeline artifacts' 33 | targetPath: '$(Build.ArtifactStagingDirectory)' 34 | artifactType: 'pipeline' 35 | artifactName: 'tfx-cli-package' 36 | steps: 37 | - template: /.azure-pipelines/common-steps.yml@self 38 | - ${{ if in(variables['build.reason'], 'IndividualCI', 'BatchedCI', 'Manual') }}: 39 | # Validate and publish packages 40 | # The npm cli will replace ${NPM_TOKEN} with the contents of the NPM_TOKEN environment variable. 41 | - bash: | 42 | echo '//registry.npmjs.org/:_authToken=$(npm-automation.token)' > .npmrc 43 | 44 | npm publish --ignore-scripts --tag prerelease 45 | exit_status=$? 46 | if [ $exit_status -eq 1 ]; then 47 | echo "##vso[task.logissue type=warning]Publishing TFX CLI was unsuccessful" 48 | echo "##vso[task.complete result=SucceededWithIssues;]" 49 | fi 50 | 51 | rm .npmrc 52 | displayName: Publish TFX CLI to npm 53 | condition: eq(variables['Build.SourceBranchName'], 'master') 54 | 55 | # Install node 16 for CodeQL 3000 56 | - task: NodeTool@0 57 | displayName: Use node 16 58 | inputs: 59 | versionSpec: "16.x" 60 | - stage: Release 61 | displayName: Release to Latest 62 | trigger: manual 63 | jobs: 64 | - job: Release 65 | displayName: Release to Latest 66 | steps: 67 | - checkout: self 68 | clean: true 69 | 70 | - task: NodeTool@0 71 | displayName: Use node 16 72 | inputs: 73 | versionSpec: "16.x" 74 | - script: npm i -g npm@8.19.4 --force 75 | displayName: Use npm version 8.19.4 76 | 77 | - bash: | 78 | echo '//registry.npmjs.org/:_authToken=$(npm-automation.token)' > .npmrc 79 | # Run the npm command and capture the output 80 | output=$(npm pkg get name version) 81 | echo "${output}" 82 | 83 | # Extract the name and version values using shell parameter expansion 84 | name=$(echo "$output" | grep -o '"name":\s*"[^"]*"' | sed 's/"name":\s*"\([^"]*\)"/\1/') 85 | version=$(echo "$output" | grep -o '"version":\s*"[^"]*"' | sed 's/"version":\s*"\([^"]*\)"/\1/') 86 | 87 | # Save them in a variable in the format name@version 88 | result="${name}@${version}" 89 | 90 | # Combine them in the format name@version 91 | echo "${result}" 92 | 93 | npm dist-tag add "$result" latest 94 | exit_status=$? 95 | if [ $exit_status -eq 1 ]; then 96 | echo "##vso[task.logissue type=error]Upgrading TFX CLI to latest was unsuccessful" 97 | echo "##vso[task.complete result=Failed;]" 98 | exit 1 99 | fi 100 | 101 | npm dist-tag remove "$result" prerelease 102 | exit_status=$? 103 | if [ $exit_status -eq 1 ]; then 104 | echo "##vso[task.logissue type=error]Removing prerelease for TFX CLI was unsuccessful" 105 | echo "##vso[task.complete result=SucceededWithIssues;]" 106 | fi 107 | rm .npmrc 108 | displayName: Publish TFX CLI to npm 109 | 110 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tfx dev environment", 3 | "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", 4 | "features": {}, 5 | "postCreateCommand": "npm install", 6 | "customizations": { 7 | "vscode": { 8 | "settings": { 9 | "editor.formatOnSave": true 10 | }, 11 | "extensions": [ 12 | "DavidAnson.vscode-markdownlint", 13 | "dbaeumer.vscode-eslint", 14 | "editorconfig.editorconfig", 15 | "GitHub.copilot", 16 | "GitHub.copilot-chat", 17 | "ms-vscode.js-debug-nightly", 18 | "ms-vscode.vscode-typescript-next" 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=false 3 | * text eol=lf -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global rule: 2 | * @microsoft/azure-pipelines-tasks-and-agent @microsoft/azure-pipelines-platform 3 | -------------------------------------------------------------------------------- /.github/workflows/autoAssignABTT.yml: -------------------------------------------------------------------------------- 1 | name: Auto Assign ABTT to Project Board 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | assign_one_project: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | name: Assign to ABTT Project 14 | steps: 15 | - name: "Add triage and area labels" 16 | uses: actions-ecosystem/action-add-labels@v1 17 | with: 18 | github_token: ${{ secrets.GITHUB_TOKEN }} 19 | labels: | 20 | Area: tfx-cli 21 | triage 22 | 23 | - name: "Assign newly opened issues to project board" 24 | uses: actions/add-to-project@v0.4.1 25 | with: 26 | project-url: https://github.com/orgs/microsoft/projects/755 27 | github-token: ${{ secrets.ABTT_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build and Packaging 2 | _build 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | 32 | # VS Code Settings 33 | .settings 34 | 35 | # Generated by nexe 36 | tmp/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | app/ 2 | definitions/ 3 | docs/ 4 | tests/ 5 | _build/tests 6 | gulpfile.js 7 | tsconfig.json 8 | tmp/ 9 | .vscode/ 10 | .github/ 11 | .azure-pipelines/ 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://pkgs.dev.azure.com/mseng/PipelineTools/_packaging/PipelineTools_PublicPackages/npm/registry/ 2 | 3 | always-auth=true -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "WorkItem Query", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/app/app.ts", 9 | "stopOnEntry": false, 10 | "args": [ 11 | "workitem", "query", "--query", "select * from workitemlinks where [source].[system.assignedto] = @me and [target].[system.assignedto] <> @me" 12 | ], 13 | "cwd": "${workspaceRoot}", 14 | "preLaunchTask": null, 15 | "runtimeExecutable": null, 16 | "runtimeArgs": [ 17 | "--nolazy" 18 | ], 19 | "env": { 20 | "NODE_ENV": "development", 21 | "TFX_TRACE": "1" 22 | }, 23 | "console": "internalConsole", 24 | "sourceMaps": true, 25 | "outDir": "${workspaceRoot}/_build" 26 | }, 27 | { 28 | "name": "Extension Create", 29 | "type": "node", 30 | "request": "launch", 31 | "program": "${workspaceRoot}/app/app.ts", 32 | "stopOnEntry": false, 33 | "args": [ 34 | 35 | "extension", "create", "--overrides-file", "../configs/release.json", "--manifest-globs", "vss-extension-release.json", "--no-prompt", "--json" 36 | 37 | // "extension", 38 | // "create", 39 | // // "--loc-root", 40 | // // "C:\\vso\\obj\\Debug.AnyCPU\\Vssf.WebPlatform\\MS.VS.Extension.WebPlatform\\resjson", 41 | // "--manifests", 42 | // "vss-charts.json", 43 | // "vss-web.json", 44 | // "--override", 45 | // "{\"version\": \"0.0.1\"}" 46 | ], 47 | "cwd": "C:\\dev\\MultiValueControlExtension\\dist", 48 | "preLaunchTask": null, 49 | "runtimeExecutable": null, 50 | "runtimeArgs": [ 51 | "--nolazy" 52 | ], 53 | "env": { 54 | "NODE_ENV": "development", 55 | "TFX_TRACE": "1" 56 | }, 57 | "console": "internalConsole", 58 | "sourceMaps": true, 59 | "outDir": "${workspaceRoot}/_build" 60 | }, 61 | { 62 | "name": "Resources Create", 63 | "type": "node", 64 | "request": "launch", 65 | "program": "${workspaceRoot}/app/app.ts", 66 | "stopOnEntry": false, 67 | "args": [ 68 | "extension", 69 | "resources", 70 | "create", 71 | "--manifests", 72 | "vss-web.json", 73 | "--override", 74 | "{\"version\": \"0.0.1\"}" 75 | ], 76 | "cwd": "C:\\vso\\src\\Vssf\\WebPlatform\\ExtensionPackages\\WebPlatform", 77 | "preLaunchTask": null, 78 | "runtimeExecutable": null, 79 | "runtimeArgs": [ 80 | "--nolazy" 81 | ], 82 | "env": { 83 | "NODE_ENV": "development", 84 | "TFX_TRACE": "1" 85 | }, 86 | "console": "internalConsole", 87 | "sourceMaps": true, 88 | "outDir": "${workspaceRoot}/_build" 89 | }, 90 | { 91 | "name": "HelpScreen", 92 | "type": "node", 93 | "request": "launch", 94 | "program": "${workspaceRoot}/app/app.ts", 95 | "stopOnEntry": false, 96 | "args": [ 97 | 98 | ], 99 | "cwd": "${workspaceRoot}", 100 | "preLaunchTask": null, 101 | "runtimeExecutable": null, 102 | "runtimeArgs": [ 103 | "--nolazy" 104 | ], 105 | "env": { 106 | "NODE_ENV": "development", 107 | "TFX_TRACE": "1" 108 | }, 109 | "console": "internalConsole", 110 | "sourceMaps": true, 111 | "outDir": "${workspaceRoot}/_build" 112 | } 113 | ] 114 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "**/.git": true, 5 | "**/.svn": true, 6 | "**/.hg": true, 7 | "**/.DS_Store": true, 8 | "tmp/": true 9 | }, 10 | "editor.insertSpaces": false, 11 | "typescript.tsdk": "./node_modules/typescript/lib" 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "command": "npm", 6 | "tasks": [ 7 | { 8 | "label": "build", 9 | "type": "shell", 10 | "args": [ 11 | "run", 12 | "build" 13 | ], 14 | "problemMatcher": [], 15 | "group": { 16 | "_id": "build", 17 | "isDefault": false 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Microsoft 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. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node CLI for Azure DevOps 2 | 3 | > NOTE: If you are looking for the new _Azure DevOps CLI_, see [vsts-cli](https://github.com/microsoft/vsts-cli) 4 | 5 | [![NPM version](https://badge.fury.io/js/tfx-cli.svg)](http://badge.fury.io/js/tfx-cli) 6 | 7 | This is a command-line utility for interacting with _Microsoft Team Foundation Server_ and _Azure DevOps Services_ (formerly _VSTS_). It is cross platform and supported on _Windows_, _MacOS_, and _Linux_. 8 | 9 | ## Setup 10 | 11 | First, download and install [Node.js](http://nodejs.org) 4.0.x or later and npm (included with the installer) 12 | 13 | ### Linux/OSX 14 | 15 | ```bash 16 | sudo npm install -g tfx-cli 17 | ``` 18 | 19 | ### Windows 20 | 21 | ```bash 22 | npm install -g tfx-cli 23 | ``` 24 | 25 | ## Commands 26 | 27 | To see the list of commands: 28 | 29 | ```bash 30 | tfx 31 | ``` 32 | 33 | For help with an individual command: 34 | 35 | ```bash 36 | tfx --help 37 | ``` 38 | 39 | > Help info is dynamically generated, so it should always be the most up-to-date authority. 40 | 41 | ### Command sets 42 | 43 | * `tfx build` ([builds](docs/builds.md)): Queue, view, and get details for builds. 44 | * `tfx build tasks` ([build tasks](docs/buildtasks.md)): Create, list, upload and delete build tasks. 45 | * `tfx extension` ([extensions](docs/extensions.md)): Package, manage, publish _Team Foundation Server_ / _Azure DevOps_ extensions. 46 | * `tfx workitem` ([work items](docs/workitems.md)): Create, query and view work items. 47 | 48 | ### Login 49 | 50 | To avoid providing credentials with every command, you can login once. Currently supported credential types: _Personal Access Tokens_ and _basic authentication credentials_. 51 | 52 | > NTLM support is under consideration 53 | > 54 | > Warning! Using this feature will store your login credentials on disk in plain text. 55 | > 56 | > To skip certificate validation connecting to on-prem _Azure DevOps Server_ use the `--skip-cert-validation` parameter. 57 | 58 | #### Personal access token 59 | 60 | Start by [creating a Personal Access Token](http://roadtoalm.com/2015/07/22/using-personal-access-tokens-to-access-visual-studio-online) and paste it into the login command. 61 | 62 | ```bash 63 | ~$ tfx login 64 | Copyright Microsoft Corporation 65 | 66 | > Service URL: {url} 67 | > Personal access token: xxxxxxxxxxxx 68 | Logged in successfully 69 | ``` 70 | 71 | Examples of valid URLs are: 72 | 73 | * `https://marketplace.visualstudio.com` 74 | * `https://youraccount.visualstudio.com/DefaultCollection` 75 | 76 | #### Basic auth 77 | 78 | You can also use basic authentication by passing the `--auth-type basic` parameter (see [Configuring Basic Auth](docs/configureBasicAuth.md) for details). 79 | 80 | ### Settings cache 81 | 82 | To avoid providing options with every command, you can save them to a settings file by adding the `--save` flag. 83 | 84 | ```bash 85 | ~$ tfx build list --project MyProject --definition-name println --top 5 --save 86 | 87 | ... 88 | 89 | id : 1 90 | definition name : TestDefinition 91 | requested by : Teddy Ward 92 | status : NotStarted 93 | queue time : Fri Aug 21 2015 15:07:49 GMT-0400 (Eastern Daylight Time) 94 | 95 | ~$ tfx build list 96 | Copyright Microsoft Corporation 97 | 98 | ... 99 | 100 | id : 1 101 | definition name : TestDefinition 102 | requested by : Teddy Ward 103 | status : NotStarted 104 | queue time : Fri Aug 21 2015 15:07:49 GMT-0400 (Eastern Daylight Time) 105 | ``` 106 | 107 | If you used `--save` to set a default value for an option, you may need to override it by explicitly providing a different value. You can clear any saved settings by running `tfx reset`. 108 | 109 | ### Troubleshooting 110 | 111 | To see detailed tracing output, set a value for the `TFX_TRACE` environment variable and then run commands. This may provide clues to the issue and can be helpful when logging an issue. 112 | 113 | ### Troubleshooting on Linux/OSX 114 | 115 | ```bash 116 | export TFX_TRACE=1 117 | ``` 118 | 119 | ### Troubleshooting on Windows 120 | 121 | ```bash 122 | set TFX_TRACE=1 123 | ``` 124 | 125 | #### PowerShell 126 | 127 | ```bash 128 | $env:TFX_TRACE=1 129 | ``` 130 | 131 | ## Contributing 132 | 133 | We accept contributions and fixes via Pull Requests. Please read the [Contributions guide](docs/contributions.md) for more details. 134 | 135 | ## Code of Conduct 136 | 137 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 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. 138 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/app.ts: -------------------------------------------------------------------------------- 1 | import command = require("./lib/command"); 2 | import common = require("./lib/common"); 3 | import errHandler = require("./lib/errorhandler"); 4 | import loader = require("./lib/loader"); 5 | import path = require("path"); 6 | 7 | function patchPromisify() { 8 | // Monkey-patch promisify if we are NodeJS <8.0 or Chakra 9 | const nodeVersion = process.version.substr(1); 10 | 11 | // Chakra has a compatibility bug that causes promisify to not work. 12 | // See https://github.com/nodejs/node-chakracore/issues/395 13 | const jsEngine = process["jsEngine"] || "v8"; 14 | const isChakra = jsEngine.indexOf("chakra") >= 0; 15 | if (isChakra) { 16 | require("util").promisify = undefined; 17 | } 18 | 19 | if (parseInt(nodeVersion.charAt(0)) < 8 || isChakra) { 20 | require("util.promisify/shim")(); 21 | } 22 | } 23 | 24 | // Set app root 25 | common.APP_ROOT = __dirname; 26 | 27 | namespace Bootstrap { 28 | export async function begin() { 29 | const cmd = await command.getCommand(); 30 | common.EXEC_PATH = cmd.execPath; 31 | 32 | const tfCommand = await loader.load(cmd.execPath, cmd.args); 33 | await tfCommand.showBanner(); 34 | const executor = await tfCommand.ensureInitialized(); 35 | return executor(cmd); 36 | } 37 | } 38 | 39 | patchPromisify(); 40 | 41 | Bootstrap.begin() 42 | .then(() => {}) 43 | .catch(reason => { 44 | errHandler.errLog(reason); 45 | }); 46 | -------------------------------------------------------------------------------- /app/exec/build/default.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand, CoreArguments } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | 4 | export interface BuildArguments extends CoreArguments { 5 | definitionId: args.IntArgument; 6 | definitionName: args.StringArgument; 7 | status: args.StringArgument; 8 | top: args.IntArgument; 9 | buildId: args.IntArgument; 10 | } 11 | 12 | export function getCommand(args: string[]): BuildBase { 13 | return new BuildBase(args); 14 | } 15 | 16 | export class BuildBase extends TfCommand { 17 | protected description = "Commands for managing Builds."; 18 | protected serverCommand = false; 19 | 20 | protected setCommandArgs(): void { 21 | super.setCommandArgs(); 22 | 23 | this.registerCommandArgument( 24 | "definitionId", 25 | "Build Definition ID", 26 | "Identifies a build definition.", 27 | args.IntArgument, 28 | null, 29 | ); 30 | this.registerCommandArgument( 31 | "definitionName", 32 | "Build Definition Name", 33 | "Name of a Build Definition.", 34 | args.StringArgument, 35 | null, 36 | ); 37 | this.registerCommandArgument("status", "Build Status", "Build status filter.", args.StringArgument, null); 38 | this.registerCommandArgument("top", "Number of builds", "Maximum number of builds to return.", args.IntArgument, null); 39 | this.registerCommandArgument("buildId", "Build ID", "Identifies a particular Build.", args.IntArgument); 40 | } 41 | 42 | public exec(cmd?: any): Promise { 43 | return this.getHelp(cmd); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/exec/build/list.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import buildBase = require("./default"); 4 | import buildClient = require("azure-devops-node-api/BuildApi"); 5 | import buildContracts = require("azure-devops-node-api/interfaces/BuildInterfaces"); 6 | 7 | import trace = require("../../lib/trace"); 8 | 9 | export function getCommand(args: string[]): BuildGetList { 10 | return new BuildGetList(args); 11 | } 12 | 13 | export class BuildGetList extends buildBase.BuildBase { 14 | protected description = "Get a list of builds."; 15 | protected serverCommand = true; 16 | 17 | protected getHelpArgs(): string[] { 18 | return ["definitionId", "definitionName", "status", "top", "project"]; 19 | } 20 | 21 | public async exec(): Promise { 22 | trace.debug("build-list.exec"); 23 | var buildapi: buildClient.IBuildApi = await this.webApi.getBuildApi(); 24 | 25 | return Promise.all([ 26 | this.commandArgs.project.val(), 27 | this.commandArgs.definitionId.val(), 28 | this.commandArgs.definitionName.val(), 29 | this.commandArgs.status.val(), 30 | this.commandArgs.top.val(), 31 | ]).then(values => { 32 | const [project, definitionId, definitionName, status, top] = values; 33 | var definitions: number[] = null; 34 | if (definitionId) { 35 | definitions = [definitionId as number]; 36 | } else if (definitionName) { 37 | trace.debug("No definition Id provided, checking for definitions with name " + definitionName); 38 | return buildapi 39 | .getDefinitions(project as string, definitionName as string) 40 | .then((defs: buildContracts.DefinitionReference[]) => { 41 | if (defs.length > 0) { 42 | definitions = [defs[0].id]; 43 | return this._getBuilds( 44 | buildapi, 45 | project as string, 46 | definitions, 47 | buildContracts.BuildStatus[status], 48 | top as number, 49 | ); 50 | } else { 51 | trace.debug("No definition found with name " + definitionName); 52 | throw new Error("No definition found with name " + definitionName); 53 | } 54 | }); 55 | } 56 | return this._getBuilds(buildapi, project as string, definitions, buildContracts.BuildStatus[status], top as number); 57 | }); 58 | } 59 | 60 | public friendlyOutput(data: buildContracts.Build[]): void { 61 | if (!data) { 62 | throw new Error("no build supplied"); 63 | } 64 | 65 | if (!(data instanceof Array)) { 66 | throw new Error("expected an array of builds"); 67 | } 68 | 69 | data.forEach(build => { 70 | trace.println(); 71 | trace.info("id : %s", build.id); 72 | trace.info("definition name : %s", build.definition ? build.definition.name : "unknown"); 73 | trace.info("requested by : %s", build.requestedBy ? build.requestedBy.displayName : "unknown"); 74 | trace.info("status : %s", buildContracts.BuildStatus[build.status]); 75 | trace.info("queue time : %s", build.queueTime ? build.queueTime.toJSON() : "unknown"); 76 | }); 77 | } 78 | 79 | private _getBuilds(buildapi: buildClient.IBuildApi, project: string, definitions: number[], status: string, top: number) { 80 | // I promise that this was as painful to write as it is to read 81 | return buildapi.getBuilds( 82 | project, 83 | definitions, 84 | null, 85 | null, 86 | null, 87 | null, 88 | null, 89 | null, 90 | buildContracts.BuildStatus[status], 91 | null, 92 | null, 93 | null, 94 | top, 95 | null, 96 | null, 97 | null, 98 | null, 99 | null, 100 | null, 101 | null, 102 | null, 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/exec/build/queue.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import buildBase = require("./default"); 4 | import buildClient = require("azure-devops-node-api/BuildApi"); 5 | import buildContracts = require("azure-devops-node-api/interfaces/BuildInterfaces"); 6 | import trace = require("../../lib/trace"); 7 | 8 | export function describe(): string { 9 | return "queue a build"; 10 | } 11 | 12 | export function getCommand(args: string[]): BuildQueue { 13 | return new BuildQueue(args); 14 | } 15 | 16 | export class BuildQueue extends buildBase.BuildBase { 17 | protected description = "Queue a build."; 18 | protected serverCommand = true; 19 | 20 | protected getHelpArgs(): string[] { 21 | return ["project", "definitionId", "definitionName"]; 22 | } 23 | 24 | public async exec(): Promise { 25 | var buildapi: buildClient.IBuildApi = await this.webApi.getBuildApi(); 26 | 27 | return this.commandArgs.project.val().then(project => { 28 | return this.commandArgs.definitionId.val(true).then(definitionId => { 29 | let definitionPromise: Promise; 30 | if (definitionId) { 31 | definitionPromise = buildapi.getDefinition(project, definitionId); 32 | } else { 33 | definitionPromise = this.commandArgs.definitionName.val().then(definitionName => { 34 | trace.debug("No definition id provided, Searching for definitions with name: " + definitionName); 35 | return buildapi 36 | .getDefinitions(project, definitionName) 37 | .then((definitions: buildContracts.DefinitionReference[]) => { 38 | if (definitions.length > 0) { 39 | var definition = definitions[0]; 40 | return definition; 41 | } else { 42 | trace.debug("No definition found with name " + definitionName); 43 | throw new Error("No definition found with name " + definitionName); 44 | } 45 | }); 46 | }); 47 | } 48 | return definitionPromise.then(definition => { 49 | return this._queueBuild(buildapi, definition, project); 50 | }); 51 | }); 52 | }); 53 | } 54 | 55 | public friendlyOutput(build: buildContracts.Build): void { 56 | if (!build) { 57 | throw new Error("no build supplied"); 58 | } 59 | 60 | trace.println(); 61 | trace.info("id : %s", build.id); 62 | trace.info("definition name : %s", build.definition ? build.definition.name : "unknown"); 63 | trace.info("requested by : %s", build.requestedBy ? build.requestedBy.displayName : "unknown"); 64 | trace.info("status : %s", buildContracts.BuildStatus[build.status]); 65 | trace.info("queue time : %s", build.queueTime ? build.queueTime.toJSON() : "unknown"); 66 | } 67 | 68 | private _queueBuild(buildapi: buildClient.IBuildApi, definition: buildContracts.DefinitionReference, project: string) { 69 | trace.debug("Queueing build..."); 70 | var build = { 71 | definition: definition, 72 | }; 73 | return buildapi.queueBuild(build, project); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/exec/build/show.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import buildBase = require("./default"); 4 | import buildClient = require("azure-devops-node-api/BuildApi"); 5 | import buildContracts = require("azure-devops-node-api/interfaces/BuildInterfaces"); 6 | import trace = require("../../lib/trace"); 7 | 8 | export function getCommand(args: string[]): BuildShow { 9 | return new BuildShow(args); 10 | } 11 | 12 | export class BuildShow extends buildBase.BuildBase { 13 | protected description = "Show build details."; 14 | protected serverCommand = true; 15 | 16 | protected getHelpArgs(): string[] { 17 | return ["project", "buildId"]; 18 | } 19 | 20 | public async exec(): Promise { 21 | trace.debug("build-show.exec"); 22 | var buildapi: buildClient.IBuildApi = await this.webApi.getBuildApi(); 23 | return this.commandArgs.project.val().then(project => { 24 | return this.commandArgs.buildId.val().then(buildId => { 25 | return buildapi.getBuild(project, buildId); 26 | }); 27 | }); 28 | } 29 | 30 | public friendlyOutput(build: buildContracts.Build): void { 31 | if (!build) { 32 | throw new Error("no build supplied"); 33 | } 34 | 35 | trace.println(); 36 | trace.info("id : %s", build.id); 37 | trace.info("definition name : %s", build.definition ? build.definition.name : "unknown"); 38 | trace.info("requested by : %s", build.requestedBy ? build.requestedBy.displayName : "unknown"); 39 | trace.info("status : %s", buildContracts.BuildStatus[build.status]); 40 | trace.info("queue time : %s", build.queueTime ? build.queueTime.toJSON() : "unknown"); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/exec/build/tasks/_resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/tfs-cli/833fcb22c650bc3b5499526ff1b813b14912e0ce/app/exec/build/tasks/_resources/icon.png -------------------------------------------------------------------------------- /app/exec/build/tasks/_resources/sample.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var tl = require('vso-task-lib'); 3 | 4 | var echo = new tl.ToolRunner(tl.which('echo', true)); 5 | 6 | var msg = tl.getInput('msg', true); 7 | echo.arg(msg); 8 | 9 | var cwd = tl.getPathInput('cwd', false); 10 | 11 | // will error and fail task if it doesn't exist 12 | tl.checkPath(cwd, 'cwd'); 13 | tl.cd(cwd); 14 | 15 | echo.exec({ failOnStdErr: false}) 16 | .then(function(code) { 17 | tl.exit(code); 18 | }) 19 | .fail(function(err) { 20 | console.error(err.message); 21 | tl.debug('taskRunner fail'); 22 | tl.exit(1); 23 | }) 24 | -------------------------------------------------------------------------------- /app/exec/build/tasks/_resources/sample.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param() 3 | 4 | # For more information on the Azure DevOps Task SDK: 5 | # https://github.com/Microsoft/vsts-task-lib 6 | Trace-VstsEnteringInvocation $MyInvocation 7 | try { 8 | # Set the working directory. 9 | $cwd = Get-VstsInput -Name cwd -Require 10 | Assert-VstsPath -LiteralPath $cwd -PathType Container 11 | Write-Verbose "Setting working directory to '$cwd'." 12 | Set-Location $cwd 13 | 14 | # Output the message to the log. 15 | Write-Host (Get-VstsInput -Name msg) 16 | } finally { 17 | Trace-VstsLeavingInvocation $MyInvocation 18 | } 19 | -------------------------------------------------------------------------------- /app/exec/build/tasks/create.ts: -------------------------------------------------------------------------------- 1 | import check = require("validator"); 2 | import fs = require("fs"); 3 | 4 | import path = require("path"); 5 | import shell = require("shelljs"); 6 | import tasksBase = require("./default"); 7 | import trace = require("../../../lib/trace"); 8 | import uuid = require("uuid"); 9 | 10 | export interface TaskCreateResult { 11 | taskPath: string; 12 | definition: TaskDefinition; 13 | } 14 | 15 | export interface TaskDefinition { 16 | id: string; 17 | name: string; 18 | friendlyName: string; 19 | description: string; 20 | author: string; 21 | helpMarkDown: string; 22 | category: string; 23 | visibility: string[]; 24 | demands: any[]; 25 | version: { Major: string; Minor: string; Patch: string }; 26 | minimumAgentVersion: string; 27 | instanceNameFormat: string; 28 | } 29 | 30 | export function getCommand(args: string[]): TaskCreate { 31 | return new TaskCreate(args); 32 | } 33 | 34 | export class TaskCreate extends tasksBase.BuildTaskBase { 35 | protected description = "Create files for new Build Task"; 36 | protected serverCommand = false; 37 | 38 | constructor(args: string[]) { 39 | super(args); 40 | } 41 | 42 | protected getHelpArgs(): string[] { 43 | return ["taskName", "friendlyName", "description", "author"]; 44 | } 45 | 46 | public async exec(): Promise { 47 | trace.debug("build-create.exec"); 48 | 49 | return Promise.all([ 50 | this.commandArgs.taskName.val(), 51 | this.commandArgs.friendlyName.val(), 52 | this.commandArgs.description.val(), 53 | this.commandArgs.author.val(), 54 | ]).then(values => { 55 | const [taskName, friendlyName, description, author] = values; 56 | if (!taskName || !check.isAlphanumeric(taskName)) { 57 | throw new Error("name is a required alphanumeric string with no spaces"); 58 | } 59 | 60 | if (!friendlyName || !check.isLength(friendlyName, 1, 40)) { 61 | throw new Error("friendlyName is a required string <= 40 chars"); 62 | } 63 | 64 | if (!description || !check.isLength(description, 1, 80)) { 65 | throw new Error("description is a required string <= 80 chars"); 66 | } 67 | 68 | if (!author || !check.isLength(author, 1, 40)) { 69 | throw new Error("author is a required string <= 40 chars"); 70 | } 71 | 72 | let ret = {}; 73 | 74 | // create definition 75 | trace.debug("creating folder for task"); 76 | let tp = path.join(process.cwd(), taskName); 77 | trace.debug(tp); 78 | shell.mkdir("-p", tp); 79 | trace.debug("created folder"); 80 | ret.taskPath = tp; 81 | 82 | trace.debug("creating definition"); 83 | let def: any = {}; 84 | def.id = uuid.v1(); 85 | trace.debug("id: " + def.id); 86 | def.name = taskName; 87 | trace.debug("name: " + def.name); 88 | def.friendlyName = friendlyName; 89 | trace.debug("friendlyName: " + def.friendlyName); 90 | def.description = description; 91 | trace.debug("description: " + def.description); 92 | def.author = author; 93 | trace.debug("author: " + def.author); 94 | 95 | def.helpMarkDown = "Replace with markdown to show in help"; 96 | def.category = "Utility"; 97 | def.visibility = ["Build", "Release"]; 98 | def.demands = []; 99 | def.version = { Major: "0", Minor: "1", Patch: "0" }; 100 | def.minimumAgentVersion = "1.95.0"; 101 | def.instanceNameFormat = taskName + " $(message)"; 102 | 103 | let cwdInput = { 104 | name: "cwd", 105 | type: "filePath", 106 | label: "Working Directory", 107 | defaultValue: "", 108 | required: false, 109 | helpMarkDown: "Current working directory when " + taskName + " is run.", 110 | }; 111 | 112 | let msgInput = { 113 | name: "msg", 114 | type: "string", 115 | label: "Message", 116 | defaultValue: "Hello World", 117 | required: true, 118 | helpMarkDown: "Message to echo out", 119 | }; 120 | 121 | def.inputs = [cwdInput, msgInput]; 122 | 123 | def.execution = { 124 | Node: { 125 | target: "sample.js", 126 | argumentFormat: "", 127 | }, 128 | PowerShell3: { 129 | target: "sample.ps1", 130 | }, 131 | }; 132 | 133 | ret.definition = def; 134 | 135 | trace.debug("writing definition file"); 136 | let defPath = path.join(tp, "task.json"); 137 | trace.debug(defPath); 138 | try { 139 | let defStr = JSON.stringify(def, null, 2); 140 | trace.debug(defStr); 141 | fs.writeFileSync(defPath, defStr); 142 | } catch (err) { 143 | throw new Error("Failed creating task: " + err.message); 144 | } 145 | trace.debug("created definition file."); 146 | 147 | let copyResource = function(fileName) { 148 | let src = path.join(__dirname, "_resources", fileName); 149 | trace.debug("src: " + src); 150 | let dest = path.join(tp, fileName); 151 | trace.debug("dest: " + dest); 152 | shell.cp(src, dest); 153 | trace.debug(fileName + " copied"); 154 | }; 155 | 156 | trace.debug("creating temporary icon"); 157 | copyResource("icon.png"); 158 | copyResource("sample.js"); 159 | copyResource("sample.ps1"); 160 | return ret; 161 | }); 162 | } 163 | 164 | public friendlyOutput(data: TaskCreateResult): void { 165 | if (!data) { 166 | throw new Error("no results"); 167 | } 168 | 169 | trace.println(); 170 | trace.success("created task @ %s", data.taskPath); 171 | let def = data.definition; 172 | trace.info("id : %s", def.id); 173 | trace.info("name: %s", def.name); 174 | trace.println(); 175 | trace.info("A temporary task icon was created. Replace with a 32x32 png with transparencies"); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /app/exec/build/tasks/default.ts: -------------------------------------------------------------------------------- 1 | import args = require("../../../lib/arguments"); 2 | import buildBase = require("../default"); 3 | 4 | export interface TaskArguments extends buildBase.BuildArguments { 5 | all: args.BooleanArgument; 6 | taskId: args.StringArgument; 7 | taskPath: args.ExistingDirectoriesArgument; 8 | overwrite: args.BooleanArgument; 9 | taskName: args.StringArgument; 10 | friendlyName: args.StringArgument; 11 | description: args.StringArgument; 12 | author: args.StringArgument; 13 | } 14 | 15 | export function getCommand(args: string[]): BuildTaskBase { 16 | return new BuildTaskBase(args); 17 | } 18 | 19 | export class BuildTaskBase extends buildBase.BuildBase { 20 | protected description = "Commands for managing Build Tasks."; 21 | protected serverCommand = false; 22 | 23 | protected setCommandArgs(): void { 24 | super.setCommandArgs(); 25 | 26 | this.registerCommandArgument("all", "All Tasks?", "Get all build tasks.", args.BooleanArgument, "false"); 27 | this.registerCommandArgument("taskId", "Task ID", "Identifies a particular Build Task.", args.StringArgument); 28 | this.registerCommandArgument("taskPath", "Task path", "Local path to a Build Task.", args.ExistingDirectoriesArgument, null); 29 | this.registerCommandArgument("taskZipPath", "Task zip path", "Local path to an already zipped task", args.StringArgument, null); 30 | this.registerCommandArgument("overwrite", "Overwrite?", "Overwrite existing Build Task.", args.BooleanArgument, "false"); 31 | 32 | this.registerCommandArgument("taskName", "Task Name", "Name of the Build Task.", args.StringArgument); 33 | this.registerCommandArgument("friendlyName", "Friendly Task Name", null, args.StringArgument); 34 | this.registerCommandArgument("description", "Task Description", null, args.StringArgument); 35 | this.registerCommandArgument("author", "Task Author", null, args.StringArgument); 36 | } 37 | 38 | public exec(cmd?: any): Promise { 39 | return this.getHelp(cmd); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/exec/build/tasks/delete.ts: -------------------------------------------------------------------------------- 1 | import agentContracts = require("azure-devops-node-api/interfaces/TaskAgentInterfaces"); 2 | import tasksBase = require("./default"); 3 | import trace = require("../../../lib/trace"); 4 | 5 | export function getCommand(args: string[]): BuildTaskDelete { 6 | return new BuildTaskDelete(args); 7 | } 8 | 9 | export class BuildTaskDelete extends tasksBase.BuildTaskBase { 10 | protected description = "Delete a Build Task."; 11 | protected serverCommand = true; 12 | 13 | protected getHelpArgs(): string[] { 14 | return ["taskId"]; 15 | } 16 | 17 | public async exec(): Promise { 18 | let agentApi = await this.webApi.getTaskAgentApi(this.connection.getCollectionUrl()); 19 | return this.commandArgs.taskId.val().then(taskId => { 20 | return agentApi.getTaskDefinitions(taskId).then(tasks => { 21 | if (tasks && tasks.length > 0) { 22 | trace.debug("Deleting task(s)..."); 23 | return agentApi.deleteTaskDefinition(taskId).then(() => { 24 | return { 25 | id: taskId, 26 | }; 27 | }); 28 | } else { 29 | trace.debug("No such task."); 30 | throw new Error("No task found with provided ID: " + taskId); 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | public friendlyOutput(data: agentContracts.TaskDefinition): void { 37 | trace.println(); 38 | trace.success("Task %s deleted successfully!", data.id); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/exec/build/tasks/list.ts: -------------------------------------------------------------------------------- 1 | import agentContracts = require("azure-devops-node-api/interfaces/TaskAgentInterfaces"); 2 | import tasksBase = require("./default"); 3 | import trace = require("../../../lib/trace"); 4 | 5 | export function getCommand(args: string[]): BuildTaskList { 6 | return new BuildTaskList(args); 7 | } 8 | 9 | export class BuildTaskList extends tasksBase.BuildTaskBase { 10 | protected description = "Get a list of build tasks"; 11 | protected serverCommand = true; 12 | 13 | protected getHelpArgs(): string[] { 14 | return ["all"]; 15 | } 16 | 17 | public async exec(): Promise { 18 | var agentapi = await this.webApi.getTaskAgentApi(this.connection.getCollectionUrl()); 19 | 20 | trace.debug("Searching for build tasks..."); 21 | return agentapi.getTaskDefinitions(null, ["build"], null).then(tasks => { 22 | trace.debug("Retrieved " + tasks.length + " build tasks from server."); 23 | return this.commandArgs.all.val().then(all => { 24 | if (all) { 25 | trace.debug("Listing all build tasks."); 26 | return tasks; 27 | } else { 28 | trace.debug("Filtering build tasks to give only the latest versions."); 29 | return this._getNewestTasks(tasks); 30 | } 31 | }); 32 | }); 33 | } 34 | 35 | /* 36 | * takes a list of non-unique task definitions and returns only the newest unique definitions 37 | * TODO: move this code to the server, add a parameter to the controllers 38 | */ 39 | private _getNewestTasks(allTasks: agentContracts.TaskDefinition[]): agentContracts.TaskDefinition[] { 40 | var taskDictionary: { [id: string]: agentContracts.TaskDefinition } = {}; 41 | for (var i = 0; i < allTasks.length; i++) { 42 | var currTask: agentContracts.TaskDefinition = allTasks[i]; 43 | if (taskDictionary[currTask.id]) { 44 | var newVersion: TaskVersion = new TaskVersion(currTask.version); 45 | var knownVersion: TaskVersion = new TaskVersion(taskDictionary[currTask.id].version); 46 | trace.debug( 47 | "Found additional version of " + currTask.name + " and comparing to the previously encountered version.", 48 | ); 49 | if (this._compareTaskVersion(newVersion, knownVersion) > 0) { 50 | trace.debug( 51 | "Found newer version of " + 52 | currTask.name + 53 | ". Previous: " + 54 | knownVersion.toString() + 55 | "; New: " + 56 | newVersion.toString(), 57 | ); 58 | taskDictionary[currTask.id] = currTask; 59 | } 60 | } else { 61 | trace.debug("Found task " + currTask.name); 62 | taskDictionary[currTask.id] = currTask; 63 | } 64 | } 65 | var newestTasks: agentContracts.TaskDefinition[] = []; 66 | for (var id in taskDictionary) { 67 | newestTasks.push(taskDictionary[id]); 68 | } 69 | return newestTasks; 70 | } 71 | /* 72 | * compares two versions of tasks, which are stored in version objects with fields 'major', 'minor', and 'patch' 73 | * @return positive value if version1 > version2, negative value if version2 > version1, 0 otherwise 74 | */ 75 | private _compareTaskVersion(version1: TaskVersion, version2: TaskVersion): number { 76 | if (version1.major != version2.major) { 77 | return version1.major - version2.major; 78 | } 79 | if (version1.minor != version2.minor) { 80 | return version1.minor - version2.minor; 81 | } 82 | if (version1.patch != version2.patch) { 83 | return version1.patch - version2.patch; 84 | } 85 | return 0; 86 | } 87 | 88 | public friendlyOutput(data: agentContracts.TaskDefinition[]): void { 89 | if (!data) { 90 | throw new Error("no tasks supplied"); 91 | } 92 | 93 | if (!(data instanceof Array)) { 94 | throw new Error("expected an array of tasks"); 95 | } 96 | 97 | data.forEach(task => { 98 | trace.println(); 99 | trace.info("id : %s", task.id); 100 | trace.info("name : %s", task.name); 101 | trace.info("friendly name : %s", task.friendlyName); 102 | trace.info("visibility : %s", task.visibility ? task.visibility.join(",") : ""); 103 | trace.info("description : %s", task.description); 104 | trace.info("version : %s", new TaskVersion(task.version).toString()); 105 | }); 106 | } 107 | } 108 | 109 | class TaskVersion { 110 | major: number; 111 | minor: number; 112 | patch: number; 113 | 114 | constructor(versionData: any) { 115 | this.major = versionData.major || 0; 116 | this.minor = versionData.minor || 0; 117 | this.patch = versionData.patch || 0; 118 | } 119 | 120 | public toString(): string { 121 | return this.major + "." + this.minor + "." + this.patch; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/exec/build/tasks/upload.ts: -------------------------------------------------------------------------------- 1 | import agentContracts = require("azure-devops-node-api/interfaces/TaskAgentInterfaces"); 2 | import archiver = require("archiver"); 3 | import fs = require("fs"); 4 | import path = require("path"); 5 | import tasksBase = require("./default"); 6 | import trace = require("../../../lib/trace"); 7 | import vm = require("../../../lib/jsonvalidate"); 8 | import { ITaskAgentApi } from "azure-devops-node-api/TaskAgentApi"; 9 | import zip = require("jszip"); 10 | 11 | export function getCommand(args: string[]): BuildTaskUpload { 12 | return new BuildTaskUpload(args); 13 | } 14 | 15 | var c_taskJsonFile: string = "task.json"; 16 | 17 | export class BuildTaskUpload extends tasksBase.BuildTaskBase { 18 | protected description = "Upload a Build Task."; 19 | protected serverCommand = true; 20 | 21 | protected getHelpArgs(): string[] { 22 | return ["taskPath", "taskZipPath", "overwrite"]; 23 | } 24 | 25 | public async exec(): Promise { 26 | const taskPaths = await this.commandArgs.taskPath.val(); 27 | const taskZipPath = await this.commandArgs.taskZipPath.val(); 28 | const overwrite = await this.commandArgs.overwrite.val(); 29 | 30 | let taskStream: NodeJS.ReadableStream = null; 31 | let taskId: string = null; 32 | let sourceLocation: string = null; 33 | 34 | if (!taskZipPath && !taskPaths) { 35 | throw new Error("You must specify either --task-path or --task-zip-path."); 36 | } 37 | 38 | if (taskZipPath) { 39 | // User provided an already zipped task, upload that. 40 | const data: Buffer = fs.readFileSync(taskZipPath); 41 | const z: zip = await zip.loadAsync(data); 42 | 43 | // find task.json inside zip, make sure its there then deserialize content 44 | const fileContent: string = await z.files[c_taskJsonFile].async('text'); 45 | const taskJson: vm.TaskJson = JSON.parse(fileContent); 46 | 47 | sourceLocation = taskZipPath; 48 | taskId = taskJson.id; 49 | taskStream = fs.createReadStream(taskZipPath); 50 | } else { 51 | // User provided the path to a directory with the task content 52 | const taskPath: string = taskPaths[0]; 53 | vm.exists(taskPath, "specified directory " + taskPath + " does not exist."); 54 | 55 | const taskJsonPath: string = path.join(taskPath, c_taskJsonFile); 56 | const taskJson: vm.TaskJson = vm.validate(taskJsonPath, "no " + c_taskJsonFile + " in specified directory"); 57 | 58 | const archive = archiver("zip"); 59 | archive.on("error", function(error) { 60 | trace.debug("Archiving error: " + error.message); 61 | error.message = "Archiving error: " + error.message; 62 | throw error; 63 | }); 64 | archive.directory(path.resolve(taskPath), false); 65 | archive.finalize(); 66 | 67 | sourceLocation = taskPath; 68 | taskId = taskJson.id; 69 | taskStream = archive; 70 | } 71 | 72 | const collectionUrl: string = this.connection.getCollectionUrl(); 73 | trace.info("Collection URL: " + collectionUrl); 74 | const agentApi: ITaskAgentApi = await this.webApi.getTaskAgentApi(collectionUrl); 75 | 76 | await agentApi.uploadTaskDefinition(null, taskStream, taskId, overwrite); 77 | trace.debug("Success"); 78 | return { sourceLocation: sourceLocation, }; 79 | } 80 | 81 | public friendlyOutput(data: agentContracts.TaskDefinition): void { 82 | trace.println(); 83 | trace.success("Task at %s uploaded successfully!", data.sourceLocation); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/exec/default.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../lib/tfcommand"; 2 | import args = require("../lib/arguments"); 3 | 4 | export function getCommand(args: string[]): TfCommand { 5 | return new DefaultCommand(args); 6 | } 7 | 8 | export class DefaultCommand extends TfCommand { 9 | protected serverCommand = false; 10 | 11 | constructor(passedArgs: string[]) { 12 | super(passedArgs); 13 | } 14 | 15 | public exec(cmd?: any): Promise { 16 | return this.getHelp(cmd); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/extension-composer-factory.ts: -------------------------------------------------------------------------------- 1 | import { ManifestBuilder } from "./manifest"; 2 | import { EOL } from "os"; 3 | import { ExtensionComposer } from "./extension-composer"; 4 | import { MergeSettings, TargetDeclaration } from "./interfaces"; 5 | import { VsixComponents } from "./merger"; 6 | import { VsixManifestBuilder } from "./vsix-manifest-builder"; 7 | import { VsoManifestBuilder } from "./targets/Microsoft.VisualStudio.Services/vso-manifest-builder"; 8 | import { VSSExtensionComposer } from "./targets/Microsoft.VisualStudio.Services/composer"; 9 | import { VSSIntegrationComposer } from "./targets/Microsoft.VisualStudio.Services.Integration/composer"; 10 | import { VSOfferComposer } from "./targets/Microsoft.VisualStudio.Offer/composer"; 11 | import _ = require("lodash"); 12 | 13 | import trace = require("../../../lib/trace"); 14 | 15 | export class ComposerFactory { 16 | public static GetComposer(settings: MergeSettings, targets: TargetDeclaration[]): ExtensionComposer { 17 | let composers: ExtensionComposer[] = []; 18 | 19 | // @TODO: Targets should be declared by the composer 20 | targets.forEach(target => { 21 | switch (target.id) { 22 | case "Microsoft.VisualStudio.Services": 23 | case "Microsoft.VisualStudio.Services.Cloud": 24 | case "Microsoft.TeamFoundation.Server": 25 | composers.push(new VSSExtensionComposer(settings)); 26 | break; 27 | case "Microsoft.VisualStudio.Services.Integration": 28 | case "Microsoft.TeamFoundation.Server.Integration": 29 | case "Microsoft.VisualStudio.Services.Cloud.Integration": 30 | composers.push(new VSSIntegrationComposer(settings)); 31 | break; 32 | case "Microsoft.VisualStudio.Offer": 33 | composers.push(new VSOfferComposer(settings)); 34 | break; 35 | default: 36 | if (!settings.bypassValidation) { 37 | throw new Error( 38 | "'" + 39 | target.id + 40 | "' is not a recognized target. Valid targets are 'Microsoft.VisualStudio.Services', 'Microsoft.VisualStudio.Services.Integration', 'Microsoft.VisualStudio.Offer'", 41 | ); 42 | } 43 | break; 44 | } 45 | }); 46 | if (composers.length === 0 && targets.length === 0) { 47 | trace.warn( 48 | `No recognized targets found. Ensure that your manifest includes a target property. E.g. "targets":[{"id":"Microsoft.VisualStudio.Services"}],...${EOL}Defaulting to Microsoft.VisualStudio.Services`, 49 | ); 50 | composers.push(new VSSExtensionComposer(settings)); 51 | } 52 | 53 | // Build a new type of composer on the fly that is the 54 | // concatenation of all of the composers necessary for 55 | // this extension. 56 | let PolyComposer = (() => { 57 | function PolyComposer(settings) { 58 | this.settings = settings; 59 | } 60 | PolyComposer.prototype.getBuilders = () => { 61 | return _.uniqWith( 62 | composers.reduce((p, c) => { 63 | return p.concat(c.getBuilders()); 64 | }, []), 65 | (b1: ManifestBuilder, b2: ManifestBuilder) => b1.getType() === b2.getType(), 66 | ); 67 | }; 68 | PolyComposer.prototype.validate = (components: VsixComponents) => { 69 | return Promise.all( 70 | composers.reduce((p, c) => { 71 | return p.concat(c.validate(components)); 72 | }, []), 73 | ).then(multi => { 74 | // flatten 75 | return multi.reduce((p, c) => p.concat(c), []); 76 | }); 77 | }; 78 | return PolyComposer; 79 | })(); 80 | 81 | return new PolyComposer(settings); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/extension-composer.ts: -------------------------------------------------------------------------------- 1 | import { ManifestBuilder } from "./manifest"; 2 | import { MergeSettings } from "./interfaces"; 3 | import { VsixManifestBuilder } from "./vsix-manifest-builder"; 4 | import { VsixComponents } from "./merger"; 5 | import _ = require("lodash"); 6 | 7 | export abstract class ExtensionComposer { 8 | constructor(protected settings: MergeSettings) {} 9 | 10 | public getBuilders(): ManifestBuilder[] { 11 | return [new VsixManifestBuilder(this.settings.root)]; 12 | } 13 | 14 | /** 15 | * Return a string[] of validation errors 16 | */ 17 | public validate(components: VsixComponents): Promise { 18 | // Take the validators and run each's method against the vsix manifest's data 19 | let errorMessages = Object.keys(ExtensionComposer.vsixValidators) 20 | .map(path => 21 | ExtensionComposer.vsixValidators[path]( 22 | _.get(components.builders.filter(b => b.getType() === VsixManifestBuilder.manifestType)[0].getData(), path), 23 | ), 24 | ) 25 | .filter(r => !!r); 26 | 27 | return Promise.resolve(errorMessages); 28 | } 29 | 30 | // Basic/global extension validations. 31 | private static vsixValidators: { [path: string]: (value) => string } = { 32 | "PackageManifest.Metadata[0].Identity[0].$.Id": value => { 33 | if (/^[A-z0-9_-]+$/.test(value)) { 34 | return null; 35 | } else { 36 | return "'extensionId' may only include letters, numbers, underscores, and dashes."; 37 | } 38 | }, 39 | "PackageManifest.Metadata[0].Identity[0].$.Version": value => { 40 | if (typeof value === "string" && value.length > 0) { 41 | return null; 42 | } else { 43 | return "'version' must be provided."; 44 | } 45 | }, 46 | "PackageManifest.Metadata[0].Description[0]._": value => { 47 | if (!value || value.length <= 200) { 48 | return null; 49 | } else { 50 | return "'description' must be less than 200 characters."; 51 | } 52 | }, 53 | "PackageManifest.Metadata[0].DisplayName[0]": value => { 54 | if (typeof value === "string" && value.length > 0) { 55 | return null; 56 | } else { 57 | return "'name' must be provided."; 58 | } 59 | }, 60 | "PackageManifest.Assets[0].Asset": value => { 61 | let usedAssetTypes = {}; 62 | if (_.isArray(value)) { 63 | for (let i = 0; i < value.length; ++i) { 64 | let asset = value[i].$; 65 | if (asset) { 66 | if (!asset.Path) { 67 | return "All 'files' must include a 'path'."; 68 | } 69 | if (asset.Type && asset.Addressable) { 70 | if (usedAssetTypes[asset.Type + "|" + asset.Lang]) { 71 | return ( 72 | "Cannot have multiple 'addressable' files with the same 'assetType'.\nFile1: " + 73 | usedAssetTypes[asset.Type + "|" + asset.Lang] + 74 | ", File 2: " + 75 | asset.Path + 76 | " (asset type: " + 77 | asset.Type + 78 | ")" 79 | ); 80 | } else { 81 | usedAssetTypes[asset.Type + "|" + asset.Lang] = asset.Path; 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | return null; 89 | }, 90 | "PackageManifest.Metadata[0].Identity[0].$.Publisher": value => { 91 | if (typeof value === "string" && value.length > 0) { 92 | return null; 93 | } else { 94 | return "'publisher' must be provided."; 95 | } 96 | }, 97 | "PackageManifest.Metadata[0].Categories[0]": value => { 98 | if (typeof value === "string" && value.length > 0) { 99 | return null; 100 | } else { 101 | return "One or more 'categories' must be provided."; 102 | } 103 | } 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/extensioninfo.ts: -------------------------------------------------------------------------------- 1 | import _ = require("lodash"); 2 | import trace = require("../../../lib/trace"); 3 | import fs = require("fs"); 4 | import xml2js = require("xml2js"); 5 | import zip = require("jszip"); 6 | 7 | import { promisify } from "util"; 8 | import { readFile } from "fs"; 9 | 10 | export interface CoreExtInfo { 11 | id: string; 12 | publisher: string; 13 | version: string; 14 | published?: boolean; 15 | } 16 | 17 | export function getExtInfo( 18 | vsixPath: string, 19 | extensionId: string, 20 | publisherName: string, 21 | cachedInfo?: CoreExtInfo, 22 | ): Promise { 23 | trace.debug("extensioninfo.getExtInfo with vsixpath: " + vsixPath + ", extId: " + extensionId + ", publisher: " + publisherName); 24 | var vsixInfoPromise: Promise; 25 | if (cachedInfo) { 26 | return Promise.resolve(cachedInfo); 27 | } else if (extensionId && publisherName) { 28 | vsixInfoPromise = Promise.resolve({ id: extensionId, publisher: publisherName, version: null }); 29 | } else if (vsixPath) { 30 | vsixInfoPromise = promisify(readFile)(vsixPath) 31 | .then(async data => { 32 | trace.debug(vsixPath); 33 | trace.debug("Read vsix as zip... Size (bytes): %s", data.length.toString()); 34 | const zipArchive = new zip(); 35 | await zipArchive.loadAsync(data); 36 | return zipArchive; 37 | }) 38 | .then(zip => { 39 | trace.debug("Files in the zip: %s", Object.keys(zip.files).join(", ")); 40 | let vsixManifestFileNames = Object.keys(zip.files).filter(key => _.endsWith(key, "vsixmanifest")); 41 | if (vsixManifestFileNames.length > 0) { 42 | return new Promise(async (resolve, reject) => { 43 | xml2js.parseString(await zip.files[vsixManifestFileNames[0]].async("text"), (err, result) => { 44 | if (err) { 45 | reject(err); 46 | } else { 47 | resolve(result); 48 | } 49 | }); 50 | }); 51 | } else { 52 | throw new Error("Could not locate vsix manifest!"); 53 | } 54 | }) 55 | .then(vsixManifestAsJson => { 56 | let foundExtId: string = 57 | extensionId || _.get(vsixManifestAsJson, "PackageManifest.Metadata[0].Identity[0].$.Id"); 58 | let foundPublisher: string = 59 | publisherName || 60 | _.get(vsixManifestAsJson, "PackageManifest.Metadata[0].Identity[0].$.Publisher"); 61 | let extensionVersion: string = _.get( 62 | vsixManifestAsJson, 63 | "PackageManifest.Metadata[0].Identity[0].$.Version", 64 | ); 65 | if (foundExtId && foundPublisher) { 66 | return { id: foundExtId, publisher: foundPublisher, version: extensionVersion }; 67 | } else { 68 | throw new Error( 69 | "Could not locate both the extension id and publisher in vsix manfiest! Ensure your manifest includes both a namespace and a publisher property, or specify the necessary --publisher and/or --extension options.", 70 | ); 71 | } 72 | }); 73 | } else { 74 | throw new Error("Either --vsix or BOTH of --extensionid and --name is required"); 75 | } 76 | return vsixInfoPromise; 77 | } 78 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a part in an OPC package 3 | */ 4 | export interface PackagePart { 5 | contentType?: string; 6 | partName: string; 7 | } 8 | 9 | /** 10 | * List of files in the package, mapped to null, or, if it can't be properly auto- 11 | * detected, a content type. 12 | */ 13 | export interface PackageFiles { 14 | [path: string]: FileDeclaration; 15 | } 16 | 17 | /** 18 | * Describes a file in a manifest 19 | */ 20 | export interface FileDeclaration { 21 | /** 22 | * The type of this asset (Type attribute in the vsixmanifest's entry) 23 | * Also used as the addressable name of this asset (if addressable = true) 24 | * If a string[] is provided, multiple entries will be added. 25 | */ 26 | assetType?: string | string[]; 27 | 28 | /** 29 | * Manually specified content-type/mime-type. Otherwise, try to automatically determine. 30 | */ 31 | contentType?: string; 32 | 33 | /** 34 | * True means that this file was added indirectly, e.g. from a directory. Files that have 35 | * auto = true will be overridden by files with the same path that do not. 36 | */ 37 | auto?: boolean; 38 | 39 | /** 40 | * Path to the file on disk 41 | */ 42 | path: string; 43 | 44 | /** 45 | * Path/file name to the file in the archive 46 | */ 47 | partName?: string; 48 | 49 | /** 50 | * Alias to partName 51 | */ 52 | packagePath?: string; 53 | 54 | /** 55 | * Language of this asset, if any 56 | */ 57 | lang?: string; 58 | 59 | /** 60 | * If true, this asset will be addressable via a public gallery endpoint 61 | */ 62 | addressable?: boolean; 63 | 64 | /** 65 | * If content is not empty, this string will be used as the packaged contents 66 | * rather than the contents of on the file system. 67 | */ 68 | content?: string; 69 | 70 | /** 71 | * If true, this file will be included when only writing vsix metadata 72 | */ 73 | isMetadata?: boolean; 74 | } 75 | 76 | /** 77 | * Describes a base asset declaration 78 | */ 79 | export interface AssetDeclaration { 80 | path: string; 81 | contentType?: string; 82 | } 83 | 84 | /** 85 | * Describes a screenshot in the manifest 86 | */ 87 | export interface ScreenshotDeclaration extends AssetDeclaration {} 88 | 89 | /** 90 | * Describes a details file in the manifest 91 | */ 92 | export interface DetailsDeclaration extends AssetDeclaration {} 93 | 94 | /** 95 | * Describes a link in the manifest 96 | */ 97 | export interface LinkDeclaration { 98 | url: string; 99 | } 100 | 101 | /** 102 | * Describes a set of links keyed off the link type in the manifest. 103 | */ 104 | export interface Links { 105 | [type: string]: LinkDeclaration; 106 | } 107 | 108 | /** 109 | * Describes a target in the manifest 110 | */ 111 | export interface TargetDeclaration { 112 | id: string; 113 | version?: string; 114 | } 115 | 116 | /** 117 | * Describes the extension's branding in the manifest. 118 | */ 119 | export interface BrandingDeclaration { 120 | color: string; 121 | theme: string; 122 | } 123 | 124 | export interface CustomerQnASupport { 125 | enableMarketplaceQnA?: boolean; 126 | url?: string; 127 | } 128 | 129 | /** 130 | * Settings for doing the merging 131 | */ 132 | export interface MergeSettings { 133 | /** 134 | * Root of source manifests 135 | */ 136 | root: string; 137 | 138 | /* 139 | * Manifest in the form of a standard Node.js CommonJS module with an exported function. 140 | * The function takes an environment as a parameter and must return the manifest JSON object. 141 | * Environment variables are specified with the env command line parameter. 142 | * If this is present then manifests and manifestGlobs are ignored. 143 | */ 144 | manifestJs: string; 145 | 146 | /* 147 | * A series of environment variables that are passed to the function exported from the manifestJs module. 148 | */ 149 | env: string[]; 150 | 151 | /* 152 | * List of paths to manifest files 153 | */ 154 | manifests: string[]; 155 | 156 | /** 157 | * List of globs for searching for partial manifests 158 | */ 159 | manifestGlobs: string[]; 160 | 161 | /** 162 | * Highest priority partial manifest 163 | */ 164 | overrides: any; 165 | 166 | /** 167 | * True to bypass validation during packaging. 168 | */ 169 | bypassValidation: boolean; 170 | 171 | /** 172 | * True to rev the version of the extension before packaging. 173 | */ 174 | revVersion: boolean; 175 | 176 | /** 177 | * Path to the root of localized resource files 178 | */ 179 | locRoot: string; 180 | 181 | /** 182 | * If true, treat JSON files as "Json 5" or "extended JSON", 183 | * which supports comments, unquoted keys, etc. 184 | */ 185 | json5: boolean; 186 | } 187 | 188 | export interface PackageSettings { 189 | /** 190 | * Path to the generated vsix 191 | */ 192 | outputPath: string; 193 | 194 | /** 195 | * Path to the root of localized resource files 196 | */ 197 | locRoot: string; 198 | 199 | /** 200 | * Only output metadata for the extension rather than a full package 201 | */ 202 | metadataOnly: boolean; 203 | } 204 | 205 | export interface PublishSettings { 206 | /** 207 | * URL to the market 208 | */ 209 | galleryUrl?: string; 210 | 211 | /** 212 | * Path to a vsix to publish 213 | */ 214 | vsixPath?: string; 215 | 216 | /** 217 | * Publisher to identifiy an extension 218 | */ 219 | publisher?: string; 220 | 221 | /** 222 | * Name of an extension belonging to publisher 223 | */ 224 | extensionId?: string; 225 | 226 | /** 227 | * List of Azure Devops organization to share an extension with. 228 | */ 229 | shareWith?: string[]; 230 | 231 | /** 232 | * If true, command will not wait for extension to be validated. 233 | */ 234 | noWaitValidation?: boolean; 235 | 236 | /** 237 | * If true, publish will not halt in the event of requiring new scopes. 238 | */ 239 | bypassScopeCheck?: boolean; 240 | } 241 | 242 | /*** Types related to localized resources ***/ 243 | 244 | export interface ResourcesFile { 245 | [key: string]: string; 246 | } 247 | 248 | // Models the schema outlined at https://msdn.microsoft.com/en-us/library/dd997147.aspx 249 | export interface VsixLanguagePack { 250 | VsixLanguagePack: { 251 | $: { 252 | Version: string; 253 | xmlns: string; 254 | }; 255 | LocalizedName: [string]; 256 | LocalizedDescription: [string]; 257 | LocalizedReleaseNotes: [string]; 258 | License: [string]; 259 | MoreInfoUrl: [string]; 260 | }; 261 | } 262 | 263 | export interface ResourceSet { 264 | manifestResources: { [manifestType: string]: ResourcesFile }; 265 | combined: ResourcesFile; 266 | } 267 | 268 | export interface LocalizedResources { 269 | [languageTag: string]: ResourcesFile; 270 | defaults: ResourcesFile; 271 | } 272 | 273 | /*** Types for VSIX Manifest ****/ 274 | 275 | export namespace Vsix { 276 | export interface PackageManifestAttr { 277 | Version?: string; 278 | xmlns?: string; 279 | "xmlns:d"?: string; 280 | } 281 | 282 | export interface IdentityAttr { 283 | Language?: string; 284 | Id?: string; 285 | Version?: string; 286 | Publisher?: string; 287 | } 288 | 289 | export interface Identity { 290 | $?: IdentityAttr; 291 | } 292 | 293 | export interface DescriptionAttr { 294 | "xml:space"?: string; 295 | } 296 | 297 | export interface Description { 298 | $?: DescriptionAttr; 299 | _?: string; 300 | } 301 | 302 | export interface Properties { 303 | Property?: Property[]; 304 | } 305 | 306 | export interface PropertyAttr { 307 | Id?: string; 308 | Value?: string; 309 | } 310 | 311 | export interface Property { 312 | $?: PropertyAttr; 313 | } 314 | 315 | export interface Metadata { 316 | Identity?: [Identity]; 317 | DisplayName?: [string]; 318 | Description?: [Description]; 319 | ReleaseNotes?: [string]; 320 | Tags?: [string]; 321 | GalleryFlags?: [string]; 322 | Categories?: [string]; 323 | Icon?: [string]; 324 | Properties?: [Properties]; 325 | } 326 | 327 | export interface InstallationTargetAttr { 328 | Id?: string; 329 | Version?: string; 330 | } 331 | 332 | export interface InstallationTarget { 333 | $?: InstallationTargetAttr; 334 | } 335 | 336 | export interface Installation { 337 | InstallationTarget?: [InstallationTarget]; 338 | } 339 | 340 | export interface AssetAttr { 341 | Type?: string; 342 | "d:Source"?: string; 343 | Path?: string; 344 | Addressable?: string; 345 | } 346 | 347 | export interface Asset { 348 | $?: AssetAttr; 349 | } 350 | 351 | export interface Assets { 352 | Asset?: Asset[]; 353 | } 354 | 355 | export interface PackageManifest { 356 | $?: PackageManifestAttr; 357 | Metadata?: [Metadata]; 358 | Installation?: [Installation]; 359 | Dependencies?: [string]; 360 | Assets?: [Assets]; 361 | } 362 | 363 | export interface VsixManifest { 364 | PackageManifest?: PackageManifest; 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/loc.ts: -------------------------------------------------------------------------------- 1 | import { ResourcesFile, VsixLanguagePack, ResourceSet } from "./interfaces"; 2 | import { ManifestBuilder } from "./manifest"; 3 | import { VsixManifestBuilder } from "./vsix-manifest-builder"; 4 | import _ = require("lodash"); 5 | import trace = require("../../../lib/trace"); 6 | import mkdirp = require("mkdirp"); 7 | import path = require("path"); 8 | 9 | import { promisify } from "util"; 10 | import { lstat, writeFile } from "fs"; 11 | import { exists } from "../../../lib/fsUtils"; 12 | 13 | export namespace LocPrep { 14 | /** 15 | * Creates a deep copy of document, replacing resource keys with the values from 16 | * the resources object. 17 | * If a resource cannot be found, the same string from the defaults document will be substituted. 18 | * The defaults object must have the same structure/schema as document. 19 | */ 20 | export function makeReplacements(document: any, resources: ResourcesFile, defaults: ResourcesFile): any { 21 | let locDocument = _.isArray(document) ? [] : {}; 22 | for (let key in document) { 23 | if (propertyIsComment(key)) { 24 | continue; 25 | } else if (_.isObject(document[key])) { 26 | locDocument[key] = makeReplacements(document[key], resources, defaults); 27 | } else if (_.isString(document[key]) && _.startsWith(document[key], "resource:")) { 28 | let resourceKey = document[key].substr("resource:".length).trim(); 29 | let replacement = resources[resourceKey]; 30 | if (!_.isString(replacement)) { 31 | replacement = defaults[resourceKey]; 32 | trace.warn( 33 | "Could not find a replacement for resource key %s. Falling back to '%s'.", 34 | resourceKey, 35 | replacement, 36 | ); 37 | } 38 | locDocument[key] = replacement; 39 | } else { 40 | locDocument[key] = document[key]; 41 | } 42 | } 43 | return locDocument; 44 | } 45 | 46 | /** 47 | * If the resjsonPath setting is set... 48 | * Check if the path exists. If it does, check if it's a directory. 49 | * If it's a directory, write to path + extension.resjson 50 | * All other cases just write to path. 51 | */ 52 | export function writeResourceFile(fullResjsonPath: string, resources: ResourcesFile): Promise { 53 | return exists(fullResjsonPath) 54 | .then(exists => { 55 | if (exists) { 56 | return promisify(lstat)(fullResjsonPath) 57 | .then(obj => { 58 | return obj.isDirectory(); 59 | }) 60 | .then(isDir => { 61 | if (isDir) { 62 | return path.join(fullResjsonPath, "extension.resjson"); 63 | } else { 64 | return fullResjsonPath; 65 | } 66 | }); 67 | } else { 68 | return fullResjsonPath; 69 | } 70 | }) 71 | .then(determinedPath => { 72 | return mkdirp(path.dirname(determinedPath)).then(() => { 73 | return promisify(writeFile)(determinedPath, JSON.stringify(resources, null, 4), "utf8"); 74 | }); 75 | }); 76 | } 77 | 78 | export function propertyIsComment(property: string): boolean { 79 | return _.startsWith(property, "_") && _.endsWith(property, ".comment"); 80 | } 81 | 82 | export class LocKeyGenerator { 83 | private static I18N_PREFIX = "i18n:"; 84 | private combined: ResourcesFile; 85 | private resourceFileMap: { [manifestType: string]: ResourcesFile }; 86 | private vsixManifestBuilder: VsixManifestBuilder; 87 | 88 | constructor(private manifestBuilders: ManifestBuilder[]) { 89 | this.initStringObjs(); 90 | 91 | // find the vsixmanifest and pull it out because we treat it a bit differently 92 | let vsixManifest = manifestBuilders.filter(b => b.getType() === VsixManifestBuilder.manifestType); 93 | if (vsixManifest.length === 1) { 94 | this.vsixManifestBuilder = vsixManifest[0]; 95 | } else { 96 | throw "Found " + vsixManifest.length + " vsix manifest builders (expected 1). Something is not right!"; 97 | } 98 | } 99 | 100 | private initStringObjs() { 101 | this.resourceFileMap = {}; 102 | this.manifestBuilders.forEach(b => { 103 | this.resourceFileMap[b.getType()] = {}; 104 | }); 105 | this.combined = {}; 106 | } 107 | 108 | /** 109 | * Destructive method modifies the manifests by replacing i18nable strings with resource: 110 | * keys. Adds all the original resources to the resources object. 111 | */ 112 | public generateLocalizationKeys(): ResourceSet { 113 | this.initStringObjs(); 114 | this.manifestBuilders.forEach(builder => { 115 | this.jsonReplaceWithKeysAndGenerateDefaultStrings(builder); 116 | }); 117 | 118 | return { 119 | manifestResources: this.resourceFileMap, 120 | combined: this.generateCombinedResourceFile(), 121 | }; 122 | } 123 | 124 | private generateCombinedResourceFile(): ResourcesFile { 125 | let combined: ResourcesFile = {}; 126 | let resValues = Object.keys(this.resourceFileMap).map(k => this.resourceFileMap[k]); 127 | 128 | // the .d.ts file falls short in this case 129 | let anyAssign: any = _.assign; 130 | anyAssign(combined, ...resValues); 131 | 132 | return combined; 133 | } 134 | 135 | private addResource(builderType: string, sourceKey: string, resourceKey: string, obj: any) { 136 | let resourceVal = this.removeI18nPrefix(obj[sourceKey]); 137 | this.resourceFileMap[builderType][resourceKey] = resourceVal; 138 | let comment = obj["_" + sourceKey + ".comment"]; 139 | if (comment) { 140 | this.resourceFileMap[builderType]["_" + resourceKey + ".comment"] = comment; 141 | } 142 | obj[sourceKey] = "resource:" + resourceKey; 143 | } 144 | 145 | private removeI18nPrefix(str: string): string { 146 | if (_.startsWith(str, LocKeyGenerator.I18N_PREFIX)) { 147 | return str.substr(LocKeyGenerator.I18N_PREFIX.length); 148 | } 149 | return str; 150 | } 151 | 152 | private jsonReplaceWithKeysAndGenerateDefaultStrings( 153 | builder: ManifestBuilder, 154 | json: any = null, 155 | path: string = "", 156 | ): void { 157 | if (!json) { 158 | json = builder.getData(); 159 | } 160 | for (let key in json) { 161 | let val = json[key]; 162 | if (_.isObject(val)) { 163 | let nextPath = path + key + "."; 164 | this.jsonReplaceWithKeysAndGenerateDefaultStrings(builder, val, nextPath); 165 | } else if (_.isString(val) && _.startsWith(val, LocKeyGenerator.I18N_PREFIX)) { 166 | this.addResource(builder.getType(), key, builder.getLocKeyPath(path + key), json); 167 | } 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/manifest.ts: -------------------------------------------------------------------------------- 1 | import { PackageFiles, FileDeclaration, LocalizedResources, ResourcesFile } from "./interfaces"; 2 | import { cleanAssetPath, forwardSlashesPath, removeMetaKeys, toZipItemName } from "./utils"; 3 | import _ = require("lodash"); 4 | import common = require("../../../lib/common"); 5 | import os = require("os"); 6 | import path = require("path"); 7 | 8 | import stream = require("stream"); 9 | import trace = require("../../../lib/trace"); 10 | 11 | export abstract class ManifestBuilder { 12 | protected packageFiles: PackageFiles = {}; 13 | protected lcPartNames: { [filename: string]: string } = {}; 14 | protected data: any = {}; 15 | private static resourcePrefix = "resource:"; 16 | private deferredFiles: FileDeclaration[] = []; 17 | public producesMetadata: boolean = false; 18 | 19 | constructor(private extRoot: string) {} 20 | 21 | /** 22 | * Explains the type of manifest builder 23 | */ 24 | public abstract getType(): string; 25 | 26 | /** 27 | * Gets the package path to this manifest 28 | */ 29 | public abstract getPath(): string; 30 | 31 | /** 32 | * Get the content type of this manifest (e.g. text/xml) 33 | */ 34 | public abstract getContentType(): string; 35 | 36 | /** 37 | * Gets the path to the localized resource associated with this manifest 38 | */ 39 | public getLocPath(): string { 40 | return this.getPath(); 41 | } 42 | 43 | /** 44 | * Given a key/value pair, decide how this effects the manifest 45 | */ 46 | public abstract processKey(key: string, value: any, override: boolean): void; 47 | 48 | /** 49 | * Called just before the package is written to make any final adjustments. 50 | */ 51 | public finalize(files: PackageFiles, resourceData: LocalizedResources, builders: ManifestBuilder[]): Promise { 52 | this.deferredFiles.forEach(f => { 53 | this.addFile(f, false); 54 | if (f.path && !files[f.path]) { 55 | files[f.path] = f; 56 | } else if (!f.path) { 57 | files[common.newGuid()] = f; 58 | } 59 | }); 60 | return Promise.resolve(null); 61 | } 62 | 63 | /** 64 | * Gives the manifest the chance to transform the key that is used when generating the localization 65 | * strings file. Path will be a dot-separated set of keys to address the string (or another 66 | * object/array) in question. See vso-manifest-builder for an example. 67 | */ 68 | public getLocKeyPath(path: string): string { 69 | return path; 70 | } 71 | 72 | protected prepResult(resources?: ResourcesFile): any { 73 | const resultData = resources ? this._getLocResult(resources, resources) : this.data; 74 | return removeMetaKeys(resultData); 75 | } 76 | 77 | /** 78 | * Generate the manifest as a string. 79 | */ 80 | public getResult(resources?: ResourcesFile): string { 81 | return JSON.stringify(this.prepResult(resources), null, 4).replace(/\n/g, os.EOL); 82 | } 83 | 84 | public getMetadataResult(resources?: ResourcesFile): string | null { 85 | return null; 86 | } 87 | 88 | /** 89 | * Gets the contents of the file that will serve as localization for this asset. 90 | * Default implementation returns JSON with all strings replaced given by the translations/defaults objects. 91 | */ 92 | public getLocResult(translations: ResourcesFile, defaults: ResourcesFile): FileDeclaration[] { 93 | return [ 94 | { 95 | partName: this.getPath(), 96 | path: null, 97 | content: JSON.stringify( 98 | this._getLocResult(this.expandResourceFile(translations), this.expandResourceFile(defaults)), 99 | null, 100 | 4, 101 | ).replace(/\n/g, os.EOL), 102 | }, 103 | ]; 104 | } 105 | 106 | private _getLocResult(translations: any, defaults: any, locData = {}, currentPath: string[] = []) { 107 | // deep iterate through this.data. If the value is a string that starts with 108 | // resource:, use the key to look in translations and defaults to find the real string. 109 | // Do the replacement 110 | 111 | let currentData = currentPath.length > 0 ? _.get(this.data, currentPath) : this.data; 112 | Object.keys(currentData).forEach(key => { 113 | // Ignore localization comments 114 | if (key.startsWith("_") && key.endsWith(".comment")) { 115 | return; 116 | } 117 | const val = currentData[key]; 118 | if ( 119 | typeof val === "string" && 120 | val.substr(0, ManifestBuilder.resourcePrefix.length) === ManifestBuilder.resourcePrefix 121 | ) { 122 | const locKey = val.substr(ManifestBuilder.resourcePrefix.length); 123 | const localized = _.get(translations, locKey) || _.get(defaults, locKey); 124 | if (localized) { 125 | _.set(locData, currentPath.concat(key), localized); 126 | } else { 127 | throw new Error("Could not find translation or default value for resource " + locKey); 128 | } 129 | } else { 130 | if (typeof val === "object" && val !== null) { 131 | if (_.isArray(val)) { 132 | _.set(locData, currentPath.concat(key), []); 133 | } else { 134 | _.set(locData, currentPath.concat(key), {}); 135 | } 136 | this._getLocResult(translations, defaults, locData, currentPath.concat(key)); 137 | } else { 138 | _.set(locData, currentPath.concat(key), val); 139 | } 140 | } 141 | }); 142 | return locData; 143 | } 144 | 145 | /** 146 | * Resource files are flat key-value pairs where the key is the json "path" to the original element. 147 | * This routine expands the resource files back into their original schema 148 | */ 149 | private expandResourceFile(resources: ResourcesFile): any { 150 | let expanded = {}; 151 | Object.keys(resources).forEach(path => { 152 | _.set(expanded, path, resources[path]); 153 | }); 154 | return expanded; 155 | } 156 | 157 | /** 158 | * Get the raw JSON data. Please do not modify it. 159 | */ 160 | public getData(): any { 161 | return this.data; 162 | } 163 | 164 | /** 165 | * Get a list of files to be included in the package 166 | */ 167 | public get files(): PackageFiles { 168 | return this.packageFiles; 169 | } 170 | 171 | /** 172 | * Set 'value' to data[path] in this manifest if it has not been set, or if override is true. 173 | * If it has been set, issue a warning. 174 | */ 175 | protected singleValueProperty( 176 | path: string, 177 | value: any, 178 | manifestKey: string, 179 | override: boolean = false, 180 | duplicatesAreErrors: boolean = false, 181 | ): boolean { 182 | let existingValue = _.get(this.data, path); 183 | 184 | if (!override && existingValue !== undefined) { 185 | if (duplicatesAreErrors) { 186 | throw new Error( 187 | trace.format( 188 | "Multiple values found for '%s'. Ensure only one occurrence of '%s' exists across all manifest files.", 189 | manifestKey, 190 | manifestKey, 191 | ), 192 | ); 193 | } else { 194 | trace.warn( 195 | "Multiple values found for '%s'. Ignoring future occurrences and using the value '%s'.", 196 | manifestKey, 197 | JSON.stringify(existingValue, null, 4), 198 | ); 199 | } 200 | return false; 201 | } else { 202 | _.set(this.data, path, value); 203 | return true; 204 | } 205 | } 206 | 207 | /** 208 | * Read a value as a delimited string or array and concat it to the existing list at data[path] 209 | */ 210 | protected handleDelimitedList(value: any, path: string, delimiter: string = ",", uniq: boolean = true): void { 211 | if (_.isString(value)) { 212 | value = value.split(delimiter); 213 | _.remove(value, v => v === ""); 214 | } 215 | var items = _.get(this.data, path, "").split(delimiter); 216 | _.remove(items, v => v === ""); 217 | let val = items.concat(value); 218 | if (uniq) { 219 | val = _.uniq(val); 220 | } 221 | _.set(this.data, path, val.join(delimiter)); 222 | } 223 | 224 | /** 225 | * Add a file to the vsix package 226 | */ 227 | protected addFile(file: FileDeclaration, defer: boolean = false) { 228 | if (defer) { 229 | this.deferredFiles.push(file); 230 | return file; 231 | } 232 | if (!file.partName && file.packagePath) { 233 | file.partName = file.packagePath; 234 | } 235 | 236 | if (_.isArray(file.partName)) { 237 | let lastAdd = null; 238 | for (let i = 0; i < file.partName.length; ++i) { 239 | const newFile = { ...file }; 240 | newFile.partName = file.partName[i]; 241 | lastAdd = this.addFile(newFile); 242 | } 243 | return lastAdd; 244 | } 245 | 246 | if (typeof file.assetType === "string") { 247 | file.assetType = [file.assetType]; 248 | } 249 | 250 | file.path = cleanAssetPath(file.path, this.extRoot); 251 | if (!file.partName) { 252 | file.partName = "/" + path.relative(this.extRoot, file.path); 253 | } 254 | if (!file.partName) { 255 | throw new Error("Every file must have a path specified name."); 256 | } 257 | 258 | file.partName = forwardSlashesPath(file.partName); 259 | 260 | // Default the assetType to the partName. 261 | if (file.addressable && !file.assetType) { 262 | file.assetType = [toZipItemName(file.partName)]; 263 | } 264 | 265 | if (this.packageFiles[file.path]) { 266 | if (_.isArray(this.packageFiles[file.path].assetType) && file.assetType) { 267 | file.assetType = (this.packageFiles[file.path].assetType).concat(file.assetType); 268 | this.packageFiles[file.path].assetType = file.assetType; 269 | } 270 | } 271 | 272 | // Files added recursively, i.e. from a directory, get lower 273 | // priority than those specified explicitly. Therefore, if 274 | // the file has already been added to the package list, don't 275 | // re-add (overwrite) with this file if it is an auto (from a dir) 276 | if (file.auto && this.packageFiles[file.path] && this.packageFiles[file.path].partName === file.partName) { 277 | // Don't add files discovered via directory if they've already 278 | // been added. 279 | } else { 280 | let existPartName = this.lcPartNames[file.partName.toLowerCase()]; 281 | if (!existPartName || file.partName === existPartName) { 282 | // key off a guid if there is no file path. 283 | const key = file.path || common.newGuid(); 284 | if (this.packageFiles[key]) { 285 | // Additional package paths is an UNSUPPORTED and UNDOCUMENTED feature. 286 | // It may trample on other existing files with no warning or error. 287 | // Use at your own risk. 288 | const additionalPackagePaths = (this.packageFiles[key] as any)._additionalPackagePaths; 289 | if (additionalPackagePaths) { 290 | additionalPackagePaths.push(file.partName); 291 | } else { 292 | (this.packageFiles[key] as any)._additionalPackagePaths = [file.partName]; 293 | } 294 | } else { 295 | this.packageFiles[key] = file; 296 | this.lcPartNames[file.partName.toLowerCase()] = file.partName; 297 | } 298 | } else { 299 | throw new Error( 300 | "All files in the package must have a case-insensitive unique filename. Trying to add " + 301 | file.partName + 302 | ", but " + 303 | existPartName + 304 | " was already added to the package.", 305 | ); 306 | } 307 | } 308 | if (file.contentType && this.packageFiles[file.path]) { 309 | this.packageFiles[file.path].contentType = file.contentType; 310 | } 311 | 312 | return file; 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.TeamFoundation.Server.Integration/composer.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionComposer } from "../../extension-composer"; 2 | import { ManifestBuilder } from "../../manifest"; 3 | import { VsixComponents } from "../../merger"; 4 | import { VsixManifestBuilder } from "../../vsix-manifest-builder"; 5 | import _ = require("lodash"); 6 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.TeamFoundation.Server/composer.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionComposer } from "../../extension-composer"; 2 | import { ManifestBuilder } from "../../manifest"; 3 | import { VsixComponents } from "../../merger"; 4 | import { VsixManifestBuilder } from "../../vsix-manifest-builder"; 5 | import _ = require("lodash"); 6 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.VisualStudio.Offer/composer.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionComposer } from "../../extension-composer"; 2 | import { ManifestBuilder } from "../../manifest"; 3 | import { VsixComponents } from "../../merger"; 4 | import { VsixManifestBuilder } from "../../vsix-manifest-builder"; 5 | import _ = require("lodash"); 6 | 7 | export class VSOfferComposer extends ExtensionComposer {} 8 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.VisualStudio.Services.Cloud.Integration/composer.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionComposer } from "../../extension-composer"; 2 | import { ManifestBuilder } from "../../manifest"; 3 | import { VsixComponents } from "../../merger"; 4 | import { VsixManifestBuilder } from "../../vsix-manifest-builder"; 5 | import _ = require("lodash"); 6 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.VisualStudio.Services.Cloud/composer.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionComposer } from "../../extension-composer"; 2 | import { ManifestBuilder } from "../../manifest"; 3 | import { VsixComponents } from "../../merger"; 4 | import { VsixManifestBuilder } from "../../vsix-manifest-builder"; 5 | import _ = require("lodash"); 6 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.VisualStudio.Services.Integration/composer.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionComposer } from "../../extension-composer"; 2 | import { ManifestBuilder } from "../../manifest"; 3 | import { VsixComponents } from "../../merger"; 4 | import { VsixManifestBuilder } from "../../vsix-manifest-builder"; 5 | import _ = require("lodash"); 6 | 7 | export class VSSIntegrationComposer extends ExtensionComposer { 8 | public validate(components: VsixComponents): Promise { 9 | return super.validate(components).then(result => { 10 | let vsixData = components.builders.filter(b => b.getType() === VsixManifestBuilder.manifestType)[0].getData(); 11 | 12 | // Ensure that an Action link or a Getstarted link exists. 13 | let properties = _.get(vsixData, "PackageManifest.Metadata[0].Properties[0].Property", []); 14 | let pIds = properties.map(p => _.get(p, "$.Id")); 15 | 16 | if ( 17 | _.intersection( 18 | ["Microsoft.VisualStudio.Services.Links.Action", "Microsoft.VisualStudio.Services.Links.Getstarted"], 19 | pIds, 20 | ).length === 0 21 | ) { 22 | result.push("An 'integration' extension must provide a 'getstarted' link."); 23 | } 24 | return result; 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.VisualStudio.Services/composer.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionComposer } from "../../extension-composer"; 2 | import { ManifestBuilder } from "../../manifest"; 3 | import { VsixComponents } from "../../merger"; 4 | import { VsoManifestBuilder } from "./vso-manifest-builder"; 5 | import { VsixManifestBuilder } from "../../vsix-manifest-builder"; 6 | 7 | export class VSSExtensionComposer extends ExtensionComposer { 8 | public static SupportLink = "Microsoft.VisualStudio.Services.Links.Support"; 9 | 10 | public getBuilders(): ManifestBuilder[] { 11 | return super.getBuilders().concat([new VsoManifestBuilder(this.settings.root)]); 12 | } 13 | 14 | public validate(components: VsixComponents): Promise { 15 | return super.validate(components).then(result => { 16 | let data = components.builders.filter(b => b.getType() === VsoManifestBuilder.manifestType)[0].getData(); 17 | if (data.contributions.length === 0 && data.contributionTypes.length === 0) { 18 | result.push("Your extension must define at least one contribution or contribution type."); 19 | } 20 | 21 | // Validate that contribution ids are unique within the extension 22 | const ids: { [contributionId: string]: boolean } = {}; 23 | for (const contribution of data.contributions) { 24 | const id = contribution.id; 25 | 26 | if (ids[id]) { 27 | result.push(`Your extension defined a duplicate contribution id '${id}'.`); 28 | } 29 | 30 | ids[id] = true; 31 | } 32 | 33 | data = components.builders.filter(b => b.getType() === VsixManifestBuilder.manifestType)[0].getData(); 34 | const galleryFlags = data.PackageManifest.Metadata[0].GalleryFlags; 35 | const properties = data.PackageManifest.Metadata[0].Properties; 36 | 37 | if (galleryFlags && galleryFlags[0] && galleryFlags[0].toLowerCase().includes("paid")) { 38 | if (properties && properties.length > 0) { 39 | const property = properties[0].Property.filter( 40 | prop => prop.$.Id === VSSExtensionComposer.SupportLink && prop.$.Value, 41 | ); 42 | if (!property) { 43 | result.push( 44 | 'Paid extensions are required to have a support link. Try adding it to your manifest: { "links": { "support": "" } }', 45 | ); 46 | } 47 | } else { 48 | result.push( 49 | 'Paid extensions are required to have a support link. Try adding it to your manifest: { "links": { "support": "" } }', 50 | ); 51 | } 52 | } 53 | 54 | return result; 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/targets/Microsoft.VisualStudio.Services/vso-manifest-builder.ts: -------------------------------------------------------------------------------- 1 | import { ManifestBuilder } from "../../manifest"; 2 | import { LocalizedResources, PackageFiles, ResourcesFile } from "../../interfaces"; 3 | import _ = require("lodash"); 4 | import os = require("os"); 5 | 6 | import stream = require("stream"); 7 | 8 | export class VsoManifestBuilder extends ManifestBuilder { 9 | public producesMetadata: boolean = true; 10 | 11 | /** 12 | * Gets the package path to this manifest. 13 | */ 14 | public getPath(): string { 15 | return "extension.vsomanifest"; 16 | } 17 | 18 | public static manifestType = "Microsoft.VisualStudio.Services.Manifest"; 19 | /** 20 | * Explains the type of manifest builder 21 | */ 22 | public getType(): string { 23 | return VsoManifestBuilder.manifestType; 24 | } 25 | 26 | public getContentType(): string { 27 | return "application/json"; 28 | } 29 | 30 | public getMetadataResult(resources: ResourcesFile): string { 31 | return this.getResult(resources); 32 | } 33 | 34 | public finalize(files: PackageFiles, resourceData: LocalizedResources, builders: ManifestBuilder[]): Promise { 35 | return super.finalize(files, resourceData, builders).then(() => { 36 | // Ensure some default values are set 37 | if (!this.data.contributions) { 38 | this.data.contributions = []; 39 | } 40 | if (!this.data.scopes) { 41 | this.data.scopes = []; 42 | } 43 | if (!this.data.contributionTypes) { 44 | this.data.contributionTypes = []; 45 | } 46 | if (!this.data.manifestVersion) { 47 | this.data.manifestVersion = 1; 48 | } 49 | }); 50 | } 51 | 52 | /** 53 | * Some elements of this file are arrays, which would typically produce a localization 54 | * key like "contributions.3.name". We want to turn the 3 into the contribution id to 55 | * make it more friendly to translators. 56 | */ 57 | public getLocKeyPath(path: string): string { 58 | let pathParts = path.split(".").filter(p => !!p); 59 | if (pathParts && pathParts.length >= 2) { 60 | let cIndex = parseInt(pathParts[1]); 61 | if ( 62 | pathParts[0] === "contributions" && 63 | !isNaN(cIndex) && 64 | this.data.contributions[cIndex] && 65 | this.data.contributions[cIndex].id 66 | ) { 67 | return _.trimEnd("contributions." + this.data.contributions[cIndex].id + "." + pathParts.slice(2).join(".")); 68 | } else if ( 69 | pathParts[0] === "contributionTypes" && 70 | !isNaN(cIndex) && 71 | this.data.contributionTypes[cIndex] && 72 | this.data.contributionTypes[cIndex].id 73 | ) { 74 | return _.trimEnd( 75 | "contributionTypes." + this.data.contributionTypes[cIndex].id + "." + pathParts.slice(2).join("."), 76 | ); 77 | } else { 78 | return path; 79 | } 80 | } else { 81 | return path; 82 | } 83 | } 84 | 85 | public processKey(key: string, value: any, override: boolean): void { 86 | switch (key.toLowerCase()) { 87 | case "eventcallbacks": 88 | if (_.isObject(value)) { 89 | this.singleValueProperty("eventCallbacks", value, key, override); 90 | } 91 | break; 92 | case "constraints": 93 | if (_.isArray(value)) { 94 | if (!this.data.constraints) { 95 | this.data.constraints = []; 96 | } 97 | this.data.constraints = this.data.constraints.concat(value); 98 | } else { 99 | throw new Error(`"constraints" must be an array of ContributionConstraint objects.`); 100 | } 101 | break; 102 | case "restrictedto": 103 | if (_.isArray(value)) { 104 | this.singleValueProperty("restrictedTo", value, key, override, true); 105 | } else { 106 | throw new Error(`"restrictedTo" must be an array of strings.`); 107 | } 108 | break; 109 | case "manifestversion": 110 | let version = value; 111 | if (_.isString(version)) { 112 | version = parseFloat(version); 113 | } 114 | this.singleValueProperty("manifestVersion", version, key, override); 115 | break; 116 | case "scopes": 117 | if (_.isArray(value)) { 118 | if (!this.data.scopes) { 119 | this.data.scopes = []; 120 | } 121 | this.data.scopes = _.uniq(this.data.scopes.concat(value)); 122 | } 123 | break; 124 | case "baseuri": 125 | this.singleValueProperty("baseUri", value, key, override); 126 | break; 127 | case "contributions": 128 | if (_.isArray(value)) { 129 | if (!this.data.contributions) { 130 | this.data.contributions = []; 131 | } 132 | this.data.contributions = this.data.contributions.concat(value); 133 | } else { 134 | throw new Error('"contributions" must be an array of Contribution objects.'); 135 | } 136 | break; 137 | case "contributiontypes": 138 | if (_.isArray(value)) { 139 | if (!this.data.contributionTypes) { 140 | this.data.contributionTypes = []; 141 | } 142 | this.data.contributionTypes = this.data.contributionTypes.concat(value); 143 | } 144 | break; 145 | 146 | // Ignore all the vsixmanifest keys so we can take a default case below. 147 | case "branding": 148 | case "categories": 149 | case "content": 150 | case "description": 151 | case "details": 152 | case "extensionid": 153 | case "files": 154 | case "flags": 155 | case "galleryflags": 156 | case "galleryproperties": 157 | case "githubflavoredmarkdown": 158 | case "icons": 159 | case "id": 160 | case "links": 161 | case "name": 162 | case "namespace": 163 | case "public": 164 | case "publisher": 165 | case "releasenotes": 166 | case "screenshots": 167 | case "showpricingcalculator": 168 | case "tags": 169 | case "targets": 170 | case "version": 171 | case "vsoflags": 172 | break; 173 | default: 174 | if (key.substr(0, 2) !== "__") { 175 | this.singleValueProperty(key, value, key, override); 176 | } 177 | break; 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /app/exec/extension/_lib/utils.ts: -------------------------------------------------------------------------------- 1 | import _ = require("lodash"); 2 | import os = require("os"); 3 | import path = require("path"); 4 | import xml = require("xml2js"); 5 | 6 | export function removeMetaKeys(obj: any): any { 7 | return _.omitBy(obj, (v, k) => _.startsWith(k, "__meta_")); 8 | } 9 | 10 | export function cleanAssetPath(assetPath: string, root: string = ".") { 11 | if (!assetPath) { 12 | return null; 13 | } 14 | return forwardSlashesPath(path.resolve(root, assetPath)); 15 | } 16 | 17 | export function forwardSlashesPath(filePath: string) { 18 | if (!filePath) { 19 | return null; 20 | } 21 | let cleanPath = filePath.replace(/\\/g, "/"); 22 | return cleanPath; 23 | } 24 | 25 | /** 26 | * OPC Convention implementation. See 27 | * http://www.ecma-international.org/news/TC45_current_work/tc45-2006-335.pdf §10.1.3.2 & §10.2.3 28 | */ 29 | export function toZipItemName(partName: string): string { 30 | if (_.startsWith(partName, "/")) { 31 | return partName.substr(1); 32 | } else { 33 | return partName; 34 | } 35 | } 36 | 37 | export function jsonToXml(json: any): string { 38 | let builder = new xml.Builder(DEFAULT_XML_BUILDER_SETTINGS); 39 | return builder.buildObject(json); 40 | } 41 | 42 | export function maxKey(obj: { [key: string]: T }, func: (input: T) => number): string { 43 | let maxProp; 44 | for (let prop in obj) { 45 | if (!maxProp || func(obj[prop]) > func(obj[maxProp])) { 46 | maxProp = prop; 47 | } 48 | } 49 | return maxProp; 50 | } 51 | 52 | export var DEFAULT_XML_BUILDER_SETTINGS: xml.BuilderOptions = { 53 | indent: " ", 54 | newline: os.EOL, 55 | pretty: true, 56 | xmldec: { 57 | encoding: "utf-8", 58 | standalone: null, 59 | version: "1.0", 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /app/exec/extension/create.ts: -------------------------------------------------------------------------------- 1 | import { Merger } from "./_lib/merger"; 2 | import { VsixManifestBuilder } from "./_lib/vsix-manifest-builder"; 3 | import { MergeSettings, PackageSettings } from "./_lib/interfaces"; 4 | import { VsixWriter } from "./_lib/vsix-writer"; 5 | import { TfCommand } from "../../lib/tfcommand"; 6 | import colors = require("colors"); 7 | import extBase = require("./default"); 8 | import trace = require("../../lib/trace"); 9 | 10 | export function getCommand(args: string[]): TfCommand { 11 | return new ExtensionCreate(args); 12 | } 13 | 14 | export interface CreationResult { 15 | path: string; 16 | extensionId: string; 17 | version: string; 18 | publisher: string; 19 | } 20 | 21 | export function createExtension(mergeSettings: MergeSettings, packageSettings: PackageSettings): Promise { 22 | return new Merger(mergeSettings).merge().then(components => { 23 | return new VsixWriter(packageSettings, components).writeVsix().then(outPath => { 24 | let vsixBuilders = components.builders.filter(b => b.getType() === VsixManifestBuilder.manifestType); 25 | let vsixBuilder: VsixManifestBuilder; 26 | if (vsixBuilders.length > 0) { 27 | vsixBuilder = vsixBuilders[0]; 28 | } 29 | return { 30 | path: outPath, 31 | extensionId: vsixBuilder ? vsixBuilder.getExtensionId() : null, 32 | version: vsixBuilder ? vsixBuilder.getExtensionVersion() : null, 33 | publisher: vsixBuilder ? vsixBuilder.getExtensionPublisher() : null, 34 | }; 35 | }); 36 | }); 37 | } 38 | 39 | export class ExtensionCreate extends extBase.ExtensionBase { 40 | protected description = "Create a vsix package for an extension."; 41 | protected serverCommand = false; 42 | 43 | constructor(passedArgs: string[]) { 44 | super(passedArgs); 45 | } 46 | 47 | protected getHelpArgs(): string[] { 48 | return [ 49 | "root", 50 | "manifestJs", 51 | "env", 52 | "manifests", 53 | "manifestGlobs", 54 | "json5", 55 | "override", 56 | "overridesFile", 57 | "revVersion", 58 | "bypassValidation", 59 | "publisher", 60 | "extensionId", 61 | "outputPath", 62 | "locRoot", 63 | "metadataOnly", 64 | ]; 65 | } 66 | 67 | public async exec(): Promise { 68 | return this.getMergeSettings().then(mergeSettings => { 69 | return this.getPackageSettings().then(packageSettings => { 70 | return createExtension(mergeSettings, packageSettings); 71 | }); 72 | }); 73 | } 74 | 75 | protected friendlyOutput(data: CreationResult): void { 76 | trace.info(colors.green("\n=== Completed operation: create extension ===")); 77 | trace.info(" - VSIX: %s", data.path); 78 | trace.info(" - Extension ID: %s", data.extensionId); 79 | trace.info(" - Extension Version: %s", data.version); 80 | trace.info(" - Publisher: %s", data.publisher); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/exec/extension/install.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import colors = require("colors"); 4 | import extBase = require("./default"); 5 | import extInfo = require("./_lib/extensioninfo"); 6 | import trace = require("../../lib/trace"); 7 | import GalleryInterfaces = require("azure-devops-node-api/interfaces/GalleryInterfaces"); 8 | import gallerym = require("azure-devops-node-api/GalleryApi"); 9 | import emsm = require("azure-devops-node-api/ExtensionManagementApi"); 10 | import EmsInterfaces = require("azure-devops-node-api/interfaces/ExtensionManagementInterfaces"); 11 | import https = require("https"); 12 | import http = require("http"); 13 | 14 | import { realPromise } from "../../lib/promiseUtils"; 15 | 16 | const SPS_INSTANCE_TYPE = "951917AC-A960-4999-8464-E3F0AA25B381"; 17 | 18 | export function getCommand(args: string[]): TfCommand { 19 | return new ExtensionInstall(args); 20 | } 21 | 22 | interface TargetAccount { 23 | accountName: string; 24 | accountId: string; 25 | } 26 | 27 | export class AccountInstallReport { 28 | constructor( 29 | public itemId: string, 30 | public accountName: string, 31 | public accountId: string, 32 | public installed: boolean = false, 33 | public reason?: string, 34 | ) {} 35 | 36 | public setError(reason: string) { 37 | this.installed = false; 38 | this.reason = reason; 39 | } 40 | 41 | public setInstalled(reason?: string) { 42 | this.installed = true; 43 | this.reason = reason; 44 | } 45 | } 46 | 47 | export interface ExtensionInstallResult { 48 | accounts: { [account: string]: { installed: boolean; issues: string } }; 49 | extension: string; 50 | } 51 | 52 | export class ExtensionInstall extends extBase.ExtensionBase { 53 | protected description = "Install a Azure DevOps Extension to a list of Azure DevOps Organizations."; 54 | protected serverCommand = true; 55 | 56 | constructor(passedArgs: string[]) { 57 | super(passedArgs); 58 | } 59 | 60 | protected setCommandArgs(): void { 61 | super.setCommandArgs(); 62 | this.registerCommandArgument( 63 | "accounts", 64 | "Installation target organizations", 65 | "List of organizations where to install the extension.", 66 | args.ArrayArgument, 67 | null, 68 | true, 69 | ); 70 | this.registerCommandArgument( 71 | "serviceUrl", 72 | "Collection/Organization URL", 73 | "URL of the organization or collection to install extension to.", 74 | args.StringArgument, 75 | undefined, 76 | ); 77 | } 78 | 79 | protected getHelpArgs(): string[] { 80 | return ["publisher", "extensionId", "vsix", "accounts"]; 81 | } 82 | 83 | public async exec(): Promise { 84 | // Check that they're not trying to use a previous version of this command 85 | const accounts = await this.commandArgs.accounts.val(true); 86 | if (accounts) { 87 | throw new Error( 88 | "Installing extensions to multiple organizations no longer supported. Please use the following syntax to install an extension to an account/collection:\ntfx extension install --service-url --token --publisher --extension-id ", 89 | ); 90 | } 91 | trace.debug("Installing extension by name"); 92 | 93 | // Read extension info from arguments 94 | const result: ExtensionInstallResult = { accounts: {}, extension: null }; 95 | const extInfo = await this._getExtensionInfo(); 96 | const itemId = `${extInfo.publisher}.${extInfo.id}`; 97 | 98 | result.extension = itemId; 99 | 100 | // New flow - service-url contains account. Install to 1 account at a time. 101 | const serviceUrl = await ExtensionInstall.getEmsAccountUrl(await this.commandArgs.serviceUrl.val()); 102 | const emsApi = await this.webApi.getExtensionManagementApi(serviceUrl); 103 | 104 | trace.debug("Installing extension by name: " + extInfo.publisher + ": " + extInfo.id); 105 | try { 106 | const installation = await emsApi.installExtensionByName(extInfo.publisher, extInfo.id); 107 | const installationResult = { installed: true, issues: null }; 108 | if (installation.installState.installationIssues && installation.installState.installationIssues.length > 0) { 109 | installationResult.installed = false; 110 | installationResult.issues = `The following issues were encountered installing to ${serviceUrl}: 111 | ${installation.installState.installationIssues.map(i => " - " + i).join("\n")}`; 112 | } 113 | result.accounts[serviceUrl] = installationResult; 114 | } catch (err) { 115 | if (err.message.indexOf("TF400856") >= 0) { 116 | throw new Error( 117 | "Failed to install extension (TF400856). Ensure service-url includes a collection name, e.g. " + 118 | serviceUrl.replace(/\/$/, "") + 119 | "/DefaultCollection", 120 | ); 121 | } else if (err.message.indexOf("TF1590010") >= 0) { 122 | trace.warn("The given extension is already installed, so nothing happened."); 123 | } else { 124 | throw err; 125 | } 126 | } 127 | 128 | return result; 129 | } 130 | 131 | private getEmsAccountUrl(marketplaceUrl: string, accountName: string) { 132 | if (marketplaceUrl.toLocaleLowerCase().indexOf("marketplace.visualstudio.com") >= 0) { 133 | return `https://${accountName}.extmgmt.visualstudio.com`; 134 | } 135 | if (marketplaceUrl.toLocaleLowerCase().indexOf("me.tfsallin.net") >= 0) { 136 | return marketplaceUrl.toLocaleLowerCase().indexOf("https://") === 0 137 | ? `https://${accountName}.me.tfsallin.net:8781` 138 | : `http://${accountName}.me.tfsallin.net:8780`; 139 | } 140 | return marketplaceUrl; 141 | } 142 | 143 | protected friendlyOutput(data: ExtensionInstallResult): void { 144 | trace.success("\n=== Completed operation: install extension ==="); 145 | Object.keys(data.accounts).forEach(a => { 146 | trace.info(`- ${a}: ${data.accounts[a].installed ? colors.green("success") : colors.red(data.accounts[a].issues)}`); 147 | }); 148 | } 149 | 150 | private async _getExtensionInfo(): Promise { 151 | const vsixPath = await this.commandArgs.vsix.val(true); 152 | let extInfoPromise: Promise; 153 | if (vsixPath !== null) { 154 | extInfoPromise = extInfo.getExtInfo(vsixPath[0], null, null); 155 | } else { 156 | extInfoPromise = Promise.all([this.commandArgs.publisher.val(), this.commandArgs.extensionId.val()]).then< 157 | extInfo.CoreExtInfo 158 | >(values => { 159 | const [publisher, extension] = values; 160 | return extInfo.getExtInfo(null, extension, publisher); 161 | }); 162 | } 163 | 164 | return extInfoPromise; 165 | } 166 | 167 | private static async getEmsAccountUrl(tfsAccountUrl: string): Promise { 168 | trace.debug("Get ems account url for " + tfsAccountUrl); 169 | const acctUrlNoSlash = tfsAccountUrl.endsWith("/") ? tfsAccountUrl.substr(0, tfsAccountUrl.length - 1) : tfsAccountUrl; 170 | 171 | if (acctUrlNoSlash.indexOf("visualstudio.com") < 0 && acctUrlNoSlash.indexOf("dev.azure.com") < 0) { 172 | return acctUrlNoSlash; 173 | } 174 | 175 | const url = `${acctUrlNoSlash}/_apis/resourceareas/6c2b0933-3600-42ae-bf8b-93d4f7e83594`; 176 | const httpModule = url.indexOf("https://") >= 0 ? https : http; 177 | 178 | const response = await new Promise((resolve, reject) => { 179 | httpModule 180 | .get(url, resp => { 181 | let data = ""; 182 | resp.on("data", chunk => { 183 | data += chunk; 184 | }); 185 | resp.on("end", () => { 186 | resolve(data); 187 | }); 188 | }) 189 | .on("error", err => { 190 | reject(err); 191 | }); 192 | }); 193 | trace.debug("response: " + response); 194 | const resourceArea = JSON.parse(response); 195 | return resourceArea.locationUrl; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /app/exec/extension/isvalid.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import colors = require("colors"); 4 | import extBase = require("./default"); 5 | import extInfo = require("./_lib/extensioninfo"); 6 | import galleryContracts = require("azure-devops-node-api/interfaces/GalleryInterfaces"); 7 | import publishUtils = require("./_lib/publish"); 8 | import trace = require("../../lib/trace"); 9 | 10 | export function getCommand(args: string[]): extBase.ExtensionBase { 11 | return new ExtensionIsValid(args); 12 | } 13 | 14 | export class ExtensionIsValid extends extBase.ExtensionBase { 15 | protected description = "Show the validation status of a given extension."; 16 | protected serverCommand = true; 17 | 18 | protected setCommandArgs(): void { 19 | super.setCommandArgs(); 20 | this.registerCommandArgument( 21 | "version", 22 | "Extension version", 23 | "Specify the version of the extension of which to get the validation status. Defaults to the latest version.", 24 | args.StringArgument, 25 | null, 26 | ); 27 | this.registerCommandArgument( 28 | "serviceUrl", 29 | "Market URL", 30 | "URL to the VSS Marketplace.", 31 | args.StringArgument, 32 | extBase.ExtensionBase.getMarketplaceUrl, 33 | ); 34 | } 35 | 36 | protected getHelpArgs(): string[] { 37 | return ["publisher", "extensionId", "vsix", "version"]; 38 | } 39 | 40 | public async exec(): Promise { 41 | const galleryApi = await this.getGalleryApi(); 42 | 43 | const extInfo = await this.identifyExtension(); 44 | const version = await this.commandArgs.version.val(); 45 | const sharingMgr = new publishUtils.SharingManager({}, galleryApi, extInfo); 46 | const validationStatus = await sharingMgr.getValidationStatus(version); 47 | return validationStatus; 48 | } 49 | 50 | protected friendlyOutput(data: string): void { 51 | if (data === publishUtils.GalleryBase.validated) { 52 | trace.info(colors.green("Valid")); 53 | } else if (data === publishUtils.GalleryBase.validationPending) { 54 | trace.info(colors.yellow("Validation pending...")); 55 | } else { 56 | trace.info(colors.red("Validation error: " + data)); 57 | } 58 | } 59 | 60 | protected jsonOutput(data: string): void { 61 | const result = <{ status: string; message?: string }>{ 62 | status: "error", 63 | }; 64 | if (data === publishUtils.GalleryBase.validationPending) { 65 | result.status = "pending"; 66 | } else if (data === publishUtils.GalleryBase.validated) { 67 | result.status = "success"; 68 | } else { 69 | result.message = data; 70 | } 71 | console.log(JSON.stringify(result, null, 4)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/exec/extension/publish.ts: -------------------------------------------------------------------------------- 1 | import { CreationResult, createExtension } from "./create"; 2 | import { TfCommand } from "../../lib/tfcommand"; 3 | import args = require("../../lib/arguments"); 4 | import colors = require("colors"); 5 | import extBase = require("./default"); 6 | import publishUtils = require("./_lib/publish"); 7 | import trace = require("../../lib/trace"); 8 | import { GalleryApi } from "azure-devops-node-api/GalleryApi"; 9 | 10 | export function getCommand(args: string[]): TfCommand { 11 | return new ExtensionPublish(args); 12 | } 13 | 14 | export interface ExtensionCreateArguments { 15 | outputpath: string; 16 | root?: string; 17 | locRoot?: string; 18 | manifestglob?: string[]; 19 | settings?: string; 20 | override?: any; 21 | publisher?: string; 22 | extensionid?: string; 23 | bypassscopecheck?: boolean; 24 | bypassvalidation?: boolean; 25 | } 26 | 27 | export interface ExtensionPublishArguments {} 28 | 29 | export interface ExtensionPublishResult { 30 | packaged: string; 31 | published: boolean; 32 | shared: string[]; 33 | } 34 | 35 | export class ExtensionPublish extends extBase.ExtensionBase { 36 | protected description = "Publish a Visual Studio Marketplace Extension."; 37 | protected serverCommand = true; 38 | 39 | protected getHelpArgs(): string[] { 40 | return [ 41 | "root", 42 | "manifestJs", 43 | "env", 44 | "manifests", 45 | "manifestGlobs", 46 | "json5", 47 | "override", 48 | "overridesFile", 49 | "bypassScopeCheck", 50 | "bypassValidation", 51 | "publisher", 52 | "extensionId", 53 | "outputPath", 54 | "locRoot", 55 | "vsix", 56 | "shareWith", 57 | "noWaitValidation", 58 | "metadataOnly", 59 | ]; 60 | } 61 | 62 | protected setCommandArgs(): void { 63 | super.setCommandArgs(); 64 | this.registerCommandArgument( 65 | "serviceUrl", 66 | "Market URL", 67 | "URL to the VSS Marketplace.", 68 | args.StringArgument, 69 | extBase.ExtensionBase.getMarketplaceUrl, 70 | ); 71 | } 72 | 73 | public async exec(): Promise { 74 | const galleryApi = await this.getGalleryApi(); 75 | let result = {}; 76 | 77 | const publishSettings = await this.getPublishSettings(); 78 | 79 | let extensionCreatePromise: Promise; 80 | if (publishSettings.vsixPath) { 81 | result.packaged = null; 82 | } else { 83 | // Run two async operations in parallel and destructure the result. 84 | const [mergeSettings, packageSettings] = await Promise.all([this.getMergeSettings(), this.getPackageSettings()]); 85 | const createdExtension = await createExtension(mergeSettings, packageSettings); 86 | result.packaged = createdExtension.path; 87 | publishSettings.vsixPath = createdExtension.path; 88 | } 89 | const packagePublisher = new publishUtils.PackagePublisher(publishSettings, galleryApi); 90 | const publishedExtension = await packagePublisher.publish(); 91 | result.published = true; 92 | if (publishSettings.shareWith && publishSettings.shareWith.length >= 0) { 93 | const sharingMgr = new publishUtils.SharingManager(publishSettings, galleryApi); 94 | await sharingMgr.shareWith(publishSettings.shareWith); 95 | result.shared = publishSettings.shareWith; 96 | } else { 97 | result.shared = null; 98 | } 99 | return result; 100 | } 101 | 102 | protected friendlyOutput(data: ExtensionPublishResult): void { 103 | trace.info(colors.green("\n=== Completed operation: publish extension ===")); 104 | let packagingStr = data.packaged ? colors.green(data.packaged) : colors.yellow("not packaged (existing package used)"); 105 | let publishingStr = data.published ? colors.green("success") : colors.yellow("???"); 106 | let sharingStr = data.shared 107 | ? "shared with " + data.shared.map(s => colors.green(s)).join(", ") 108 | : colors.yellow("not shared (use --share-with to share)"); 109 | trace.info(" - Packaging: %s", packagingStr); 110 | trace.info(" - Publishing: %s", publishingStr); 111 | trace.info(" - Sharing: %s", sharingStr); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /app/exec/extension/resources/create.ts: -------------------------------------------------------------------------------- 1 | import { Merger } from "../_lib/merger"; 2 | import { TfCommand } from "../../../lib/tfcommand"; 3 | import { VsixWriter } from "../_lib/vsix-writer"; 4 | import * as Loc from "../_lib/loc"; 5 | import colors = require("colors"); 6 | import extBase = require("../default"); 7 | import trace = require("../../../lib/trace"); 8 | 9 | export function getCommand(args: string[]): TfCommand { 10 | return new GenerateExtensionResources(args); 11 | } 12 | 13 | export interface GenResourcesResult { 14 | resjsonPath: string; 15 | } 16 | 17 | export class GenerateExtensionResources extends extBase.ExtensionBase { 18 | protected description = "Create a vsix package for an extension."; 19 | protected serverCommand = false; 20 | 21 | constructor(passedArgs: string[]) { 22 | super(passedArgs); 23 | } 24 | 25 | protected getHelpArgs(): string[] { 26 | return [ 27 | "root", 28 | "manifestJs", 29 | "env", 30 | "manifests", 31 | "manifestGlobs", 32 | "override", 33 | "overridesFile", 34 | "revVersion", 35 | "bypassScopeCheck", 36 | "bypassValidation", 37 | "publisher", 38 | "extensionId", 39 | "outputPath", 40 | "locRoot", 41 | ]; 42 | } 43 | 44 | public async exec(): Promise { 45 | return this.getMergeSettings().then(mergeSettings => { 46 | return this.getPackageSettings().then(packageSettings => { 47 | return new Merger(mergeSettings).merge().then(components => { 48 | const writer = new VsixWriter(packageSettings, components); 49 | const resjsonPath = writer.getOutputPath(packageSettings.outputPath, "resjson"); 50 | Loc.LocPrep.writeResourceFile(resjsonPath, components.resources.combined); 51 | return { 52 | resjsonPath: writer.getOutputPath(packageSettings.outputPath, "resjson"), 53 | }; 54 | }); 55 | }); 56 | }); 57 | } 58 | 59 | protected friendlyOutput(data: GenResourcesResult): void { 60 | trace.info(colors.green("\n=== Completed operation: generate extension resources ===")); 61 | trace.info(" - .resjson: %s", data.resjsonPath); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/exec/extension/resources/default.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand, CoreArguments } from "../../../lib/tfcommand"; 2 | import args = require("../../../lib/arguments"); 3 | import ext = require("../default"); 4 | 5 | export function getCommand(args: string[]): TfCommand { 6 | return new ExtensionResourcesBase(args); 7 | } 8 | 9 | export class ExtensionResourcesBase extends ext.ExtensionBase { 10 | protected description = "Commands for working with localization of extensions."; 11 | protected serverCommand = false; 12 | 13 | public exec(cmd?: any): Promise { 14 | return this.getHelp(cmd); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/exec/extension/share.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import colors = require("colors"); 4 | import extBase = require("./default"); 5 | import extInfo = require("./_lib/extensioninfo"); 6 | import trace = require("../../lib/trace"); 7 | 8 | import { SharingManager } from "./_lib/publish"; 9 | 10 | export function getCommand(args: string[]): TfCommand { 11 | return new ExtensionShare(args); 12 | } 13 | 14 | export class ExtensionShare extends extBase.ExtensionBase { 15 | protected description = "Share an Azure DevOps Extension with Azure DevOps Organizations."; 16 | protected serverCommand = true; 17 | 18 | constructor(passedArgs: string[]) { 19 | super(passedArgs); 20 | this.registerCommandArgument( 21 | "shareWith", 22 | "Share with", 23 | "List of organizations with which to share the extension.", 24 | args.ArrayArgument, 25 | ); 26 | this.registerCommandArgument( 27 | "serviceUrl", 28 | "Market URL", 29 | "URL to the VSS Marketplace.", 30 | args.StringArgument, 31 | extBase.ExtensionBase.getMarketplaceUrl, 32 | ); 33 | } 34 | 35 | protected getHelpArgs(): string[] { 36 | return ["publisher", "extensionId", "vsix", "shareWith"]; 37 | } 38 | 39 | public async exec(): Promise { 40 | const galleryApi = await this.getGalleryApi(); 41 | 42 | return this.commandArgs.vsix.val(true).then(vsixPath => { 43 | let extInfoPromise: Promise; 44 | if (vsixPath !== null) { 45 | extInfoPromise = extInfo.getExtInfo(vsixPath[0], null, null); 46 | } else { 47 | extInfoPromise = Promise.all([this.commandArgs.publisher.val(), this.commandArgs.extensionId.val()]).then< 48 | extInfo.CoreExtInfo 49 | >(values => { 50 | const [publisher, extension] = values; 51 | return extInfo.getExtInfo(null, extension, publisher); 52 | }); 53 | } 54 | return extInfoPromise.then(extInfo => { 55 | return this.commandArgs.shareWith.val().then(accounts => { 56 | const sharingMgr = new SharingManager({}, galleryApi, extInfo); 57 | return sharingMgr.shareWith(accounts).then(() => accounts); 58 | }); 59 | }); 60 | }); 61 | } 62 | 63 | protected friendlyOutput(data: string[]): void { 64 | trace.success("\n=== Completed operation: share extension ==="); 65 | trace.info(" - Shared with:"); 66 | data.forEach(acct => { 67 | trace.info(" - " + acct); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/exec/extension/show.ts: -------------------------------------------------------------------------------- 1 | import args = require("../../lib/arguments"); 2 | import extBase = require("./default"); 3 | import galleryContracts = require("azure-devops-node-api/interfaces/GalleryInterfaces"); 4 | import publishUtils = require("./_lib/publish"); 5 | 6 | export function getCommand(args: string[]): extBase.ExtensionBase { 7 | return new ExtensionShow(args); 8 | } 9 | 10 | export class ExtensionShow extends extBase.ExtensionBase { 11 | protected description = "Show info about a published Azure DevOps Services Extension."; 12 | protected serverCommand = true; 13 | 14 | protected getHelpArgs(): string[] { 15 | return ["publisher", "extensionId", "vsix"]; 16 | } 17 | 18 | protected setCommandArgs(): void { 19 | super.setCommandArgs(); 20 | this.registerCommandArgument( 21 | "serviceUrl", 22 | "Market URL", 23 | "URL to the VSS Marketplace.", 24 | args.StringArgument, 25 | extBase.ExtensionBase.getMarketplaceUrl, 26 | ); 27 | } 28 | 29 | public async exec(): Promise { 30 | const galleryApi = await this.getGalleryApi(); 31 | 32 | return this.identifyExtension().then(extInfo => { 33 | let sharingMgr = new publishUtils.SharingManager({}, galleryApi, extInfo); 34 | return sharingMgr.getExtensionInfo(); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/exec/extension/unpublish.ts: -------------------------------------------------------------------------------- 1 | import args = require("../../lib/arguments"); 2 | import extBase = require("./default"); 3 | import galleryContracts = require("azure-devops-node-api/interfaces/GalleryInterfaces"); 4 | import trace = require("../../lib/trace"); 5 | 6 | export function getCommand(args: string[]): extBase.ExtensionBase { 7 | return new ExtensionUnpublish(args); 8 | } 9 | 10 | export class ExtensionUnpublish extends extBase.ExtensionBase { 11 | protected description = "Unpublish (delete) an extension from the Marketplace."; 12 | protected serverCommand = true; 13 | 14 | protected getHelpArgs(): string[] { 15 | return ["publisher", "extensionId", "vsix"]; 16 | } 17 | 18 | protected setCommandArgs(): void { 19 | super.setCommandArgs(); 20 | this.registerCommandArgument( 21 | "serviceUrl", 22 | "Market URL", 23 | "URL to the VSS Marketplace.", 24 | args.StringArgument, 25 | extBase.ExtensionBase.getMarketplaceUrl, 26 | ); 27 | } 28 | 29 | public async exec(): Promise { 30 | const galleryApi = await this.getGalleryApi(); 31 | 32 | const extInfo = await this.identifyExtension(); 33 | await galleryApi.deleteExtension(extInfo.publisher, extInfo.id); 34 | 35 | return true; 36 | } 37 | 38 | protected friendlyOutput(): void { 39 | trace.success("\n=== Completed operation: unpublish extension ==="); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/exec/extension/unshare.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import extBase = require("./default"); 4 | import extInfo = require("./_lib/extensioninfo"); 5 | import trace = require("../../lib/trace"); 6 | 7 | export function getCommand(args: string[]): TfCommand { 8 | // this just offers description for help and to offer sub commands 9 | return new ExtensionShare(args); 10 | } 11 | 12 | export class ExtensionShare extends extBase.ExtensionBase { 13 | protected description = "Unshare a Azure Devops Extension with Azure DevOps Organizations."; 14 | protected serverCommand = true; 15 | 16 | constructor(passedArgs: string[]) { 17 | super(passedArgs); 18 | 19 | // Override this argument so we are prompted (e.g. no default provided) 20 | this.registerCommandArgument( 21 | "unshareWith", 22 | "Un-share with", 23 | "List of organizations with which to un-share the extension", 24 | args.ArrayArgument, 25 | ); 26 | 27 | this.registerCommandArgument( 28 | "serviceUrl", 29 | "Market URL", 30 | "URL to the VSS Marketplace.", 31 | args.StringArgument, 32 | extBase.ExtensionBase.getMarketplaceUrl, 33 | ); 34 | } 35 | 36 | protected getHelpArgs(): string[] { 37 | return ["publisher", "extensionId", "vsix", "unshareWith"]; 38 | } 39 | 40 | public async exec(): Promise { 41 | const galleryApi = await this.getGalleryApi(); 42 | 43 | return this.commandArgs.vsix.val(true).then(vsixPath => { 44 | let extInfoPromise: Promise; 45 | if (vsixPath !== null) { 46 | extInfoPromise = extInfo.getExtInfo(vsixPath[0], null, null); 47 | } else { 48 | extInfoPromise = Promise.all([this.commandArgs.publisher.val(), this.commandArgs.extensionId.val()]).then< 49 | extInfo.CoreExtInfo 50 | >(values => { 51 | const [publisher, extension] = values; 52 | return extInfo.getExtInfo(null, extension, publisher); 53 | }); 54 | } 55 | return extInfoPromise.then(extInfo => { 56 | return this.commandArgs.unshareWith.val().then(unshareWith => { 57 | let sharePromises: Promise[] = []; 58 | unshareWith.forEach(account => { 59 | sharePromises.push(galleryApi.unshareExtension(extInfo.publisher, extInfo.id, account)); 60 | }); 61 | return Promise.all(sharePromises).then(() => { 62 | return unshareWith; 63 | }); 64 | }); 65 | }); 66 | }); 67 | } 68 | 69 | protected friendlyOutput(data: string[]): void { 70 | trace.success("\n=== Completed operation: un-share extension ==="); 71 | trace.info(" - Removed sharing from:"); 72 | data.forEach(acct => { 73 | trace.info(" - " + acct); 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/exec/login.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand, CoreArguments } from "../lib/tfcommand"; 2 | import { DiskCache } from "../lib/diskcache"; 3 | import { getCredentialStore } from "../lib/credstore"; 4 | import colors = require("colors"); 5 | import os = require("os"); 6 | import trace = require("../lib/trace"); 7 | 8 | export function getCommand(args: string[]): Login { 9 | // this just offers description for help and to offer sub commands 10 | return new Login(args); 11 | } 12 | 13 | export interface LoginResult { 14 | success: boolean; 15 | } 16 | 17 | /** 18 | * Facilitates a "log in" to a service by caching credentials. 19 | */ 20 | export class Login extends TfCommand { 21 | protected description = "Login and cache credentials using a PAT or basic auth."; 22 | protected serverCommand = true; 23 | 24 | public async exec(): Promise { 25 | trace.debug("Login.exec"); 26 | return this.commandArgs.serviceUrl.val().then(async collectionUrl => { 27 | const skipCertValidation = await this.commandArgs.skipCertValidation.val(false); 28 | 29 | const authHandler = await this.getCredentials(collectionUrl, false); 30 | const webApi = await this.getWebApi({ 31 | ignoreSslError: skipCertValidation 32 | }); 33 | const locationsApi = await webApi.getLocationsApi(); 34 | 35 | try { 36 | const connectionData = await locationsApi.getConnectionData(); 37 | let tfxCredStore = getCredentialStore("tfx"); 38 | let tfxCache = new DiskCache("tfx"); 39 | let credString; 40 | if (authHandler.username === "OAuth") { 41 | credString = "pat:" + authHandler.password; 42 | } else { 43 | credString = "basic:" + authHandler.username + ":" + authHandler.password; 44 | } 45 | await tfxCredStore.storeCredential(collectionUrl, "allusers", credString); 46 | await tfxCache.setItem("cache", "connection", collectionUrl); 47 | await tfxCache.setItem("cache", "skipCertValidation", skipCertValidation.toString()); 48 | return { success: true } as LoginResult; 49 | } catch (err) { 50 | if (err && err.statusCode && err.statusCode === 401) { 51 | trace.debug("Connection failed: invalid credentials."); 52 | throw new Error("Invalid credentials. " + err.message); 53 | } else if (err) { 54 | trace.debug("Connection failed."); 55 | throw new Error( 56 | "Connection failed. Check your internet connection & collection URL." + 57 | os.EOL + 58 | "Message: " + 59 | err.message, 60 | ); 61 | } else { 62 | throw new Error("Unknown error logging in."); 63 | } 64 | } 65 | }); 66 | } 67 | 68 | public friendlyOutput(data: LoginResult): void { 69 | if (data.success) { 70 | trace.info(colors.green("Logged in successfully")); 71 | } else { 72 | trace.error("login unsuccessful."); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/exec/logout.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand, CoreArguments } from "../lib/tfcommand"; 2 | import { DiskCache } from "../lib/diskcache"; 3 | import { EOL as eol } from "os"; 4 | import args = require("../lib/arguments"); 5 | import common = require("../lib/common"); 6 | import credStore = require("../lib/credstore"); 7 | import path = require("path"); 8 | 9 | import trace = require("../lib/trace"); 10 | 11 | export function getCommand(args: string[]): Reset { 12 | return new Reset(args); 13 | } 14 | 15 | export class Reset extends TfCommand { 16 | protected description = "Log out and clear cached credential."; 17 | protected getHelpArgs() { 18 | return []; 19 | } 20 | protected serverCommand = false; 21 | 22 | constructor(args: string[]) { 23 | super(args); 24 | } 25 | 26 | public async exec(): Promise { 27 | return Promise.resolve(null); 28 | } 29 | 30 | public dispose(): Promise { 31 | let diskCache = new DiskCache("tfx"); 32 | return diskCache.itemExists("cache", "connection").then(isCachedConnection => { 33 | if (isCachedConnection) { 34 | return diskCache 35 | .getItem("cache", "connection") 36 | .then(cachedConnection => { 37 | let store = credStore.getCredentialStore("tfx"); 38 | return store.credentialExists(cachedConnection, "allusers").then(isCredential => { 39 | if (isCredential) { 40 | return store.clearCredential(cachedConnection, "allusers"); 41 | } else { 42 | return Promise.resolve(null); 43 | } 44 | }); 45 | }) 46 | .then(() => { 47 | return diskCache.deleteItem("cache", "connection"); 48 | }); 49 | } else { 50 | return Promise.resolve(null); 51 | } 52 | }); 53 | } 54 | 55 | public friendlyOutput(): void { 56 | trace.success("Successfully logged out."); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/exec/reset.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand, CoreArguments } from "../lib/tfcommand"; 2 | import { DiskCache } from "../lib/diskcache"; 3 | import { EOL as eol } from "os"; 4 | import args = require("../lib/arguments"); 5 | import common = require("../lib/common"); 6 | import path = require("path"); 7 | 8 | import trace = require("../lib/trace"); 9 | 10 | export function getCommand(args: string[]): Reset { 11 | return new Reset(args); 12 | } 13 | 14 | export interface ResetArgs extends CoreArguments { 15 | all: args.BooleanArgument; 16 | } 17 | 18 | export class Reset extends TfCommand { 19 | protected description = "Reset any saved options to their defaults."; 20 | protected getHelpArgs() { 21 | return ["all"]; 22 | } 23 | protected serverCommand = false; 24 | 25 | constructor(args: string[]) { 26 | super(args); 27 | } 28 | 29 | protected setCommandArgs(): void { 30 | super.setCommandArgs(); 31 | this.registerCommandArgument( 32 | "all", 33 | "All directories", 34 | "Pass this option to reset saved options for all directories.", 35 | args.BooleanArgument, 36 | "false", 37 | ); 38 | } 39 | 40 | public async exec(): Promise { 41 | return Promise.resolve(null); 42 | } 43 | 44 | public dispose(): Promise { 45 | let currentPath = path.resolve(); 46 | return this.commandArgs.all.val().then(allSettings => { 47 | return args.getOptionsCache().then(existingCache => { 48 | if (existingCache[currentPath]) { 49 | existingCache[currentPath] = {}; 50 | return new DiskCache("tfx").setItem( 51 | "cache", 52 | "command-options", 53 | allSettings ? "" : JSON.stringify(existingCache, null, 4).replace(/\n/g, eol), 54 | ); 55 | } else { 56 | return Promise.resolve(null); 57 | } 58 | }); 59 | }); 60 | } 61 | 62 | public friendlyOutput(): void { 63 | trace.success("Settings reset."); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/exec/version.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand, CoreArguments } from "../lib/tfcommand"; 2 | import version = require("../lib/version"); 3 | import trace = require("../lib/trace"); 4 | 5 | export function getCommand(args: string[]): TfCommand { 6 | return new Version(args); 7 | } 8 | 9 | export class Version extends TfCommand { 10 | protected description = "Output the version of this tool."; 11 | protected getHelpArgs() { 12 | return []; 13 | } 14 | protected serverCommand = false; 15 | 16 | constructor(args: string[]) { 17 | super(args); 18 | } 19 | 20 | public async exec(): Promise { 21 | trace.debug("version.exec"); 22 | return version.getTfxVersion(); 23 | } 24 | 25 | public friendlyOutput(data: version.SemanticVersion): void { 26 | trace.info("Version %s", data.toString()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/exec/workitem/create.ts: -------------------------------------------------------------------------------- 1 | import { EOL as eol } from "os"; 2 | import { TfCommand } from "../../lib/tfcommand"; 3 | import args = require("../../lib/arguments"); 4 | import trace = require("../../lib/trace"); 5 | import witBase = require("./default"); 6 | import witClient = require("azure-devops-node-api/WorkItemTrackingApi"); 7 | import witContracts = require("azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"); 8 | 9 | export function getCommand(args: string[]): WorkItemCreate { 10 | return new WorkItemCreate(args); 11 | } 12 | 13 | export class WorkItemCreate extends witBase.WorkItemBase { 14 | protected description = "Create a Work Item."; 15 | protected serverCommand = true; 16 | 17 | protected getHelpArgs(): string[] { 18 | return ["workItemType", "title", "assignedTo", "description", "project", "values"]; 19 | } 20 | 21 | public async exec(): Promise { 22 | var witapi = await this.webApi.getWorkItemTrackingApi(); 23 | 24 | return Promise.all([ 25 | this.commandArgs.workItemType.val(), 26 | this.commandArgs.project.val(), 27 | this.commandArgs.title.val(true), 28 | this.commandArgs.assignedTo.val(true), 29 | this.commandArgs.description.val(true), 30 | this.commandArgs.values.val(true), 31 | ]).then(promiseValues => { 32 | const [wiType, project, title, assignedTo, description, values] = promiseValues; 33 | if (!title && !assignedTo && !description && (!values || Object.keys(values).length <= 0)) { 34 | throw new Error("At least one field value must be specified."); 35 | } 36 | 37 | var patchDoc = witBase.buildWorkItemPatchDoc(title, assignedTo, description, values); 38 | return witapi.createWorkItem(null, patchDoc, project, wiType); 39 | }); 40 | } 41 | 42 | public friendlyOutput(workItem: witContracts.WorkItem): void { 43 | return witBase.friendlyOutput([workItem]); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/exec/workitem/default.ts: -------------------------------------------------------------------------------- 1 | import { TfCommand, CoreArguments } from "../../lib/tfcommand"; 2 | import args = require("../../lib/arguments"); 3 | import vssCoreContracts = require("azure-devops-node-api/interfaces/common/VSSInterfaces"); 4 | import witContracts = require("azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"); 5 | import trace = require("../../lib/trace"); 6 | import { EOL as eol } from "os"; 7 | import _ = require("lodash"); 8 | 9 | export class WorkItemValuesJsonArgument extends args.JsonArgument {} 10 | 11 | export interface WorkItemArguments extends CoreArguments { 12 | workItemId: args.IntArgument; 13 | query: args.StringArgument; 14 | workItemType: args.StringArgument; 15 | 16 | // Convienience way to set common work item arguments 17 | assignedTo: args.StringArgument; 18 | title: args.StringArgument; 19 | description: args.StringArgument; 20 | 21 | // Generic way to assign work item values 22 | values: WorkItemValuesJsonArgument; 23 | } 24 | 25 | export function getCommand(args: string[]): TfCommand { 26 | return new WorkItemBase(args); 27 | } 28 | 29 | export class WorkItemBase extends TfCommand { 30 | protected description = "Commands for managing Work Items."; 31 | protected serverCommand = false; 32 | 33 | protected setCommandArgs(): void { 34 | super.setCommandArgs(); 35 | 36 | this.registerCommandArgument("workItemId", "Work Item ID", "Identifies a particular Work Item.", args.IntArgument); 37 | this.registerCommandArgument("query", "Work Item Query (WIQL)", null, args.StringArgument); 38 | this.registerCommandArgument("workItemType", "Work Item Type", "Type of Work Item to create.", args.StringArgument); 39 | this.registerCommandArgument("assignedTo", "Assigned To", "Who to assign the Work Item to.", args.StringArgument); 40 | this.registerCommandArgument("title", "Work Item Title", "Title of the Work Item.", args.StringArgument); 41 | this.registerCommandArgument( 42 | "description", 43 | "Work Item Description", 44 | "Description of the Work Item.", 45 | args.StringArgument, 46 | ); 47 | this.registerCommandArgument( 48 | "values", 49 | "Work Item Values", 50 | 'Mapping from field reference name to value to set on the workitem. (E.g. {"system.assignedto": "Some Name"})', 51 | WorkItemValuesJsonArgument, 52 | "{}", 53 | ); 54 | } 55 | 56 | public exec(cmd?: any): Promise { 57 | return this.getHelp(cmd); 58 | } 59 | } 60 | 61 | export function friendlyOutput(data: witContracts.WorkItem[]): void { 62 | if (!data) { 63 | throw new Error("no results"); 64 | } 65 | 66 | if (data.length <= 0) { 67 | return trace.info("Command returned no results."); 68 | } 69 | 70 | let fieldsToIgnore = [ 71 | "System.Id", 72 | "System.AreaLevel1", 73 | "System.IterationId", 74 | "System.IterationLevel1", 75 | "System.ExternalLinkCount", 76 | "System.AreaLevel1", 77 | ]; 78 | 79 | data.forEach(workItem => { 80 | trace.info(eol); 81 | trace.info("System.Id: " + workItem.id); 82 | trace.info("System.Rev: " + workItem.rev); 83 | Object.keys(workItem.fields).forEach(arg => { 84 | if (!_.includes(fieldsToIgnore, arg)) { 85 | trace.info(arg + ": " + workItem.fields[arg]); 86 | } 87 | }); 88 | }); 89 | } 90 | 91 | export function buildWorkItemPatchDoc(title, assignedTo, description, values) { 92 | var patchDoc: vssCoreContracts.JsonPatchOperation[] = []; 93 | 94 | // Check the convienience helpers for wit values 95 | if (title) { 96 | patchDoc.push({ 97 | op: vssCoreContracts.Operation.Add, 98 | path: "/fields/System.Title", 99 | value: title, 100 | from: null, 101 | }); 102 | } 103 | 104 | if (assignedTo) { 105 | patchDoc.push({ 106 | op: vssCoreContracts.Operation.Add, 107 | path: "/fields/System.AssignedTo", 108 | value: assignedTo, 109 | from: null, 110 | }); 111 | } 112 | 113 | if (description) { 114 | patchDoc.push({ 115 | op: vssCoreContracts.Operation.Add, 116 | path: "/fields/System.Description", 117 | value: description, 118 | from: null, 119 | }); 120 | } 121 | 122 | // Set the field reference values 123 | Object.keys(values).forEach(fieldReference => { 124 | patchDoc.push({ 125 | op: vssCoreContracts.Operation.Add, 126 | path: "/fields/" + fieldReference, 127 | value: values[fieldReference], 128 | from: null, 129 | }); 130 | }); 131 | 132 | return patchDoc; 133 | } 134 | -------------------------------------------------------------------------------- /app/exec/workitem/query.ts: -------------------------------------------------------------------------------- 1 | import { EOL as eol } from "os"; 2 | import { TfCommand } from "../../lib/tfcommand"; 3 | import _ = require("lodash"); 4 | import args = require("../../lib/arguments"); 5 | import coreContracts = require("azure-devops-node-api/interfaces/CoreInterfaces"); 6 | import trace = require("../../lib/trace"); 7 | import witBase = require("./default"); 8 | import witClient = require("azure-devops-node-api/WorkItemTrackingApi"); 9 | import witContracts = require("azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"); 10 | 11 | export function getCommand(args: string[]): WorkItemQuery { 12 | return new WorkItemQuery(args); 13 | } 14 | 15 | export class WorkItemQuery extends witBase.WorkItemBase { 16 | protected description = "Get a list of Work Items given a query"; 17 | protected serverCommand = true; 18 | 19 | protected getHelpArgs(): string[] { 20 | return ["project", "query"]; 21 | } 22 | 23 | public async exec(): Promise { 24 | var witApi: witClient.IWorkItemTrackingApi = await this.webApi.getWorkItemTrackingApi(); 25 | 26 | return this.commandArgs.project.val(true).then(projectName => { 27 | return this.commandArgs.query.val().then(query => { 28 | let wiql: witContracts.Wiql = { query: query }; 29 | return witApi.queryByWiql(wiql, { project: projectName }).then(result => { 30 | let workItemIds: number[] = []; 31 | 32 | // Flat Query 33 | if (result.queryType == witContracts.QueryType.Flat) { 34 | workItemIds = result.workItems.map(val => val.id).slice(0, Math.min(200, result.workItems.length)); 35 | } 36 | 37 | // Link Query 38 | else { 39 | let sourceIds = result.workItemRelations 40 | .filter(relation => relation.source && relation.source.id) 41 | .map(relation => relation.source.id); 42 | let targetIds = result.workItemRelations 43 | .filter(relation => relation.target && relation.target.id) 44 | .map(relation => relation.target.id); 45 | let allIds = sourceIds.concat(targetIds); 46 | workItemIds = allIds.slice(0, Math.min(200, allIds.length)); 47 | } 48 | 49 | let fieldRefs = result.columns.map(val => val.referenceName); 50 | 51 | fieldRefs = fieldRefs.slice(0, Math.min(20, result.columns.length)); 52 | return workItemIds.length > 0 ? witApi.getWorkItems(workItemIds, fieldRefs) : []; 53 | }); 54 | }); 55 | }); 56 | } 57 | 58 | public friendlyOutput(data: witContracts.WorkItem[]): void { 59 | return witBase.friendlyOutput(data); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/exec/workitem/show.ts: -------------------------------------------------------------------------------- 1 | import { EOL as eol } from "os"; 2 | import { TfCommand } from "../../lib/tfcommand"; 3 | import args = require("../../lib/arguments"); 4 | import trace = require("../../lib/trace"); 5 | import witBase = require("./default"); 6 | import witClient = require("azure-devops-node-api/WorkItemTrackingApi"); 7 | import witContracts = require("azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"); 8 | 9 | export function getCommand(args: string[]): WorkItemShow { 10 | return new WorkItemShow(args); 11 | } 12 | 13 | export class WorkItemShow extends witBase.WorkItemBase { 14 | protected description = "Show Work Item details."; 15 | protected serverCommand = true; 16 | 17 | protected getHelpArgs(): string[] { 18 | return ["workItemId"]; 19 | } 20 | 21 | public async exec(): Promise { 22 | var witapi: witClient.IWorkItemTrackingApi = await this.webApi.getWorkItemTrackingApi(); 23 | return this.commandArgs.workItemId.val().then(workItemId => { 24 | return witapi.getWorkItem(workItemId); 25 | }); 26 | } 27 | 28 | public friendlyOutput(workItem: witContracts.WorkItem): void { 29 | return witBase.friendlyOutput([workItem]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/exec/workitem/update.ts: -------------------------------------------------------------------------------- 1 | import { EOL as eol } from "os"; 2 | import { TfCommand } from "../../lib/tfcommand"; 3 | import args = require("../../lib/arguments"); 4 | import trace = require("../../lib/trace"); 5 | import witBase = require("./default"); 6 | import witClient = require("azure-devops-node-api/WorkItemTrackingApi"); 7 | import witContracts = require("azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"); 8 | 9 | export function getCommand(args: string[]): WorkItemUpdate { 10 | return new WorkItemUpdate(args); 11 | } 12 | 13 | export class WorkItemUpdate extends witBase.WorkItemBase { 14 | protected description = "Update a Work Item."; 15 | protected serverCommand = true; 16 | 17 | protected getHelpArgs(): string[] { 18 | return ["workItemId", "title", "assignedTo", "description", "values"]; 19 | } 20 | 21 | public async exec(): Promise { 22 | var witapi = await this.webApi.getWorkItemTrackingApi(); 23 | 24 | return Promise.all([ 25 | this.commandArgs.workItemId.val(), 26 | this.commandArgs.title.val(true), 27 | this.commandArgs.assignedTo.val(true), 28 | this.commandArgs.description.val(true), 29 | this.commandArgs.values.val(true), 30 | ]).then(promiseValues => { 31 | const [workItemId, title, assignedTo, description, values] = promiseValues; 32 | if (!title && !assignedTo && !description && (!values || Object.keys(values).length <= 0)) { 33 | throw new Error("At least one field value must be specified."); 34 | } 35 | 36 | var patchDoc = witBase.buildWorkItemPatchDoc(title, assignedTo, description, values); 37 | return witapi.updateWorkItem(null, patchDoc, workItemId); 38 | }); 39 | } 40 | 41 | public friendlyOutput(workItem: witContracts.WorkItem): void { 42 | return witBase.friendlyOutput([workItem]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/lib/command.ts: -------------------------------------------------------------------------------- 1 | import common = require("./common"); 2 | import path = require("path"); 3 | import { lstat, readdir } from "fs"; 4 | import { promisify } from "util"; 5 | 6 | export interface TFXCommand { 7 | execPath: string[]; 8 | args: string[]; 9 | commandHierarchy: CommandHierarchy; 10 | } 11 | 12 | export interface CommandHierarchy { 13 | [command: string]: CommandHierarchy; 14 | } 15 | 16 | export function getCommand(): Promise { 17 | let args = process.argv.slice(2); 18 | return getCommandHierarchy(path.resolve(common.APP_ROOT, "exec")).then(hierarchy => { 19 | let execPath: string[] = []; 20 | let commandArgs: string[] = []; 21 | let currentHierarchy = hierarchy; 22 | let inArgs = false; 23 | args.forEach(arg => { 24 | if (arg.substr(0, 1) === "-" || inArgs) { 25 | commandArgs.push(arg); 26 | inArgs = true; 27 | } else if (currentHierarchy && currentHierarchy[arg] !== undefined) { 28 | currentHierarchy = currentHierarchy[arg]; 29 | execPath.push(arg); 30 | } else { 31 | throw "Command '" + arg + "' not found. For help, type tfx " + execPath.join(" ") + " --help"; 32 | } 33 | }); 34 | return { 35 | execPath: execPath, 36 | args: commandArgs, 37 | commandHierarchy: hierarchy, 38 | }; 39 | }); 40 | } 41 | 42 | function getCommandHierarchy(root: string): Promise { 43 | let hierarchy: CommandHierarchy = {}; 44 | return promisify(readdir)(root).then(files => { 45 | let filePromises = []; 46 | files.forEach(file => { 47 | if (file.startsWith("_") || file.endsWith(".map")) { 48 | return; 49 | } 50 | let fullPath = path.resolve(root, file); 51 | let parsedPath = path.parse(fullPath); 52 | 53 | let promise = promisify(lstat)(fullPath).then(stats => { 54 | if (stats.isDirectory()) { 55 | return getCommandHierarchy(fullPath).then(subHierarchy => { 56 | hierarchy[parsedPath.name] = subHierarchy; 57 | }); 58 | } else { 59 | hierarchy[parsedPath.name] = null; 60 | return null; 61 | } 62 | }); 63 | filePromises.push(promise); 64 | }); 65 | return Promise.all(filePromises).then(() => { 66 | return hierarchy; 67 | }); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /app/lib/common.ts: -------------------------------------------------------------------------------- 1 | export let APP_ROOT: string; 2 | export let NO_PROMPT: boolean; 3 | export let EXEC_PATH: string[]; 4 | 5 | export interface IStringDictionary { 6 | [name: string]: string; 7 | } 8 | export interface IStringIndexer { 9 | [name: string]: any; 10 | } 11 | export interface IOptions { 12 | [name: string]: string; 13 | } 14 | 15 | export function endsWith(str: string, end: string): boolean { 16 | return str.slice(-end.length) == end; 17 | } 18 | 19 | /** 20 | * Generate a new rfc4122 version 4 compliant GUID. 21 | */ 22 | export function newGuid() { 23 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { 24 | var r = (Math.random() * 16) | 0, 25 | v = c == "x" ? r : (r & 0x3) | 0x8; 26 | return v.toString(16); 27 | }); 28 | } 29 | 30 | /** 31 | * Repeat a string times. 32 | */ 33 | export function repeatStr(str: string, count: number): string { 34 | let result = []; 35 | for (let i = 0; i < count; ++i) { 36 | result.push(str); 37 | } 38 | return result.join(""); 39 | } 40 | -------------------------------------------------------------------------------- /app/lib/connection.ts: -------------------------------------------------------------------------------- 1 | import { BasicCredentialHandler } from "azure-devops-node-api/handlers/basiccreds"; 2 | 3 | import url = require("url"); 4 | import apim = require("azure-devops-node-api/WebApi"); 5 | import apibasem = require("azure-devops-node-api/interfaces/common/VsoBaseInterfaces"); 6 | import trace = require("./trace"); 7 | 8 | export class TfsConnection { 9 | private parsedUrl: url.Url; 10 | 11 | private accountUrl: string; 12 | private collectionUrl: string; 13 | 14 | constructor(private serviceUrl: string) { 15 | this.parsedUrl = url.parse(this.serviceUrl); 16 | 17 | var splitPath: string[] = this.parsedUrl.path.split("/").slice(1); 18 | this.accountUrl = this.parsedUrl.protocol + "//" + this.parsedUrl.host; 19 | 20 | if (splitPath.length === 2 && splitPath[0] === "tfs") { 21 | // on prem 22 | this.accountUrl += "/" + "tfs"; 23 | } else if (!this.parsedUrl.protocol || !this.parsedUrl.host) { 24 | throw new Error("Invalid service url - protocol and host are required"); 25 | } else if (splitPath.length > 1) { 26 | throw new Error( 27 | "Invalid service url - path is too long. A service URL should include the account/application URL and the collection, e.g. https://fabrikam.visualstudio.com/DefaultCollection or http://tfs-server:8080/tfs/DefaultCollection", 28 | ); 29 | } else if (splitPath.length === 0) { 30 | throw new Error("Expected URL path."); 31 | } 32 | 33 | if (splitPath[0].trim() !== "" || (splitPath[0] === "tfs" && splitPath[1].trim() !== "")) { 34 | this.collectionUrl = this.serviceUrl; 35 | } 36 | } 37 | 38 | /** 39 | * Returns the account URL from the given service URL 40 | */ 41 | public getAccountUrl() { 42 | return this.accountUrl; 43 | } 44 | 45 | /** 46 | * Returns the collection URL from the given service URL 47 | * if a collection is available. Otherwise, throws. 48 | * @TODO maybe make this prompt for a new url? 49 | */ 50 | public getCollectionUrl() { 51 | if (this.collectionUrl) { 52 | return this.collectionUrl; 53 | } else { 54 | throw new Error( 55 | "Provided URL '" + 56 | this.serviceUrl + 57 | "' is not a collection-level URL. Ensure the URL contains a collection, e.g. https://fabrikam.visualstudio.com/DefaultCollection.", 58 | ); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/lib/credstore.ts: -------------------------------------------------------------------------------- 1 | import fs = require("fs"); 2 | 3 | var osHomedir = require("os-homedir"); 4 | var path = require("path"); 5 | var cm = require("./diskcache"); 6 | var cache = new cm.DiskCache("tfx"); 7 | 8 | export function getCredentialStore(appName: string): ICredentialStore { 9 | // TODO: switch on OS specific cred stores. 10 | var store: ICredentialStore = new FileStore(); 11 | store.appName = appName; 12 | return store; 13 | } 14 | 15 | // TODO: break out into a separate 16 | export interface ICredentialStore { 17 | appName: string; 18 | 19 | credentialExists(service: string, user: string): Promise; 20 | getCredential(service: string, user: string): Promise; 21 | storeCredential(service: string, user: string, password: string): Promise; 22 | clearCredential(service: string, user: string): Promise; 23 | } 24 | 25 | class FileStore { 26 | public appName: string; 27 | 28 | private escapeService(service: string): string { 29 | service = service.replace(/:/g, ""); 30 | service = service.replace(/\//g, "_"); 31 | return service; 32 | } 33 | 34 | public credentialExists(service: string, user: string): Promise { 35 | return cache.itemExists(this.escapeService(service), user); 36 | } 37 | 38 | public getCredential(service: string, user: string): Promise { 39 | return cache.getItem(this.escapeService(service), user); 40 | } 41 | 42 | public storeCredential(service: string, user: string, password: string): Promise { 43 | return cache.setItem(this.escapeService(service), user, password); 44 | } 45 | 46 | public clearCredential(service: string, user: string): Promise { 47 | return cache.deleteItem(this.escapeService(service), user); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/lib/diskcache.ts: -------------------------------------------------------------------------------- 1 | import * as common from "./common"; 2 | import * as fs from "fs"; 3 | import { defer } from "./promiseUtils"; 4 | 5 | var osHomedir = require("os-homedir"); 6 | var path = require("path"); 7 | var shell = require("shelljs"); 8 | var trace = require("./trace"); 9 | 10 | export class DiskCache { 11 | constructor(appName: string) { 12 | this.appName = appName; 13 | } 14 | 15 | public appName: string; 16 | 17 | private getFilePath(store: string, name: string): string { 18 | var storeFolder = path.join(osHomedir(), "." + this.appName, store); 19 | try { 20 | shell.mkdir("-p", storeFolder); 21 | } catch (e) {} 22 | return path.join(storeFolder, "." + name); 23 | } 24 | 25 | public itemExists(store: string, name: string): Promise { 26 | var deferred = defer(); 27 | 28 | fs.exists(this.getFilePath(store, name), (exists: boolean) => { 29 | deferred.resolve(exists); 30 | }); 31 | 32 | return deferred.promise; 33 | } 34 | 35 | public getItem(store: string, name: string): Promise { 36 | trace.debug("cache.getItem"); 37 | var deferred = defer(); 38 | var fp = this.getFilePath(store, name); 39 | trace.debugArea("read: " + store + ":" + name, "CACHE"); 40 | trace.debugArea(fp, "CACHE"); 41 | fs.readFile(fp, (err: Error, contents: Buffer) => { 42 | if (err) { 43 | deferred.reject(err); 44 | return; 45 | } 46 | 47 | var str = contents.toString(); 48 | trace.debugArea(str, "CACHE"); 49 | deferred.resolve(str); 50 | }); 51 | 52 | return deferred.promise; 53 | } 54 | 55 | public setItem(store: string, name: string, data: string): Promise { 56 | trace.debug("cache.setItem"); 57 | var deferred = defer(); 58 | var fp = this.getFilePath(store, name); 59 | trace.debugArea("write: " + store + ":" + name + ":" + data, "CACHE"); 60 | trace.debugArea(fp, "CACHE"); 61 | fs.writeFile(fp, data, { flag: "w" }, (err: Error) => { 62 | if (err) { 63 | deferred.reject(err); 64 | return; 65 | } 66 | trace.debugArea("written", "CACHE"); 67 | deferred.resolve(null); 68 | }); 69 | 70 | return deferred.promise; 71 | } 72 | 73 | public deleteItem(store: string, name: string): Promise { 74 | return new Promise((resolve, reject) => { 75 | fs.unlink(this.getFilePath(store, name), err => { 76 | if (err) { 77 | reject(err); 78 | } else { 79 | resolve(null); 80 | } 81 | }); 82 | }); 83 | } 84 | } 85 | 86 | export function parseSettingsFile(settingsPath: string, noWarn: boolean): Promise { 87 | trace.debug("diskcache.parseSettings"); 88 | trace.debug("reading settings from %s", settingsPath); 89 | return new Promise((resolve, reject) => { 90 | try { 91 | if (fs.existsSync(settingsPath)) { 92 | let settingsStr = fs.readFileSync(settingsPath, "utf8").replace(/^\uFEFF/, ""); 93 | let settingsJSON; 94 | try { 95 | resolve(JSON.parse(settingsStr)); 96 | } catch (err) { 97 | trace.warn("Could not parse settings file as JSON. No settings were read from %s.", settingsPath); 98 | resolve({}); 99 | } 100 | } else { 101 | if (!noWarn) { 102 | trace.warn("No settings file found at %s.", settingsPath); 103 | } 104 | resolve({}); 105 | } 106 | } catch (err) { 107 | reject(err); 108 | } 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /app/lib/dynamicVersion.ts: -------------------------------------------------------------------------------- 1 | export class DynamicVersion { 2 | protected numbers: number[]; 3 | 4 | constructor(...numbers: number[]) { 5 | if (numbers.some(n => n < 0)) { 6 | throw new Error("Version numbers must be non-negative."); 7 | } 8 | 9 | if (numbers.every(n => n === 0)) { 10 | throw new Error("Version must be greater than 0.0.0"); 11 | } 12 | 13 | this.numbers = numbers; 14 | } 15 | 16 | /** 17 | * Parse a DynamicVersion from a string. 18 | */ 19 | public static parse(version: string): DynamicVersion { 20 | try { 21 | const splitVersion = version.split(".").map(v => parseInt(v)); 22 | if (!splitVersion.some(e => isNaN(e))) { 23 | return new DynamicVersion(...splitVersion); 24 | } else { 25 | throw ""; 26 | } 27 | } catch (e) { 28 | throw new Error("Could not parse '" + version + "' as a Semantic Version."); 29 | } 30 | } 31 | 32 | /** 33 | * Increase the last number of a dynamic version and returns the new version. 34 | */ 35 | public static increase(version: DynamicVersion): DynamicVersion { 36 | const newVersion = version.numbers; 37 | newVersion[newVersion.length - 1] = newVersion[newVersion.length - 1] + 1; 38 | return new DynamicVersion(...newVersion); 39 | } 40 | 41 | /** 42 | * Return a string-representation of this dynamic version, e.g. 2.10.5.42 43 | */ 44 | public toString(): string { 45 | return this.numbers.join("."); 46 | } 47 | 48 | /** 49 | * Return < 0 if this version is less than other, 50 | * > 0 if this version is greater than other, 51 | * and 0 if they are equal. 52 | * 53 | * If this version length is less than than other 54 | * this version is less than other. 55 | */ 56 | public compareTo(other: DynamicVersion): number { 57 | // [2,0,7] --- [2,0,7,1] 58 | for (let i = 0; i < Math.min(this.numbers.length, other.numbers.length); ++i) { 59 | const thisV = this.numbers[i]; 60 | const otherV = other.numbers[i]; 61 | if (thisV !== otherV) { 62 | return thisV - otherV; 63 | } 64 | } 65 | return this.numbers.length - other.numbers.length; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/lib/errorhandler.ts: -------------------------------------------------------------------------------- 1 | import trace = require("./trace"); 2 | 3 | export function httpErr(obj): any { 4 | let errorAsObj = obj; 5 | if (typeof errorAsObj === "string") { 6 | try { 7 | errorAsObj = JSON.parse(errorAsObj); 8 | } catch (parseError) { 9 | throw errorAsObj; 10 | } 11 | } 12 | let statusCode: number = errorAsObj.statusCode; 13 | if (statusCode === 401) { 14 | throw "Received response 401 (Not Authorized). Check that your personal access token is correct and hasn't expired."; 15 | } 16 | if (statusCode === 403) { 17 | throw "Received response 403 (Forbidden). Check that you have access to this resource. Message from server: " + 18 | errorAsObj.message; 19 | } 20 | let errorBodyObj = errorAsObj.body; 21 | if (errorBodyObj) { 22 | if (typeof errorBodyObj === "string") { 23 | try { 24 | errorBodyObj = JSON.parse(errorBodyObj); 25 | } catch (parseError) { 26 | throw errorBodyObj; 27 | } 28 | } 29 | if (errorBodyObj.message) { 30 | let message = errorBodyObj.message; 31 | if (message) { 32 | throw message; 33 | } else { 34 | throw errorBodyObj; 35 | } 36 | } 37 | } else { 38 | throw errorAsObj.message || "Encountered an unknown failure issuing an HTTP request."; 39 | } 40 | } 41 | 42 | export function errLog(arg) { 43 | if (typeof arg === "string") { 44 | trace.error(arg); 45 | } else if (typeof arg.toString === "function") { 46 | trace.debug(arg.stack); 47 | trace.error(arg.toString()); 48 | } else if (typeof arg === "object") { 49 | try { 50 | trace.error(JSON.parse(arg)); 51 | } catch (e) { 52 | trace.error(arg); 53 | } 54 | } else { 55 | trace.error(arg); 56 | } 57 | process.exit(-1); 58 | } 59 | -------------------------------------------------------------------------------- /app/lib/fsUtils.ts: -------------------------------------------------------------------------------- 1 | import fs = require("fs"); 2 | import { promisify } from "util"; 3 | 4 | // This is an fs lib that uses Q instead of callbacks. 5 | 6 | export var W_OK = fs.constants ? fs.constants.W_OK : (fs as any).W_OK; // back-compat 7 | export var R_OK = fs.constants ? fs.constants.R_OK : (fs as any).R_OK; // back-compat 8 | export var X_OK = fs.constants ? fs.constants.X_OK : (fs as any).X_OK; // back-compat 9 | export var F_OK = fs.constants ? fs.constants.F_OK : (fs as any).F_OK; // back-compat 10 | 11 | export function exists(path: string): Promise { 12 | return new Promise(resolve => { 13 | fs.exists(path, fileExists => { 14 | resolve(fileExists); 15 | }); 16 | }); 17 | } 18 | 19 | /** 20 | * Returns a promise resolved true or false if a file is accessible 21 | * with the given mode (F_OK, R_OK, W_OK, X_OK) 22 | */ 23 | export function fileAccess(path: string, mode: number = F_OK): Promise { 24 | return new Promise(resolve => { 25 | fs.access(path, mode, err => { 26 | if (err) { 27 | resolve(false); 28 | } else { 29 | resolve(true); 30 | } 31 | }); 32 | }); 33 | } 34 | 35 | /** 36 | * Given a valid path, resolves true if the file represented by the path 37 | * can be written to. Files that do not exist are assumed writable. 38 | */ 39 | export function canWriteTo(path: string): Promise { 40 | return exists(path).then(exists => { 41 | if (exists) { 42 | return fileAccess(path, W_OK); 43 | } else { 44 | return true; 45 | } 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /app/lib/jsonvalidate.ts: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | import * as path from 'path'; 3 | var check = require("validator"); 4 | var trace = require("./trace"); 5 | 6 | const deprecatedRunners = ["Node", "Node6", "Node10", "Node16"]; 7 | 8 | export interface TaskJson { 9 | id: string; 10 | } 11 | 12 | /* 13 | * Checks a json file for correct formatting against some validation function 14 | * @param jsonFilePath path to the json file being validated 15 | * @param jsonValidationFunction function that validates parsed json data against some criteria 16 | * @return the parsed json file 17 | * @throws InvalidDirectoryException if json file doesn't exist, InvalidJsonException on failed parse or *first* invalid field in json 18 | */ 19 | export function validate(jsonFilePath: string, jsonMissingErrorMessage?: string): TaskJson { 20 | trace.debug("Validating task json..."); 21 | var jsonMissingErrorMsg: string = jsonMissingErrorMessage || "specified json file does not exist."; 22 | this.exists(jsonFilePath, jsonMissingErrorMsg); 23 | 24 | var taskJson; 25 | try { 26 | taskJson = require(jsonFilePath); 27 | } catch (jsonError) { 28 | trace.debug("Invalid task json: %s", jsonError); 29 | throw new Error("Invalid task json: " + jsonError); 30 | } 31 | 32 | var issues: string[] = this.validateTask(jsonFilePath, taskJson); 33 | if (issues.length > 0) { 34 | var output: string = "Invalid task json:"; 35 | for (var i = 0; i < issues.length; i++) { 36 | output += "\n\t" + issues[i]; 37 | } 38 | trace.debug(output); 39 | throw new Error(output); 40 | } 41 | 42 | trace.debug("Json is valid."); 43 | validateRunner(taskJson); 44 | return taskJson; 45 | } 46 | 47 | /* 48 | * Wrapper for fs.existsSync that includes a user-specified errorMessage in an InvalidDirectoryException 49 | */ 50 | export function exists(path: string, errorMessage: string) { 51 | if (!fs.existsSync(path)) { 52 | trace.debug(errorMessage); 53 | throw new Error(errorMessage); 54 | } 55 | } 56 | 57 | /* 58 | * Validates a task against deprecated runner 59 | * @param taskData the parsed json file 60 | */ 61 | export function validateRunner(taskData: any) { 62 | if (taskData == undefined || taskData.execution == undefined) 63 | return 64 | 65 | const validRunnerCount = Object.keys(taskData.execution).filter(itm => deprecatedRunners.indexOf(itm) == -1) || 0; 66 | if (validRunnerCount == 0) { 67 | trace.warn("Task %s is dependent on a task runner that is end-of-life and will be removed in the future. Please visit https://aka.ms/node-runner-guidance to learn how to upgrade the task.", taskData.name) 68 | } 69 | } 70 | 71 | /* 72 | * Validates a parsed json file describing a build task 73 | * @param taskPath the path to the original json file 74 | * @param taskData the parsed json file 75 | * @return list of issues with the json file 76 | */ 77 | export function validateTask(taskPath: string, taskData: any): string[] { 78 | var vn = taskData.name || taskPath; 79 | var issues: string[] = []; 80 | 81 | if (!taskData.id || !check.isUUID(taskData.id)) { 82 | issues.push(vn + ": id is a required guid"); 83 | } 84 | 85 | if (!taskData.name || !check.matches(taskData.name, /^[A-Za-z0-9\-]+$/)) { 86 | issues.push(vn + ": name is a required alphanumeric string"); 87 | } 88 | 89 | if (!taskData.friendlyName || !check.isLength(taskData.friendlyName, 1, 40)) { 90 | issues.push(vn + ": friendlyName is a required string <= 40 chars"); 91 | } 92 | 93 | if (!taskData.instanceNameFormat) { 94 | issues.push(vn + ": instanceNameFormat is required"); 95 | } 96 | 97 | if (taskData.execution) { 98 | const supportedRunners = ["Node", "Node10", "Node16", "Node20_1", "PowerShell", "PowerShell3", "Process"] 99 | 100 | for (var runner in taskData.execution) { 101 | if (supportedRunners.indexOf(runner) > -1) { 102 | var runnerData = taskData.execution[runner]; 103 | if (!runnerData.target) { 104 | issues.push(vn + ": execution." + runner + ".target is required"); 105 | } else { 106 | const target = runnerData.target.replace(/\$\(\s*currentdirectory\s*\)/i, "."); 107 | 108 | // target contains a variable 109 | if (target.match(/\$\([^)]+\)/)) { 110 | continue; 111 | } 112 | 113 | // check if the target file exists 114 | if (!fs.existsSync(path.join(path.dirname(taskPath), target))) { 115 | issues.push(vn + ": execution target for " + runner + " references file that does not exist: " + target); 116 | } 117 | } 118 | } 119 | } 120 | 121 | return (issues.length > 0) ? [taskPath, ...issues] : []; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/lib/loader.ts: -------------------------------------------------------------------------------- 1 | import colors = require("colors"); 2 | import common = require("./common"); 3 | import fsUtils = require("./fsUtils"); 4 | import path = require("path"); 5 | import trace = require("./trace"); 6 | import { TfCommand } from "./tfcommand"; 7 | import { promisify } from "util"; 8 | import { lstat } from "fs"; 9 | 10 | export interface CommandFactory { 11 | getCommand: (args: string[]) => TfCommand | Promise>; 12 | } 13 | 14 | /** 15 | * Load the module given by execPath and instantiate a TfCommand using args. 16 | * @param {string[]} execPath: path to the module to load. This module must implement CommandFactory. 17 | * @param {string[]} args: args to pass to the command factory to instantiate the TfCommand 18 | * @return {Promise} Promise that is resolved with the module's command 19 | */ 20 | export function load(execPath: string[], args): Promise> { 21 | trace.debug("loader.load"); 22 | let commandModulePath = path.resolve(common.APP_ROOT, "exec", execPath.join("/")); 23 | return fsUtils.exists(commandModulePath).then(exists => { 24 | let resolveDefaultPromise = Promise.resolve(commandModulePath); 25 | if (exists) { 26 | // If this extensionless path exists, it should be a directory. 27 | // If the path doesn't exist, for now we assume that a file with a .js extension 28 | // exists (if it doens't, we will find out below). 29 | resolveDefaultPromise = promisify(lstat)(commandModulePath).then(stats => { 30 | if (stats.isDirectory()) { 31 | return path.join(commandModulePath, "default"); 32 | } 33 | return commandModulePath; 34 | }); 35 | } 36 | return resolveDefaultPromise.then((commandModulePath: string) => { 37 | let commandModule: CommandFactory; 38 | return fsUtils.exists(path.resolve(commandModulePath + ".js")).then(exists => { 39 | if (!exists) { 40 | throw new Error( 41 | commandModulePath + " is not a recognized command. Run with --help to see available commands.", 42 | ); 43 | } 44 | try { 45 | commandModule = require(commandModulePath); 46 | } catch (e) { 47 | trace.error(commandModulePath + " could not be fully loaded as a tfx command."); 48 | throw e; 49 | } 50 | if (!commandModule.getCommand) { 51 | throw new Error( 52 | "Command modules must export a function, getCommand, that takes no arguments and returns an instance of TfCommand", 53 | ); 54 | } 55 | 56 | return commandModule.getCommand(args); 57 | }); 58 | }); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /app/lib/outputs.ts: -------------------------------------------------------------------------------- 1 | import path = require("path"); 2 | 3 | import fsUtils = require("./fsUtils"); 4 | import { readFile, writeFile } from "fs"; 5 | import { promisify } from "util"; 6 | import qread = require("./qread"); 7 | 8 | export interface Outputter { 9 | processOutput: (data: any) => void | Promise; 10 | } 11 | 12 | export interface JsonOutputterOptions { 13 | minify?: boolean; 14 | } 15 | 16 | export class JsonOutputter implements Outputter { 17 | private minify: boolean = false; 18 | constructor(options?: JsonOutputterOptions) { 19 | if (options) { 20 | this.minify = options.minify; 21 | } 22 | } 23 | 24 | public processOutput(data: any): void { 25 | try { 26 | JSON.stringify(data, null, this.minify ? 0 : 4); 27 | } catch (e) { 28 | throw new Error("Error processing JSON"); 29 | } 30 | } 31 | } 32 | 33 | export enum FileOverwriteOption { 34 | /** 35 | * Prompt the user before overwriting 36 | */ 37 | Prompt, 38 | 39 | /** 40 | * Throw an exception if the file exists 41 | */ 42 | Throw, 43 | 44 | /** 45 | * Overwrite the file without warning/prompting 46 | */ 47 | Overwrite, 48 | 49 | /** 50 | * Append output to the given file 51 | */ 52 | Append, 53 | } 54 | 55 | export interface FileOutputterOptions { 56 | overwrite?: FileOverwriteOption; 57 | } 58 | 59 | export class FileOutputter implements Outputter { 60 | private overwriteSetting: FileOverwriteOption; 61 | 62 | constructor(private outputPath: string, options?: FileOutputterOptions) { 63 | if (options.overwrite === undefined || options.overwrite === null) { 64 | this.overwriteSetting = FileOverwriteOption.Prompt; 65 | } else { 66 | this.overwriteSetting = options.overwrite; 67 | } 68 | } 69 | 70 | /** 71 | * Given a file path: 72 | * - Convert it to an absolute path 73 | * - If it exists, warn the user it will be overwritten 74 | * - If it does not exist and the original file path was relative, confirm the absolute path with user 75 | * - Otherwise continue. 76 | * User has the option during confirmations to change path, in which the above process happens again. 77 | * Once we have the file, check that we have write access. If not, ask for a new file name and re-do all of the above. 78 | */ 79 | private confirmPath(outPath: string, confirmRelative: boolean = false): Promise { 80 | let absPath = path.resolve(outPath); 81 | return fsUtils 82 | .exists(absPath) 83 | .then(exists => { 84 | if (!exists && (!confirmRelative || path.isAbsolute(outPath))) { 85 | return Promise.resolve(absPath); 86 | } 87 | if (exists && this.overwriteSetting === FileOverwriteOption.Throw) { 88 | throw new Error("Cannot overwrite existing file " + this.outputPath); 89 | } 90 | if ( 91 | (exists && this.overwriteSetting === FileOverwriteOption.Overwrite) || 92 | this.overwriteSetting === FileOverwriteOption.Append 93 | ) { 94 | return Promise.resolve(absPath); 95 | } 96 | let prompt = exists 97 | ? "Warning: " + absPath + " will be overwritten. Continue? (y/n or provide another file name.)" 98 | : "Write to " + absPath + "? (y/n or provide another file name.)"; 99 | return qread.read("overwrite", prompt).then((result: string) => { 100 | let lcResult = result.toLowerCase(); 101 | if (["y", "yes"].indexOf(lcResult) >= 0) { 102 | return Promise.resolve(result); 103 | } else if (["n", "no"].indexOf(lcResult) >= 0) { 104 | throw new Error("Operation canceled by user."); 105 | } else { 106 | return this.confirmPath(result, true); 107 | } 108 | }); 109 | }) 110 | .then(confirmedPath => { 111 | return fsUtils.fileAccess(confirmedPath, fsUtils.W_OK).then(access => { 112 | if (access) { 113 | return confirmedPath; 114 | } else { 115 | if (this.overwriteSetting === FileOverwriteOption.Throw) { 116 | throw new Error("Cannot write to file " + this.outputPath + " (access denied)."); 117 | } 118 | return qread 119 | .read("filename", "No write access to file " + confirmedPath + ". Provide a new file name.") 120 | .then((result: string) => { 121 | return this.confirmPath(result, true); 122 | }); 123 | } 124 | }); 125 | }); 126 | } 127 | 128 | /** 129 | * Write the given string of data to the file 130 | */ 131 | public processOutput(data: string): Promise { 132 | return this.confirmPath(this.outputPath).then(confirmed => { 133 | let dataPromise = Promise.resolve(data); 134 | if (this.overwriteSetting === FileOverwriteOption.Append) { 135 | dataPromise = promisify(readFile)(confirmed, "utf8") 136 | .then(result => { 137 | return result + data; 138 | }) 139 | .catch(e => { 140 | return data; 141 | }); 142 | } 143 | return dataPromise.then(data => { 144 | return promisify(writeFile)(confirmed, data); 145 | }); 146 | }); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /app/lib/promiseUtils.ts: -------------------------------------------------------------------------------- 1 | export function timeout(promise: PromiseLike, timeoutMs: number, message?: string) { 2 | return new Promise((resolve, reject) => { 3 | const timeoutHandle = setTimeout(() => { 4 | reject(message == null ? `Timed out after ${timeoutMs} ms.` : message); 5 | }, timeoutMs); 6 | 7 | // Maybe use finally when it's available. 8 | promise.then( 9 | result => { 10 | resolve(result); 11 | clearTimeout(timeoutHandle); 12 | }, 13 | reason => { 14 | reject(reason); 15 | clearTimeout(timeoutHandle); 16 | }, 17 | ); 18 | }); 19 | } 20 | 21 | // This method is bad and you should feel bad for using it. 22 | export interface Deferred { 23 | resolve: (val: T) => void; 24 | reject: (reason: any) => void; 25 | promise: Promise; 26 | } 27 | 28 | export function defer(): Deferred { 29 | let resolve: (val: T) => void; 30 | let reject: (val: any) => void; 31 | const promise = new Promise((resolver, rejecter) => { 32 | resolve = resolver; 33 | reject = rejecter; 34 | }); 35 | return { 36 | resolve, 37 | reject, 38 | promise, 39 | }; 40 | } 41 | 42 | export async function wait(timeoutMs: number): Promise { 43 | return new Promise(resolve => { 44 | setTimeout(resolve, timeoutMs); 45 | }); 46 | } 47 | 48 | // Return a promise that resolves at least delayMs from now. Rejection happens immediately. 49 | export async function delay(promise: Promise, delayMs: number): Promise { 50 | return (await Promise.all([promise, wait(delayMs)]))[0]; 51 | } 52 | 53 | export interface PromiseResult { 54 | state: "fulfilled" | "rejected"; 55 | value?: T; 56 | reason?: any; 57 | } 58 | 59 | export function allSettled(promises: PromiseLike[]): Promise[]> { 60 | const results = new Array(promises.length); 61 | return new Promise(resolve => { 62 | let count = 0; 63 | for (let i = 0; i < promises.length; ++i) { 64 | const promise = promises[i]; 65 | promise 66 | .then( 67 | result => { 68 | results[i] = { 69 | state: "fulfilled", 70 | value: result, 71 | }; 72 | }, 73 | reason => { 74 | results[i] = { 75 | state: "rejected", 76 | reason: reason, 77 | }; 78 | }, 79 | ) 80 | .then(() => { 81 | if (++count === promises.length) { 82 | resolve(results); 83 | } 84 | }); 85 | } 86 | }); 87 | } 88 | 89 | export function realPromise(promise: PromiseLike): Promise { 90 | return new Promise((resolve, reject) => { 91 | promise.then(resolve, reject); 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /app/lib/qread.ts: -------------------------------------------------------------------------------- 1 | import prompt = require("prompt"); 2 | 3 | prompt.delimiter = ""; 4 | prompt.message = "> "; 5 | 6 | var queue = []; 7 | 8 | // This is the read lib that uses Q instead of callbacks. 9 | export function read(name: string, message: string, silent: boolean = false, promptDefault?: string): Promise { 10 | let promise = new Promise((resolve, reject) => { 11 | let schema: prompt.PromptSchema = { 12 | properties: {}, 13 | }; 14 | schema.properties[name] = { 15 | hidden: silent, 16 | }; 17 | if (typeof promptDefault === "undefined") { 18 | schema.properties[name].required = true; 19 | schema.properties[name].description = message + ":"; 20 | } else { 21 | schema.properties[name].description = message + " (default = " + promptDefault + ")" + ":"; 22 | } 23 | Promise.all(queue.filter(x => x !== promise)).then(() => { 24 | prompt.start(); 25 | prompt.get(schema, (err, result) => { 26 | if (err) { 27 | reject(err); 28 | } else { 29 | if (!result || !result[name] || !result[name].trim || !result[name].trim()) { 30 | resolve(promptDefault); 31 | } else { 32 | resolve(result[name]); 33 | } 34 | } 35 | queue.shift(); 36 | }); 37 | }); 38 | }); 39 | queue.unshift(promise); 40 | return promise; 41 | } 42 | -------------------------------------------------------------------------------- /app/lib/trace.ts: -------------------------------------------------------------------------------- 1 | import colors = require("colors"); 2 | import os = require("os"); 3 | let debugTracingEnvVar = process.env["TFX_TRACE"]; 4 | 5 | export const enum TraceLevel { 6 | None = 0, 7 | Info = 1, 8 | Debug = 2, 9 | } 10 | 11 | export let traceLevel: TraceLevel = debugTracingEnvVar ? TraceLevel.Debug : TraceLevel.Info; 12 | export let debugLogStream = console.log; 13 | 14 | export type printable = string | number | boolean; 15 | 16 | export function println() { 17 | info(""); 18 | } 19 | 20 | export function error(msg: any, ...replacements: printable[]): void { 21 | log("error: ", msg, colors.bgRed, replacements, console.error); 22 | } 23 | 24 | export function success(msg: any, ...replacements: printable[]): void { 25 | log("", msg, colors.green, replacements); 26 | } 27 | 28 | export function info(msg: any, ...replacements: printable[]): void { 29 | if (traceLevel >= TraceLevel.Info) { 30 | log("", msg, colors.white, replacements); 31 | } 32 | } 33 | 34 | export function warn(msg: any, ...replacements: printable[]): void { 35 | log("warning: ", msg, colors.bgYellow.black, replacements); 36 | } 37 | 38 | export function debugArea(msg: any, area: string) { 39 | debugTracingEnvVar = process.env["TFX_TRACE_" + area.toUpperCase()]; 40 | if (debugTracingEnvVar) { 41 | log(colors.cyan(new Date().toISOString() + " : "), msg, colors.grey, [], debugLogStream); 42 | } 43 | debugTracingEnvVar = process.env["TFX_TRACE"]; 44 | } 45 | 46 | export function debug(msg: any, ...replacements: printable[]) { 47 | if (traceLevel >= TraceLevel.Debug) { 48 | log(colors.cyan(new Date().toISOString() + " : "), msg, colors.grey, replacements, debugLogStream); 49 | } 50 | } 51 | 52 | function log(prefix: string, msg: any, color: any, replacements: printable[], method = console.log): void { 53 | var t = typeof msg; 54 | if (t === "string") { 55 | write(prefix, msg, color, replacements, method); 56 | } else if (msg instanceof Array) { 57 | msg.forEach(function(line) { 58 | if (typeof line === "string") { 59 | write(prefix, line, color, replacements, method); 60 | } 61 | }); 62 | } else if (t === "object") { 63 | write(prefix, JSON.stringify(msg, null, 2), color, replacements, method); 64 | } 65 | } 66 | 67 | function write(prefix: string, msg: string, color: any, replacements: printable[], method = console.log) { 68 | let toLog = format(msg, ...replacements); 69 | toLog = toLog 70 | .split(/\n|\r\n/) 71 | .map(line => prefix + line) 72 | .join(os.EOL); 73 | method(color(toLog)); 74 | } 75 | 76 | export function format(str: string, ...replacements: printable[]): string { 77 | let lcRepl = str.replace(/%S/g, "%s"); 78 | let split = lcRepl.split("%s"); 79 | if (split.length - 1 !== replacements.length) { 80 | throw new Error( 81 | "The number of replacements (" + 82 | replacements.length + 83 | ") does not match the number of placeholders (" + 84 | (split.length - 1) + 85 | ")", 86 | ); 87 | } 88 | 89 | let resultArr = []; 90 | split.forEach((piece, index) => { 91 | resultArr.push(piece); 92 | if (index < split.length - 1) { 93 | resultArr.push(replacements[index]); 94 | } 95 | }); 96 | return resultArr.join(""); 97 | } 98 | -------------------------------------------------------------------------------- /app/lib/version.ts: -------------------------------------------------------------------------------- 1 | import common = require("./common"); 2 | import path = require("path"); 3 | import { DynamicVersion } from "./dynamicVersion"; 4 | 5 | export class SemanticVersion extends DynamicVersion { 6 | constructor(public major: number, public minor: number, public patch: number) { 7 | super(major, minor, patch); 8 | } 9 | 10 | /** 11 | * Parse a Semantic Version from a string. 12 | */ 13 | public static parse(version: string): SemanticVersion { 14 | try { 15 | const spl = version.split(".").map(v => parseInt(v)); 16 | if (spl.length === 3 && !spl.some(e => isNaN(e))) { 17 | return new SemanticVersion(spl[0], spl[1], spl[2]); 18 | } else { 19 | throw ""; 20 | } 21 | } catch (e) { 22 | throw new Error("Could not parse '" + version + "' as a Semantic Version."); 23 | } 24 | } 25 | } 26 | 27 | export function getTfxVersion(): Promise { 28 | let packageJson = require(path.join(common.APP_ROOT, "package.json")); 29 | return Promise.resolve(SemanticVersion.parse(packageJson.version)); 30 | } 31 | -------------------------------------------------------------------------------- /app/tfx-cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('./app'); -------------------------------------------------------------------------------- /docs/basicAuthEnabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/tfs-cli/833fcb22c650bc3b5499526ff1b813b14912e0ce/docs/basicAuthEnabled.png -------------------------------------------------------------------------------- /docs/builds.md: -------------------------------------------------------------------------------- 1 | # Build Tasks 2 | 3 | You can queue, show, and list builds using tfx. 4 | 5 | ## Queue 6 | 7 | Queues a build for a given project with a given definition. 8 | 9 | ### Options 10 | ```txt 11 | --project - Required. The name of the project to queue a build for. 12 | AND 13 | --definition-id - The id of the build definition to build against. 14 | OR 15 | --definition-name - The name of the build definition to build against. 16 | ``` 17 | 18 | ### Example 19 | ```bash 20 | ~$ tfx build queue --project MyProject --definition-name TestDefinition 21 | Copyright Microsoft Corporation 22 | 23 | Queued new build: 24 | id : 1 25 | definition name : TestDefinition 26 | requested by : Teddy Ward 27 | status : NotStarted 28 | queue time : Fri Aug 21 2015 15:07:49 GMT-0400 (Eastern Daylight Time) 29 | ``` 30 | 31 | ## Show 32 | 33 | Shows information for a given build. 34 | 35 | ### Options 36 | ```txt 37 | --project - Required. The name of the project to queue a build for. 38 | --id - Required. The id of the build to show. 39 | ``` 40 | 41 | ### Example 42 | ```bash 43 | $ tfx build show --project MyProject --id 1 44 | Copyright Microsoft Corporation 45 | 46 | 47 | id : 1 48 | definition name : TestDefinition 49 | requested by : Teddy Ward 50 | status : NotStarted 51 | queue time : Fri Aug 21 2015 15:07:49 GMT-0400 (Eastern Daylight Time) 52 | ``` 53 | 54 | ## List 55 | 56 | Queries for a list of builds. 57 | 58 | ### Options 59 | ```txt 60 | --project - Required. The name of the project to queue a build for. 61 | --defintion-id - The id of a build definition. 62 | --definition-name - The name of a build definition. 63 | --status - The status of the build (eg: NotStarted, Completed). 64 | --top - Show the first X builds that satisfy the other query criteria. 65 | ``` 66 | 67 | ### Example 68 | ```bash 69 | ~$ tfx build list 70 | 71 | Copyright Microsoft Corporation 72 | 73 | ... 74 | 75 | id : 1 76 | definition name : TestDefinition 77 | requested by : Teddy Ward 78 | status : NotStarted 79 | queue time : Fri Aug 21 2015 15:07:49 GMT-0400 (Eastern Daylight Time) 80 | 81 | ``` 82 | 83 | -------------------------------------------------------------------------------- /docs/buildtasks.md: -------------------------------------------------------------------------------- 1 | # Build Tasks 2 | 3 | You can create, list, upload and delete build tasks with tfx. 4 | 5 | ## Permissions 6 | You need to be in the top level Agent Pool Administrators group to manipulate tasks 7 | [See here](https://msdn.microsoft.com/Library/vs/alm/Build/agents/admin) 8 | 9 | Account admins can add users to that group 10 | 11 | ## Create 12 | 13 | Creates a templated task ready for you to start editing 14 | 15 | ### Example 16 | ```bash 17 | ~$ tfx build tasks create 18 | Copyright Microsoft Corporation 19 | 20 | Enter short name > sample 21 | Enter friendly name > Sample Task 22 | Enter description > Sample Task for Docs 23 | Enter author > Me 24 | 25 | created task @ /Users/bryanmac/sample 26 | id : 305898e0-3eba-11e5-af7a-1181c3c6c966 27 | name: sample 28 | 29 | A temporary task icon was created. Replace with a 32x32 png with transparencies 30 | 31 | ~$ ls ./sample 32 | icon.png sample.js sample.ps1 task.json 33 | ``` 34 | 35 | ## Upload 36 | 37 | You can upload a task by specifying the directory (fully qualified or relative) which has the files. 38 | 39 | As an example we can upload the Octopus Deploy custom task. 40 | 41 | ### Example 42 | ```bash 43 | ~$ git clone https://github.com/OctopusDeploy/OctoTFS 44 | Cloning into 'OctoTFS'... 45 | Checking connectivity... done. 46 | 47 | ~$ cd OctoTFS/source/CustomBuildSteps 48 | ``` 49 | 50 | It's task is in the 51 | 52 | ```bash 53 | ~$ tfx build tasks upload --task-path ./CreateOctopusRelease 54 | ``` 55 | 56 | Build tasks are cached by version on the agent. The implementation by that version is considered to be immutable. If you are changing the implementation and uploading, bump at least the patch version. 57 | 58 | ## List 59 | 60 | To list the tasks that are on the server ... 61 | 62 | ### Example 63 | ```bash 64 | ~$ tfx build tasks list 65 | 66 | ... 67 | 68 | id : 4e131b60-5532-4362-95b6-7c67d9841b4f 69 | name : OctopusCreateRelease 70 | friendly name : Create Octopus Release 71 | visibility: Build,Release 72 | description: Create a Release in Octopus Deploy 73 | version: 0.1.2 74 | 75 | ``` 76 | 77 | ## Delete 78 | 79 | You can delete a task by supplying the id. Use list above to get the id 80 | As an example, this would delete the Octopus task we uploaded above 81 | 82 | Of course, be cautious deleting tasks. 83 | 84 | ### Example 85 | ```bash 86 | ~/$ tfx build tasks delete --task-id 4e131b60-5532-4362-95b6-7c67d9841b4f 87 | Copyright Microsoft Corporation 88 | 89 | task: 4e131b60-5532-4362-95b6-7c67d9841b4f deleted successfully! 90 | ``` 91 | 92 | -------------------------------------------------------------------------------- /docs/configureBasicAuth.md: -------------------------------------------------------------------------------- 1 | # Using `tfx` against Team Foundation Server (TFS) 2015 using Basic Authentication 2 | In order to use `tfx` against TFS on-premises, you will need to enable basic authentication in the tfs virtual application in IIS. _This is a temporary solution until NTLM authentication is supported._ 3 | 4 | > **WARNING!!** Basic authentication sends usernames and passwords in plaintext. You should consider [configuring TFS to use SSL](https://msdn.microsoft.com/en-us/library/aa833872.aspx) in order to enable secure communication when using basic auth. 5 | 6 | ## Configuring TFS to use Basic Authentication 7 | Follow these steps to enable basic auth for your TFS: 8 | 9 | 1. Install the `Basic Authentication` feature for IIS in Server Manager. ![Basic Auth feature in Server Manager](configureBasicAuthFeature.png) 10 | 2. Open IIS Manager and expand to the `Team Foundation Server` website, and then click on the `tfs` virtual application. Double-click the `Authentication` tile in the Features view ![Auth tile in IIS Manager](tfsAuth.png) 11 | 3. Click on `Basic Authentication` in the list of authentication methods. Click `Enable` in the right hand column. You should now see `Basic Authentication` enabled. ![Basic auth enabled](basicAuthEnabled.png) 12 | 4. **Note**: leave the `domain` and `realm` settings for `Basic Authentication` empty. 13 | 14 | ## `tfx login` with Basic Authentication 15 | Now you can start to use `tfx` against your TFS server. You'll want to `login` before issuing commands. 16 | 17 | 1. Type `tfx login --auth-type basic` 18 | 2. You will be prompted to add your service Url. 19 | * **Note for TFS on-prem**: If the server name is part of the service URL, be sure to specify the fully-qualitifed domain name (FQDN) of that server (i.e. in the form _servername.domain.local_). Otherwise, TFX will fail to connect. 20 | 3. You will be prompted for your username. Use `domain\user` (e.g. fabrikam\peter). If you are on a workgroup machine, use `machinename\username`. 21 | 4. You will be prompted for your password. Enter the password for the username you entered. 22 | 23 | You can now use any other `tfx` commands. 24 | ``` 25 | > tfx login --auth-type basic 26 | Copyright Microsoft Corporation 27 | 28 | Enter service url > http://localhost:8080/tfs/defaultcollection 29 | Enter username > fabfiber\peter 30 | Enter password > ******* 31 | logged in successfully 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/configureBasicAuthFeature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/tfs-cli/833fcb22c650bc3b5499526ff1b813b14912e0ce/docs/configureBasicAuthFeature.png -------------------------------------------------------------------------------- /docs/contributions.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | We accept contributions. Supply a pull request. 4 | 5 | ## Pre-reqs 6 | 7 | Typescript (>=1.6): 8 | `sudo npm install tsc -g` 9 | 10 | Install Dev Dependencies from root of repo: 11 | `npm install` 12 | 13 | ## Build 14 | 15 | We build the product using npm. Just type `npm run build` in the root of the repo. 16 | 17 | This builds the product in the _build/app directory 18 | 19 | ```bash 20 | C:\tfs-cli>npm run build 21 | ``` 22 | 23 | ## Install for Verification 24 | 25 | To install the product globally without pushing the npm (you cannot, we do that), run npm install by specifying the directory instead of the name 26 | 27 | After building, from the _build directory ... 28 | 29 | ```bash 30 | ~/Projects/tfs-cli/_build$ sudo npm install ./app -g 31 | Password: 32 | /usr/local/bin/tfx -> /usr/local/lib/node_modules/tfx-cli/tfx-cli.js 33 | tfx-cli@0.1.8 /usr/local/lib/node_modules/tfx-cli 34 | ├── os-homedir@1.0.1 35 | ├── async@1.4.2 36 | ├── colors@1.1.2 37 | ├── minimist@1.1.3 38 | ├── node-uuid@1.4.3 39 | ├── q@1.4.1 40 | ├── validator@3.43.0 41 | ├── shelljs@0.5.1 42 | ├── vso-node-api@0.3.0 43 | ├── read@1.0.6 (mute-stream@0.0.5) 44 | └── archiver@0.14.4 (buffer-crc32@0.2.5, lazystream@0.1.0, async@0.9.2, readable-stream@1.0.33, tar-stream@1.1.5, lodash@3.2.0, zip-stream@0.5.2, glob@4.3.5) 45 | ``` 46 | The product with your local changes is now installed globally. You can now validate your changes as customers would run after installing from npm globally 47 | 48 | ## Tracing 49 | 50 | Ensure your changes include ample tracing. After installing your changes globally (above), set TFX_TRACE=1 (export on *nix) and run the tool with your changes. Trace output should validate it's doing what you intended, for the reason you intended and provide tracing for others down the road. 51 | 52 | ## Unit Test 53 | 54 | If you're changing core parts of the CL engine, ensure unit tests are run and optionally cover the changes you are making. 55 | 56 | TODO: unit testing details (we use mocha) - pending changes 57 | 58 | 59 | -------------------------------------------------------------------------------- /docs/extensions.md: -------------------------------------------------------------------------------- 1 | # TFX extension commands 2 | 3 | Package, publish, and manage Team Services and Team Foundation Server extensions. To learn more, see an [introduction to extensions](https://docs.microsoft.com/azure/devops/extend/overview?view=vsts). 4 | 5 | ## Get started 6 | 7 | To learn more about TFX, its pre-reqs and how to install, see the [readme](../README.md) 8 | 9 | ## Package an extension 10 | 11 | ### Usage 12 | 13 | `tfx extension create` 14 | 15 | ### Arguments 16 | 17 | * `--root`: Root directory. 18 | * `--manifest-js`: Manifest in the form of a standard Node.js CommonJS module with an exported function. If present then the manifests and manifest-globs arguments are ignored. 19 | * `--env`: Environment variables passed to the manifestJs module. 20 | * `--manifests`: List of individual manifest files (space separated). 21 | * `--manifest-globs`: List of globs to find manifests (space separated). 22 | * `--override`: JSON string which is merged into the manifests, overriding any values. 23 | * `--overrides-file`: Path to a JSON file with overrides. This partial manifest will always take precedence over any values in the manifests. 24 | * `--rev-version`: Rev the latest version number of the extension and save the result. 25 | * `--bypass-validation`: Bypass local validation. 26 | * `--publisher`: Use this as the publisher ID instead of what is specified in the manifest. 27 | * `--extension-id`: Use this as the extension ID instead of what is specified in the manifest. 28 | * `--output-path`: Path to write the VSIX. 29 | * `--loc-root`: Root of localization hierarchy (see README for more info). 30 | 31 | ### Examples 32 | 33 | #### Package for a different publisher 34 | 35 | ``` 36 | tfx extension create --publisher mypublisher --manifest-js myextension.config.js --env mode=production 37 | ``` 38 | 39 | #### Increment (rev) the patch segment of the extension version 40 | 41 | For example, assume the extension's version is currently `0.4.0` 42 | 43 | ``` 44 | tfx extension create --rev-version 45 | ``` 46 | 47 | The version included in the packaged .VSIX and in the source manifest file is now `0.4.1`. 48 | 49 | #### Manifest JS file 50 | 51 | Eventually you will find the need to disambiguate in your manifest contents between development and production builds. Use the `--manifest-js` option to supply a Node.JS CommonJS module and export a function. The function will be invoked with an environment property bag as a parameter, and must return the manifest JSON object. 52 | 53 | Environment variables for the property bag are specified with the `--env` command line parameter. These are space separated key-value pairs, e.g. `--env mode=production rootpath="c:\program files" size=large`. 54 | 55 | An example manifest JS file might look like the following: 56 | 57 | ``` 58 | module.exports = (env) => { 59 | let [idPostfix, namePostfix] = (env.mode == "development") ? ["-dev", " [DEV]"] : ["", ""]; 60 | 61 | let manifest = { 62 | manifestVersion: 1, 63 | id: `myextensionidentifier${idPostfix}`, 64 | name: `My Great Extension${namePostfix}`, 65 | ... 66 | contributions: [ 67 | { 68 | id: "mywidgetidentifier", 69 | properties: { 70 | name: `Super Widget${namePostfix}`, 71 | ... 72 | }, 73 | ... 74 | } 75 | ] 76 | } 77 | 78 | if (env.mode == 'development') { 79 | manifest.baseUri = "https://localhost:3000"; 80 | } 81 | 82 | return manifest; 83 | } 84 | ``` 85 | 86 | 87 | ## Publish an extension 88 | 89 | ### Usage 90 | 91 | ``` 92 | tfx extension publish 93 | ``` 94 | 95 | ### Arguments 96 | 97 | In addition to all of the `extension create` options, the following options are available for `extension publish`: 98 | 99 | * `--vsix`: Path to an existing VSIX (to publish or query for). 100 | * `--share-with`: List of accounts (VSTS) with which to share the extension. 101 | * `--nowait-validation`: Use this paramater if you or the pipeline don’t want to wait for the CLI tool to complete. The extension is published and available in the Marketplace only after completion successful validation. 102 | 103 | ### Example 104 | 105 | ``` 106 | tfx extension publish --publisher mypublisher --manifest-js myextension.config.js --env mode=development --share-with myaccount 107 | ``` 108 | 109 | ### Tips 110 | 111 | 1. By default, `publish` first packages the extension using the same mechanism as `tfx extension create`. All options available for `create` are available for `publish`. 112 | 2. If an Extension with the same ID already exists publisher, the command will attempt to update the extension. 113 | 3. When you run the `publish` command, you will be prompted for a Personal Access Token to authenticate to the Marketplace. For more information about obtaining a Personal Access Token, see [Publish from the command line](https://docs.microsoft.com/azure/devops/extend/publish/command-line?view=vsts). 114 | 115 | 116 | 117 | ## Other commands 118 | 119 | * `tfx extension install`: Install a Visual Studio Services Extension to a list of VSTS Accounts. 120 | * `tfx extension show`: Show information about a published extension. 121 | * `tfx extension share`: Share an extension with an account. 122 | * `tfx extension unshare`: Unshare an extension with an account. 123 | * `tfx extension isvalid`: Checks if an extension is valid. 124 | 125 | For more details on a specific command, run: 126 | 127 | ```bash 128 | tfx extension {command} --help 129 | ``` 130 | 131 | -------------------------------------------------------------------------------- /docs/help-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/tfs-cli/833fcb22c650bc3b5499526ff1b813b14912e0ce/docs/help-screen.png -------------------------------------------------------------------------------- /docs/tfsAuth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/tfs-cli/833fcb22c650bc3b5499526ff1b813b14912e0ce/docs/tfsAuth.png -------------------------------------------------------------------------------- /docs/workitems.md: -------------------------------------------------------------------------------- 1 | # TFX Work Item commands 2 | 3 | Create, query and view [Work items](https://www.visualstudio.com/en-us/docs/work/backlogs/add-work-items). 4 | 5 | ## Get started 6 | 7 | To learn more about TFX, its pre-reqs and how to install, see the [readme](../README.md) 8 | 9 | __Note:__ For work item commands to function when connecting to Team Foundation Server you must login using the collection URL. On VSTS your normal account url will work fine. 10 | 11 | Team Foundation Server 12 | > tfx login --service-url http://myServer/DefaultCollection 13 | 14 | 15 | Visual Studio Team Services 16 | > tfx login --service-url https://myAccount.VisualStudio.com 17 | 18 | 19 | ## Show work item details 20 | 21 | ### Usage 22 | 23 | `tfx workitem show` 24 | 25 | ### Arguments 26 | 27 | * `--work-item-id`: Work item id. 28 | 29 | ### Examples 30 | 31 | #### Show a work item with id 2 32 | 33 | ``` 34 | tfx workitem show --work-item-id 2 35 | ``` 36 | 37 | ## Query for work items 38 | 39 | ### Usage 40 | 41 | ``` 42 | tfx workitem query 43 | ``` 44 | 45 | ### Arguments 46 | 47 | * `--query`: WIQL query text. 48 | * `--project`: Optional name of the project. This lets you use @Project and @CurrentIteration macros in the query. 49 | 50 | ### Example 51 | 52 | ``` 53 | tfx workitem query --project MyProject --query "select [system.id], [system.title] from WorkItems" 54 | ``` 55 | 56 | ## Create a work item 57 | 58 | ### Usage 59 | 60 | ``` 61 | tfx workitem create 62 | ``` 63 | 64 | ### Arguments 65 | 66 | * `--project`: Name of the project. 67 | * `--work-item-type`: Work Item type (e.g. Bug). 68 | * `--title`: Title value 69 | * `--assignedTo`: Assigned To value 70 | * `--description`: Description value 71 | * `--values`: JSON object of name/value pairs to set on the Work Item 72 | 73 | 74 | ### Examples 75 | 76 | #### Create a work item with a given title 77 | ``` 78 | tfx workitem create --work-item-type Bug --project MyProject --title MyTitle 79 | ``` 80 | 81 | #### Create a work item with values specified from JSON (PowerShell) 82 | ``` 83 | tfx workitem create --work-item-type Bug --project MyProject --values --% {\"system.title\":\"MyTitle\", "system.description\":\"MyDescription\"} 84 | ``` 85 | 86 | #### Create a work item with values specified from JSON (CMD) 87 | ``` 88 | tfx workitem create --work-item-type Bug --project MyProject --values {\"system.title\":\"MyTitle\", "system.description\":\"MyDescription\"} 89 | ``` 90 | 91 | 92 | ## Update a work item 93 | 94 | ### Usage 95 | 96 | ``` 97 | tfx workitem update 98 | ``` 99 | 100 | ### Arguments 101 | 102 | * `--work-item-id`: Name ID of the work item to update. 103 | * `--title`: Title value 104 | * `--assignedTo`: Assigned To value 105 | * `--description`: Description value 106 | * `--values`: JSON object of name/value pairs to set on the Work Item 107 | 108 | 109 | ### Examples 110 | 111 | #### Update a work item with a given title 112 | ``` 113 | tfx workitem update --work-item-id 11 --title MyTitle 114 | ``` 115 | 116 | #### Update a work item with values specified from JSON (PowerShell) 117 | ``` 118 | tfx workitem update --work-item-id 11 --values --% {\"system.title\":\"MyTitle\", "system.description\":\"MyDescription\"} 119 | ``` 120 | 121 | #### Update a work item with values specified from JSON (CMD) 122 | ``` 123 | tfx workitem update --work-item-id 11 --values {\"system.title\":\"MyTitle\", "system.description\":\"MyDescription\"} 124 | ``` 125 | -------------------------------------------------------------------------------- /gulp.cmd: -------------------------------------------------------------------------------- 1 | echo Gulp is no longer used to build this repo. Please type 'npm run build' to build TFX. See the README for more details. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tfx-cli", 3 | "version": "0.21.1", 4 | "description": "CLI for Azure DevOps Services and Team Foundation Server", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Microsoft/tfs-cli" 8 | }, 9 | "main": "./_build/tfx-cli.js", 10 | "preferGlobal": true, 11 | "bin": { 12 | "tfx": "./_build/tfx-cli.js" 13 | }, 14 | "scripts": { 15 | "clean": "rimraf _build", 16 | "build": "tsc -p .", 17 | "postbuild": "ncp app/tfx-cli.js _build/tfx-cli.js && ncp package.json _build/package.json && ncp app/exec/build/tasks/_resources _build/exec/build/tasks/_resources", 18 | "prepublishOnly": "npm run build" 19 | }, 20 | "dependencies": { 21 | "app-root-path": "1.0.0", 22 | "archiver": "2.0.3", 23 | "azure-devops-node-api": "^14.0.0", 24 | "clipboardy": "~1.2.3", 25 | "colors": "~1.3.0", 26 | "glob": "7.1.2", 27 | "jju": "^1.4.0", 28 | "json-in-place": "^1.0.1", 29 | "jszip": "^3.10.1", 30 | "lodash": "^4.17.21", 31 | "minimist": "^1.2.6", 32 | "mkdirp": "^1.0.4", 33 | "onecolor": "^2.5.0", 34 | "os-homedir": "^1.0.1", 35 | "prompt": "^1.3.0", 36 | "read": "^1.0.6", 37 | "shelljs": "^0.8.5", 38 | "tmp": "0.0.26", 39 | "tracer": "0.7.4", 40 | "util.promisify": "^1.0.0", 41 | "uuid": "^3.0.1", 42 | "validator": "^13.7.0", 43 | "winreg": "0.0.12", 44 | "xml2js": "^0.5.0" 45 | }, 46 | "devDependencies": { 47 | "@types/clipboardy": "~1.1.0", 48 | "@types/glob": "^5.0.29", 49 | "@types/jju": "^1.4.1", 50 | "@types/jszip": "~3.1.2", 51 | "@types/lodash": "~4.14.110", 52 | "@types/mkdirp": "^1.0.2", 53 | "@types/node": "8.10.66", 54 | "@types/shelljs": "^0.8.11", 55 | "@types/uuid": "^2.0.29", 56 | "@types/validator": "^4.5.27", 57 | "@types/winreg": "^1.2.29", 58 | "@types/xml2js": "0.0.27", 59 | "ncp": "^2.0.0", 60 | "rimraf": "^2.6.1", 61 | "typescript": "^3.2.2" 62 | }, 63 | "engines": { 64 | "node": ">=8.0.0" 65 | }, 66 | "author": "Microsoft Corporation", 67 | "contributors": [ 68 | { 69 | "email": "trgau@microsoft.com", 70 | "name": "Trevor Gau" 71 | } 72 | ], 73 | "license": "MIT" 74 | } 75 | -------------------------------------------------------------------------------- /tests/commandline.ts: -------------------------------------------------------------------------------- 1 | import Q = require('q'); 2 | import assert = require('assert'); 3 | 4 | describe('tfx-cli', function() { 5 | 6 | before((done) => { 7 | // init here 8 | done(); 9 | }); 10 | 11 | after(function() { 12 | 13 | }); 14 | 15 | describe('Command Line Parsing', function() { 16 | it('It succeeds', (done) => { 17 | this.timeout(500); 18 | 19 | assert(true, 'true is not true?'); 20 | done(); 21 | }) 22 | it('It succeeds again', (done) => { 23 | this.timeout(500); 24 | 25 | assert(true, 'true is not true?'); 26 | done(); 27 | }) 28 | }); 29 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "moduleResolution": "node", 6 | "declaration": false, 7 | "sourceMap": true, 8 | "newLine": "LF", 9 | "noFallthroughCasesInSwitch": true, 10 | 11 | // Note: dom is specified below so that the JSZip d.ts can use the Blob type. 12 | "lib": ["es5", "es2015", "es6", "dom"], 13 | "outDir": "_build" 14 | }, 15 | "include": [ 16 | "app/app.ts", 17 | "app/exec/**/*.ts", 18 | "typings/**/*.d.ts" 19 | /*, 20 | "node_modules/@types/q/index.d.ts" // We augment the definitions with a file under typings, so we need an explicit reference here.*/ 21 | ] 22 | } -------------------------------------------------------------------------------- /tsd.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v4", 3 | "repo": "borisyankov/DefinitelyTyped", 4 | "ref": "master", 5 | "path": "typings", 6 | "bundle": "typings/tsd.d.ts", 7 | "installed": { 8 | "node/node.d.ts": { 9 | "commit": "f0aa5507070dc74859b636bd2dac37f3e8cab8d1" 10 | }, 11 | "q/Q.d.ts": { 12 | "commit": "066819c65ff561ca109955e0b725c253e243f0a2" 13 | }, 14 | "colors/colors.d.ts": { 15 | "commit": "b83eea746f8583a6c9956c19d8b553f2b66ce577" 16 | }, 17 | "glob/glob.d.ts": { 18 | "commit": "066819c65ff561ca109955e0b725c253e243f0a2" 19 | }, 20 | "xml2js/xml2js.d.ts": { 21 | "commit": "066819c65ff561ca109955e0b725c253e243f0a2" 22 | }, 23 | "mocha/mocha.d.ts": { 24 | "commit": "9cf502e3c1a8532f92ee0114e46509175e790938" 25 | }, 26 | "minimatch/minimatch.d.ts": { 27 | "commit": "066819c65ff561ca109955e0b725c253e243f0a2" 28 | }, 29 | "lodash/lodash.d.ts": { 30 | "commit": "066819c65ff561ca109955e0b725c253e243f0a2" 31 | }, 32 | "tmp/tmp.d.ts": { 33 | "commit": "066819c65ff561ca109955e0b725c253e243f0a2" 34 | }, 35 | "fs-extra/fs-extra.d.ts": { 36 | "commit": "230b346dad577c91af8db9f0a1db26697d2dd5bd" 37 | }, 38 | "jszip/jszip.d.ts": { 39 | "commit": "e18e130e2188348070f98d92b98c0ea481c4a086" 40 | }, 41 | "request/request.d.ts": { 42 | "commit": "43b6bf88758852b9ab713a9b011487f047f94f4e" 43 | }, 44 | "form-data/form-data.d.ts": { 45 | "commit": "43b6bf88758852b9ab713a9b011487f047f94f4e" 46 | }, 47 | "vso-node-api/vso-node-api.d.ts": { 48 | "commit": "b9c534a52a50547c61c5f80519aaed285eb3e8b8" 49 | }, 50 | "mkdirp/mkdirp.d.ts": { 51 | "commit": "76ed26e95e91f1c91f2d11819c44b55b617e859a" 52 | }, 53 | "minimist/minimist.d.ts": { 54 | "commit": "eb59a40d3c2f3257e34ec2ede181046230814a41" 55 | }, 56 | "shelljs/shelljs.d.ts": { 57 | "commit": "eb59a40d3c2f3257e34ec2ede181046230814a41" 58 | }, 59 | "node-uuid/node-uuid.d.ts": { 60 | "commit": "eb59a40d3c2f3257e34ec2ede181046230814a41" 61 | }, 62 | "validator/validator.d.ts": { 63 | "commit": "eb59a40d3c2f3257e34ec2ede181046230814a41" 64 | }, 65 | "archiver/archiver.d.ts": { 66 | "commit": "eb59a40d3c2f3257e34ec2ede181046230814a41" 67 | }, 68 | "read/read.d.ts": { 69 | "commit": "0dd29bf8253536ae24e61c109524e924ea510046" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /typings/archiver/archiver.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for archiver v0.15.0 2 | // Project: https://github.com/archiverjs/node-archiver 3 | // Definitions by: Esri 4 | // Definitions: https://github.com/borisyankov/DefinitelyTyped 5 | 6 | /* =================== USAGE =================== 7 | 8 | import Archiver = require('archiver); 9 | var archiver = Archiver.create('zip'); 10 | archiver.pipe(FS.createWriteStream('xxx')); 11 | archiver.append(FS.createReadStream('xxx')); 12 | archiver.finalize(); 13 | 14 | =============================================== */ 15 | 16 | declare module "archiver" { 17 | import * as stream from "stream"; 18 | import * as glob from "glob"; 19 | import { ZlibOptions } from "zlib"; 20 | 21 | interface nameInterface { 22 | name?: string; 23 | } 24 | 25 | interface EntryData { 26 | name?: string; 27 | prefix?: string; 28 | stats?: string; 29 | } 30 | 31 | /** A function that lets you either opt out of including an entry (by returning false), or modify the contents of an entry as it is added (by returning an EntryData) */ 32 | type EntryDataFunction = (entry: EntryData) => false | EntryData; 33 | 34 | interface Archiver extends stream.Transform { 35 | abort(): this; 36 | append(source: stream.Readable | Buffer | string, name?: EntryData): this; 37 | 38 | /** if false is passed for destpath, the path of a chunk of data in the archive is set to the root */ 39 | directory(dirpath: string, destpath: false | string, data?: EntryData | EntryDataFunction): this; 40 | file(filename: string, data: EntryData): this; 41 | glob(pattern: string, options?: glob.IOptions, data?: EntryData): this; 42 | finalize(): Promise; 43 | 44 | setFormat(format: string): this; 45 | setModule(module: Function): this; 46 | 47 | pointer(): number; 48 | use(plugin: Function): this; 49 | 50 | symlink(filepath: string, target: string): this; 51 | } 52 | 53 | interface Options {} 54 | 55 | function archiver(format: string, options?: Options): Archiver; 56 | 57 | namespace archiver { 58 | function create(format: string, options?: Options): Archiver; 59 | } 60 | 61 | export = archiver; 62 | } 63 | -------------------------------------------------------------------------------- /typings/json-in-place/json-in-place.d.ts: -------------------------------------------------------------------------------- 1 | declare class InPlace { 2 | public set: (path: string, value: any) => InPlace; 3 | public toString: () => string; 4 | } 5 | 6 | declare const JSONInPlace: (json: string) => InPlace; 7 | 8 | declare module "json-in-place" { 9 | export = JSONInPlace; 10 | } -------------------------------------------------------------------------------- /typings/onecolor/onecolor.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for onecolor (incomplete) 2 | 3 | declare module "onecolor" { 4 | 5 | var onecolor: any; 6 | 7 | export = onecolor; 8 | } 9 | -------------------------------------------------------------------------------- /typings/prompt/prompt.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for Prompt.js 0.2.14 2 | // Project: https://github.com/flatiron/prompt 3 | 4 | declare module "prompt" { 5 | 6 | type properties = string[] | p.PromptSchema | p.PromptPropertyOptions[]; 7 | 8 | module p { 9 | interface PromptSchema { 10 | properties: PromptProperties; 11 | } 12 | 13 | interface PromptProperties { 14 | [propName: string]: PromptPropertyOptions; 15 | } 16 | 17 | interface PromptPropertyOptions { 18 | pattern?: RegExp; 19 | message?: string; 20 | required?: boolean; 21 | hidden?: boolean; 22 | description?: string; 23 | type?: string; 24 | default?: string; 25 | before?: (value: any) => any; 26 | conform?: (result: any) => boolean; 27 | name?: string; 28 | } 29 | 30 | export function start(): void; 31 | 32 | export function get( 33 | properties: properties, 34 | callback: (err: Error, result: string) => void): void; 35 | 36 | export function get( 37 | properties: properties, 38 | callback: (err: Error, result: T) => void): void; 39 | 40 | export function addProperties( 41 | obj: any, properties: properties, 42 | callback: (err: Error) => void): void; 43 | 44 | export function history(propertyName: string): any; 45 | 46 | export var override: any; 47 | export var colors: boolean; 48 | export var message: string; 49 | export var delimiter: string; 50 | } 51 | export = p; 52 | } --------------------------------------------------------------------------------