├── .gitignore ├── README.md ├── build.sh ├── clear.sh ├── dockerfile ├── inspect.sh ├── lib ├── streamer.js ├── vmw.api.js ├── vmw.cli.js ├── xcell.js └── xtable.js ├── license ├── package.json ├── start.sh ├── vmw-cli └── vmw.complete /.gitignore: -------------------------------------------------------------------------------- 1 | **/.localStorage 2 | **/state 3 | **/cfg 4 | **/node_modules 5 | **/package-lock.json 6 | **/params.json 7 | **/*.iso 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `vmw-cli` 2 | **2.0 marks the first in a series of releases of the new `vmw-cli` tool and supercedes all previous versions.** 3 | **It has been built from the ground up to be aligned with the new `my.vmware.com` website.** 4 | **New features will become available in on-going releases.** 5 | 6 | `vmw-cli` is a CLI client used to login and interact with my.vmware.com. 7 | It provides an interface for programmatic query and download of VMware product binaries. 8 | 9 | Every product. 10 | Every version. 11 | Every file. 12 | 13 | `vmw-cli` uses the **my.vmware.com** Node.js SDK here: [`vmw-sdk`](https://github.com/apnex/vmw-sdk) 14 | 15 | ## Install 16 | #### Configure authentication for my.vmware.com 17 | ``` 18 | export VMWUSER='' 19 | export VMWPASS='' 20 | ``` 21 | **Note:** Any download attempts will be restricted to the entitlements afforded by your account. 22 | Alternatively, if using `docker` commands, you can pass credentials directly to the container instead. 23 | 24 | `vmw-cli` can be consumed using the Shell + Docker pre-built image (preferred), or installing the package via NPM. 25 | By default, requested files via the `cp` command will be downloaded to current working directory. 26 | 27 | ### via Docker: Shell Integration 28 | Builds a shell command that links to the docker container. 29 | Requires docker installed on your system. 30 | 31 | ``` 32 | docker run apnex/vmw-cli shell > vmw-cli 33 | chmod 755 vmw-cli 34 | mv vmw-cli /usr/bin/ 35 | ``` 36 | 37 | Once shell integration installed, `vmw-cli` can be leveraged directly via the `vmw-cli` shell command - see **Usage** below 38 | 39 | ### via NPM 40 | **vmw-cli requires NodeJS >= 12.x, some older Linux distros need to have NodeJS [manually updated](https://nodejs.org/en/download/package-manager/#debian-and-ubuntu-based-linux-distributions)** 41 | ``` 42 | npm install vmw-cli --global 43 | ``` 44 | Once installed, `vmw-cli` can be leveraged directly via the `vmw-cli` shell command - see **Usage** below 45 | 46 | 47 | ### via Docker [Manual] 48 | This is where we manually start the container using `docker run` with the required ENV parameters set. 49 | This is not required if you have performed Shell Integration. 50 | Subsequent commands are then issued using `docker exec` commands. 51 | 52 | Start the container in background: 53 | ``` 54 | docker run -itd --name vmw -e VMWUSER='' -e VMWPASS='' -v ${PWD}:/files --entrypoint=sh apnex/vmw-cli 55 | ``` 56 | **Where:** 57 | - `` is your **my.vmware.com** username 58 | - `` is your **my.vmware.com** password 59 | - `${PWD}` ENV will resolve to current working directory in BASH for file downloads 60 | 61 | Then issue one or more `docker exec` commands: 62 | ``` 63 | docker exec -t vmw vmw-cli 64 | ``` 65 | 66 | Clean up docker container when done: 67 | ``` 68 | docker rm -f vmw 69 | ``` 70 | 71 | See **Usage** for more examples 72 | 73 | ## Overview 74 | `vmw-cli` has been modelled to make resources on `my.vmware.com` resemble a file system. 75 | This allows you to **browse** available downloads via the `ls` command, and select a file to copy to your local system. 76 | 77 | ### Directory Geometry 78 | All files are grouped into nested directory structures in the form `//` 79 | **Where:** 80 | - `` is one of the high-level solution groups listed on **my.vmware.com** 81 | - `` is a solution version available within a specific `` 82 | - `` is one of the following [`PRODUCT_BINARY`, `DRIVERS_TOOLS`, `OPEN_SOURCE`, `CUSTOM_ISO`, `ADDONS`] 83 | 84 | For example; 85 | 86 | Current `vmware_nsx_t_data_center` file structure: 87 |
 88 | vmware_nsx_t_data_center
 89 |   ┣━ 3_x
 90 |   ┃   ┣━ PRODUCT_BINARY
 91 |   ┃   ┣━ DRIVERS_TOOLS
 92 |   ┃   ┣━ OPEN_SOURCE
 93 |   ┃   ┣━ CUSTOM_ISO
 94 |   ┃   ┗━ ADDONS
 95 |   ┣━ 2_x
 96 |   ┃   ┣━ PRODUCT_BINARY
 97 |   ┃   ┣━ DRIVERS_TOOLS
 98 |   ┃   ┣━ OPEN_SOURCE
 99 |   ┃   ┣━ CUSTOM_ISO
100 |   ┃   ┗━ ADDONS
101 |   ┗━ 1_x
102 |       ┣━ PRODUCT_BINARY
103 |       ┣━ DRIVERS_TOOLS
104 |       ┣━ OPEN_SOURCE
105 |       ┣━ CUSTOM_ISO
106 |       ┗━ ADDONS
107 | 
108 | 109 | Current `vmware_vsphere` file structure: 110 |
111 | vmware_vsphere
112 |   ┣━ 7_0
113 |   ┃   ┣━ PRODUCT_BINARY
114 |   ┃   ┣━ DRIVERS_TOOLS
115 |   ┃   ┣━ OPEN_SOURCE
116 |   ┃   ┣━ CUSTOM_ISO
117 |   ┃   ┗━ ADDONS
118 |   ┣━ 6_7
119 |   ┃   ┣━ PRODUCT_BINARY
120 |   ┃   ┣━ DRIVERS_TOOLS
121 |   ┃   ┣━ OPEN_SOURCE
122 |   ┃   ┣━ CUSTOM_ISO
123 |   ┃   ┗━ ADDONS
124 |   ┣━ 6_5
125 |   ┃   ┣━ PRODUCT_BINARY
126 |   ┃   ┣━ DRIVERS_TOOLS
127 |   ┃   ┣━ OPEN_SOURCE
128 |   ┃   ┣━ CUSTOM_ISO
129 |   ┃   ┗━ ADDONS
130 |   ┣━ 6_0
131 |   ┃   ┣━ PRODUCT_BINARY
132 |   ┃   ┣━ DRIVERS_TOOLS
133 |   ┃   ┣━ OPEN_SOURCE
134 |   ┃   ┣━ CUSTOM_ISO
135 |   ┃   ┗━ ADDONS
136 |   ┣━ 5_5
137 |   ┃   ┣━ PRODUCT_BINARY
138 |   ┃   ┣━ DRIVERS_TOOLS
139 |   ┃   ┣━ OPEN_SOURCE
140 |   ┃   ┣━ CUSTOM_ISO
141 |   ┃   ┗━ ADDONS
142 |   ┣━ 5_1
143 |   ┃   ┣━ PRODUCT_BINARY
144 |   ┃   ┣━ DRIVERS_TOOLS
145 |   ┃   ┣━ OPEN_SOURCE
146 |   ┃   ┣━ CUSTOM_ISO
147 |   ┃   ┗━ ADDONS
148 |   ┗━ 5_0
149 |       ┣━ PRODUCT_BINARY
150 |       ┣━ DRIVERS_TOOLS
151 |       ┣━ OPEN_SOURCE
152 |       ┣━ CUSTOM_ISO
153 |       ┗━ ADDONS
154 | 
155 | 156 | And so on. 157 | 158 | ## Usage 159 | `vmw-cli` supports the ability to **browse** available files using the `ls` command. 160 | You can then leverage the `cp` command to retrieve one of the listed files. 161 | You must execute an `ls` for the desired `` before you can issue the `cp` command for a file. 162 | 163 | #### Get root-level category listing 164 | ``` 165 | $ vmw-cli ls 166 | ``` 167 | 168 | #### View files listed under `vmware_nsx_t_data_center` 169 | ``` 170 | $ vmw-cli ls vmware_nsx_t_data_center 171 | ``` 172 | 173 | Note: This will default to latest `` and `` = `PRODUCT_BINARY` as they are not specified. 174 | 175 | #### View available versions listed under `vmware_nsx_t_data_center` 176 | ``` 177 | $ vmw-cli ls vmware_nsx_t_data_center/ 178 | ``` 179 | 180 | #### View files for version `3_x` under `vmware_nsx_t_data_center` 181 | ``` 182 | $ vmw-cli ls vmware_nsx_t_data_center/3_x 183 | ``` 184 | 185 | Note: This will default `` = `PRODUCT_BINARY` as it was not specified. 186 | 187 | #### View available types for version `3_x` under `vmware_nsx_t_data_center` 188 | ``` 189 | $ vmw-cli ls vmware_nsx_t_data_center/3_x/ 190 | ``` 191 | 192 | #### View `DRIVERS_TOOLS` files for version `3_x` under `vmware_nsx_t_data_center` 193 | ``` 194 | $ vmw-cli ls vmware_nsx_t_data_center/3_x/DRIVERS_TOOLS 195 | ``` 196 | 197 | #### View `PRODUCT_BINARY` files for version `3_x` under `vmware_nsx_t_data_center` 198 | ``` 199 | $ vmw-cli ls vmware_nsx_t_data_center/3_x/PRODUCT_BINARY 200 | ``` 201 | 202 | #### Download file `nsx-unified-appliance-3.0.1.1.0.16556500.ova` 203 | ``` 204 | $ vmw-cli cp nsx-unified-appliance-3.0.1.1.0.16556500.ova 205 | [POST] https://my.vmware.com/channel/api/v1.0/ems/accountinfo 206 | { 207 | "locale": "en_US", 208 | "downloadGroup": "NSX-T-30110", 209 | "productId": "982", 210 | "md5checksum": "9cf49e74d7d43c11768a04fb05f92d85", 211 | "tagId": 12178, 212 | "dlgType": "Product Binaries", 213 | "productFamily": "VMware NSX-T Data Center", 214 | "releaseDate": "2020-07-16", 215 | "dlgVersion": "3.0.1.1", 216 | "isBetaFlow": false 217 | } 218 | [POST] https://my.vmware.com/channel/api/v1.0/dlg/download 219 | nsx-unified-appliance-3.0.1.1.0.16556500.ova [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■] 100% | 0.0s | 11.02/11.02 GB 220 | MD5 MATCH: local[ 9cf49e74d7d43c11768a04fb05f92d85 ] remote [ 9cf49e74d7d43c11768a04fb05f92d85 ] 221 | ``` 222 | 223 | File will be downloaded to current working directory. 224 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | CNAME='apnex/vmw-cli' 3 | docker rm -f ${CNAME} 2>/dev/null 4 | docker rm -v $(docker ps -qa -f name=${CNAME} -f status=exited) 2>/dev/null 5 | docker rmi -f ${CNAME} 2>/dev/null 6 | 7 | docker build --no-cache -t docker.io/apnex/vmw-cli https://github.com/apnex/vmw-cli.git 8 | #docker build --no-cache -t docker.io/apnex/vmw-cli -f dockerfile . 9 | docker rmi -f $(docker images -q --filter label=stage=intermediate) 10 | -------------------------------------------------------------------------------- /clear.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -f lib/cookies.json 4 | rm -f lib/headers.json 5 | rm -f lib/fileList.json 6 | rm -f lib/mainIndex.json 7 | rm -f lib/productIndex.json 8 | rm -f lib/cache/* 9 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS build 2 | LABEL stage=intermediate 3 | WORKDIR "/root" 4 | RUN apk --no-cache add \ 5 | git \ 6 | nodejs \ 7 | npm \ 8 | && npm install npm@latest --global \ 9 | && npm install vmw-cli 10 | 11 | FROM alpine 12 | COPY --from=build /root/node_modules /root/node_modules 13 | RUN apk --no-cache add nodejs \ 14 | && ln -s /root/node_modules/vmw-cli/lib/vmw.cli.js /usr/bin/vmw-cli \ 15 | && mkdir -p /files /state 16 | COPY vmw-cli /root/ 17 | COPY vmw.complete /root/ 18 | COPY start.sh /root/ 19 | ENV VMWFILESDIR "/files" 20 | ENV VMWSTATEDIR "/state" 21 | ENTRYPOINT ["/root/start.sh"] 22 | -------------------------------------------------------------------------------- /inspect.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## list local env variables to be passed to container here 4 | LENV=() 5 | LENV+=("VMWUSER") 6 | LENV+=("VMWPASS") 7 | 8 | ## get container env variable 9 | CENV=$(docker inspect -f "{{json .Config.Env }}" vmw-cli | tr "," "\n") 10 | 11 | ## return specific container env value 12 | cget() { 13 | for ITEM in ${CENV[@]}; do 14 | if [[ ${ITEM} =~ ([0-9a-zA-Z]+)=(.*)\" ]]; then 15 | if [[ "${BASH_REMATCH[1]}" == "${1}" ]]; then 16 | printf "${BASH_REMATCH[2]}" 17 | fi 18 | fi 19 | done 20 | } 21 | 22 | ## compare defined local values against container 23 | lget() { 24 | local PASS=1 25 | for ITEM in ${LENV[@]}; do 26 | local LVAL=${!ITEM} 27 | local CVAL=$(cget ${ITEM}) 28 | if [[ -n ${LVAL} && ${LVAL} != ${CVAL} ]]; then 29 | PASS=0 30 | fi 31 | echo "LVAL: ${LVAL} >> CVAL: ${CVAL}" 1>&2 32 | done 33 | printf ${PASS} 34 | } 35 | 36 | if [[ $(lget) == 0 ]]; then 37 | echo "MATCH FAILED, RESTART CONTAINER" 38 | fi 39 | -------------------------------------------------------------------------------- /lib/streamer.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const stream = require('stream'); 3 | const got = require('got'); 4 | const ProgressBar = require('progress'); 5 | const {promisify} = require('util'); 6 | const pipeline = promisify(stream.pipeline); 7 | 8 | // colours 9 | const chalk = require('chalk'); 10 | const red = chalk.bold.red; 11 | const orange = chalk.keyword('orange'); 12 | const green = chalk.green; 13 | const blue = chalk.blueBright; 14 | 15 | module.exports = class vmwClient { 16 | constructor(options) { 17 | this.options = options; 18 | // setup stuff 19 | } 20 | async download(url, fileName, dstStream) { 21 | let bar; 22 | let current = 0; 23 | let prevTrans = 0; 24 | let label = 0; 25 | let labels = [ 26 | 'KB', 27 | 'MB', 28 | 'GB' 29 | ]; 30 | return pipeline( 31 | got.stream(url) 32 | .on('request', (request) => { 33 | //console.log('REQUEST START'); 34 | }) 35 | .on('response', (response) => { 36 | //console.log('FIRST RESPONSE'); 37 | }) 38 | .on('downloadProgress', (progress) => { 39 | //let rate = Math.round(bar.curr / ((new Date - bar.start) / 1000) / 1000); 40 | if(progress.total) { // handle no content-header 41 | if(typeof(bar) == 'undefined') { 42 | let totalBytes = progress.total; 43 | let total = parseInt(totalBytes / 1024 * 100) / 100; // get KB 44 | while(total > 99.99) { // scale up 45 | label++; 46 | total = parseInt(total / 1024 * 100) / 100; 47 | } 48 | bar = new ProgressBar(blue(fileName) + ' [' + green(':bar') + '] :percent | :etas | :curr/' + pad(total) + ' ' + labels[label], { 49 | complete: '\u25A0', 50 | head: '\u25A0', 51 | incomplete: ' ', 52 | width: 50, 53 | renderThrottle: 500, 54 | total: totalBytes 55 | }); 56 | } else { 57 | let chunk = progress.transferred - prevTrans; 58 | prevTrans = progress.transferred; 59 | current = progress.transferred; 60 | for(let i = 0; i <= label; i++) { 61 | current = parseInt(current / 1024 * 100) / 100; 62 | } 63 | bar.tick(chunk, { 64 | 'curr': pad(current) 65 | }); 66 | } 67 | } 68 | }), 69 | dstStream 70 | .on('finish', () => { 71 | if(!bar.complete) { 72 | bar.update(1); 73 | } 74 | }) 75 | ); 76 | } 77 | } 78 | 79 | // pad/truncate zeros to left and right of number 80 | function pad(num) { 81 | let rgx = /^([^.]+)\.([^.]+)/g 82 | if(m = rgx.exec(num.toFixed(2).toString())) { 83 | let left = m[1]; 84 | if(left.length < 2) { 85 | left = ('00' + left).slice(-2); 86 | } 87 | return left + '.' + m[2]; 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /lib/vmw.api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | const vmwClient = require('vmw-sdk'); 4 | const streamer = require('./streamer'); 5 | const fs = require('fs'); 6 | const CookieFileStore = require('tough-cookie-file-store').FileCookieStore; 7 | const {CookieJar} = require('tough-cookie'); 8 | //import pMap from 'p-map'; // not set up yet for ESM 9 | const pMap = require('p-map'); 10 | const storage = require('node-persist'); 11 | 12 | // provides a higher order interface on top of vmw.sdk.js 13 | // handles auth/session persistence 14 | // handles FS/IO operations 15 | // deals in input/output JSON data, does not render to screen - delegates to cli 16 | // handles file download operations 17 | // handles MD5 checks 18 | 19 | // ignore self-signed certificate 20 | //process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; 21 | // colours 22 | const chalk = require('chalk'); 23 | const red = chalk.bold.red; 24 | const orange = chalk.keyword('orange'); 25 | const green = chalk.green; 26 | const blue = chalk.blueBright; 27 | 28 | module.exports = class vmwApi { 29 | constructor(options = {}) { 30 | this.username = options.username; 31 | this.password = options.password; 32 | this.statedir = options.statedir; 33 | this.client = new vmwClient({ 34 | cookieJar: new CookieJar(new CookieFileStore(this.statedir + '/cookies.json')) 35 | }); 36 | if (!fs.existsSync(this.statedir + '/cache/')){ 37 | fs.mkdirSync(this.statedir + '/cache/'); 38 | } 39 | this.cache = storage.create({ 40 | dir: this.statedir + '/cache/', 41 | stringify: JSON.stringify, 42 | parse: JSON.parse, 43 | encoding: 'utf8', 44 | logging: false, 45 | ttl: 60 * 60 * 1000, // 60 minutes 46 | expiredInterval: 10 * 60 * 1000, // every 2 minutes the process will clean-up the expired cache 47 | forgiveParseErrors: false 48 | }); 49 | } 50 | async main(category, version, type) { 51 | try { 52 | // test account info 53 | let account = await this.tryAuth(); 54 | 55 | // build product cache 56 | let result = await this.getRelatedDLGList(category, version, type); 57 | let fetchEntries = Object.entries(this.buildFileList(result)); 58 | 59 | // Map and resolve all outstanding web calls, flatten arrays and return data result 60 | let data = (await pMap(fetchEntries, ((item) => { 61 | return this.fetchFiles({ 62 | downloadGroup: item[0], 63 | productId: Object.keys(item[1].productId)[0] 64 | }); 65 | }), {concurrency: 8})).flat(1); 66 | fs.writeFileSync(this.statedir + '/fileList.json', JSON.stringify(data, null, "\t"), 'utf8'); 67 | return data; 68 | } catch(error) { 69 | throw new Error(error.message); 70 | } 71 | } 72 | async clear() { 73 | await this.cache.clear(); 74 | this.client.cookieJar.removeAllCookiesSync(); 75 | try { 76 | fs.unlinkSync(this.statedir + '/cookies.json'); 77 | fs.unlinkSync(this.statedir + '/fileList.json'); 78 | fs.unlinkSync(this.statedir + '/headers.json'); 79 | fs.unlinkSync(this.statedir + '/mainIndex.json'); 80 | } catch(error){} 81 | console.log('[CLEAR]: All state cleared'); 82 | } 83 | async tryAuth() { 84 | // check cache if username has changed since last attempt - if so, clear cache 85 | let value = await this.cache.getItem('username'); 86 | if(value != this.username) { 87 | console.error('[WARN]: AUTH-CHANGE - [' + value + ' : ' + this.username + ']'); 88 | await this.clear(); 89 | await this.cache.setItem('username', this.username); 90 | } 91 | // attempt accountInfo 92 | try { 93 | if(fs.existsSync(this.statedir + '/headers.json')) { 94 | let headers = require(this.statedir + '/headers.json'); 95 | this.client.client = this.client.base.extend({ 96 | prefixUrl: 'https://customerconnect.vmware.com/', 97 | headers 98 | }); 99 | } else { 100 | this.client.client = this.client.base.extend({ 101 | prefixUrl: 'https://customerconnect.vmware.com/' 102 | }); 103 | } 104 | return await this.client.accountInfo(); 105 | } catch(error) { 106 | //console.log(error); 107 | if(error.code == 401) { 108 | // login 109 | console.error('[INFO]: 401 - Attempting new login'); 110 | let headers = await this.client.login({ 111 | username: this.username, 112 | password: this.password 113 | }); 114 | fs.writeFileSync(this.statedir + '/headers.json', JSON.stringify(headers, null, "\t"), 'utf8'); 115 | // retry 116 | console.log('[INFO]: Re-authentication completed, trying call again...'); 117 | return await this.client.accountInfo(); 118 | } 119 | } 120 | } 121 | async fetchFiles(body) { 122 | // Consult response cache 123 | let cacheKey = body.downloadGroup + ':' + body.productId; 124 | let value = await this.cache.getItem(cacheKey); 125 | if(typeof(value) != 'undefined') { 126 | console.error('[CACHE]: ' + cacheKey); 127 | return value; 128 | } 129 | // get product header 130 | let fileHeader = await this.client.getDLGHeader(body); 131 | 132 | // check download eligibility 133 | try { 134 | let fileDetails = await this.client.getDLGDetails(body); 135 | let canDownload = fileDetails.eligibilityResponse.eligibleToDownload.toString(); 136 | // build file list 137 | let files = fileDetails.downloadFiles; 138 | let result = []; 139 | files.forEach((item) => { 140 | if(!item.header) { 141 | result.push({ 142 | fileName: item.fileName, 143 | title: item.title, 144 | description: item.description, 145 | sha1checksum: item.sha1checksum, 146 | sha256checksum: item.sha256checksum, 147 | md5checksum: item.md5checksum, 148 | build: item.build, 149 | releaseDate: item.releaseDate, 150 | fileType: item.fileType, 151 | fileSize: item.fileSize, 152 | version: item.version, 153 | uuid: item.uuid, 154 | productFamily: fileHeader.product.name, 155 | downloadGroup: body.downloadGroup, 156 | productId: body.productId, 157 | dlgType: fileHeader.dlg.type.replace(/&/g, '&'), 158 | tagId: fileHeader.dlg.tagId, 159 | canDownload: canDownload 160 | }); 161 | } 162 | }); 163 | // set cache 164 | await this.cache.setItem(cacheKey, result); 165 | return result; 166 | } catch(error) { 167 | console.log('BAD RESPONSE - SKIPPING: ' + body.downloadGroup); 168 | return []; 169 | } 170 | } 171 | buildFileList(result) { // make async? 172 | // BUILD code->[productId] map 173 | let cache = {}; 174 | result.dlgEditionsLists.forEach((item) => { 175 | item.dlgList.forEach((product) => { 176 | if(typeof(cache[product.code]) == 'undefined') { 177 | cache[product.code] = { 178 | productId: {} 179 | } 180 | } 181 | cache[product.code].productId[product.productId] = 1; 182 | }); 183 | }); 184 | //fs.writeFileSync('./mainIndex.json', JSON.stringify(cache, null, "\t"), 'utf8'); 185 | return cache; 186 | } 187 | async getProductVersions(productName) { 188 | let products = await this.getProducts(); 189 | let product = products.filter((item) => { 190 | return (item.product == productName); 191 | })[0]; 192 | return this.client.getProductHeader({ 193 | category: product.category, 194 | product: product.product, 195 | version: product.version 196 | }); 197 | } 198 | async getRelatedDLGList(productName, productVersion, productType) { 199 | let products = await this.getProducts(); 200 | let product = products.filter((item) => { 201 | return (item.product == productName); 202 | })[0]; 203 | 204 | if(typeof(product) != 'undefined') { 205 | if(typeof(productVersion) == 'undefined') { 206 | productVersion = product.version; 207 | } 208 | if(typeof(productType) == 'undefined') { 209 | productType = product.dlgType; 210 | } 211 | let params = { 212 | category: product.category, 213 | product: product.product, 214 | version: productVersion, 215 | dlgType: productType 216 | }; 217 | return this.client.getRelatedDLGList(params); 218 | } else { 219 | throw new Error('[' + red('ERROR') + ']: Invalid category selected [' + blue(productName) + ']'); 220 | } 221 | } 222 | async getProducts() { 223 | let result = []; 224 | return this.client.getProducts().then((products) => { 225 | let links = products.productCategoryList[0].productList; 226 | links.forEach((item) => { 227 | let target = item.actions.filter((link) => { 228 | return (link.linkname == 'View Download Components'); 229 | })[0].target; 230 | let values = target.split('/'); 231 | result.push({ 232 | name: item.name, 233 | target: target, 234 | category: values[3], 235 | product: values[4], 236 | version: values[5], 237 | dlgType: 'PRODUCT_BINARY' 238 | }); 239 | }); 240 | return result; 241 | }); 242 | } 243 | async getDownload(fileName) { 244 | // test account info 245 | let account = await this.tryAuth(); 246 | 247 | // load fileList and retrieve file details 248 | if(fs.existsSync(this.statedir + '/fileList.json')) { 249 | let data = require(this.statedir + '/fileList.json'); 250 | let details = data.filter((file) => { 251 | return (file.fileName == fileName); 252 | })[0]; 253 | 254 | // fire download request 255 | if(typeof(details) != 'undefined') { 256 | if(details.canDownload == 'true') { 257 | let json = { 258 | "locale": "en_US", 259 | "downloadGroup": details.downloadGroup, 260 | "productId": details.productId, 261 | "md5checksum": details.md5checksum, 262 | "tagId": details.tagId, 263 | "uUId": details.uuid, 264 | "dlgType": details.dlgType, 265 | "productFamily": details.productFamily, 266 | "releaseDate": details.releaseDate, 267 | "dlgVersion": details.version, 268 | "isBetaFlow": false 269 | }; 270 | console.log(JSON.stringify(json, null, "\t")); 271 | let result = await this.client.getDownload(json); 272 | result.md5checksum = details.md5checksum; 273 | return result; 274 | } else { 275 | throw new Error('[' + red('ERROR') + ']: Not permitted to download this file, check account entitlement'); 276 | } 277 | } else { 278 | throw new Error('[' + red('ERROR') + ']: [' + blue(fileName) + '] not cached, please use ' + blue('ls ') + ' to view first'); 279 | } 280 | } else { 281 | throw new Error('[' + red('ERROR') + ']: No files cached, please use ' + blue('ls ') + ' to view'); 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /lib/vmw.cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | const vmwClient = require('./vmw.api'); 4 | const streamer = require('./streamer'); 5 | const fs = require('fs'); 6 | const xtable = require('./xtable'); 7 | const md5File = require('md5-file'); 8 | 9 | /* Module Purpose 10 | To provide a cli interface for the vmw-api module. 11 | To parse stdin input from user, structure syntac and execute api calls in valid format. 12 | To cleanly display output to user via stdout 13 | To perform any view specific data transforms 14 | Handle client IO unrelated to the VMW-API interface 15 | */ 16 | 17 | // ignore self-signed certificate 18 | //process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; 19 | 20 | // global ENV settings 21 | var username = process.env.VMWUSER; 22 | var password = process.env.VMWPASS; 23 | var filesdir = process.cwd(); 24 | var statedir = __dirname; 25 | if(process.env.VMWFILESDIR) { 26 | filesdir = process.env.VMWFILESDIR; 27 | } 28 | if(process.env.VMWSTATEDIR) { 29 | statedir = process.env.VMWSTATEDIR; 30 | } 31 | 32 | // colours 33 | const chalk = require('chalk'); 34 | const red = chalk.bold.red; 35 | const orange = chalk.keyword('orange'); 36 | const green = chalk.green; 37 | const blue = chalk.blueBright; 38 | 39 | // init vmw-api client 40 | const client = new vmwClient({ 41 | username, 42 | password, 43 | statedir 44 | }); 45 | 46 | // called from shell 47 | const args = process.argv.splice(2); 48 | if(process.argv[1].match(/vmw.cli/g)) { 49 | switch(args[0]) { 50 | case 'ls': 51 | if(args.length >= 2) { 52 | if(args[1] == '.') { 53 | cmdFile(); 54 | } else { 55 | cmdPath(args[1]); 56 | } 57 | } else { 58 | list(); 59 | } 60 | break; 61 | case 'cp': 62 | if(args.length >= 2) { 63 | cmdGet(args[1]); 64 | } else { 65 | console.log('[' + red('ERROR') + ']: usage ' + blue('get ')); 66 | } 67 | break; 68 | case 'clear': 69 | cmdClear(); 70 | break; 71 | case 'files': 72 | cmdFile(); 73 | break; 74 | default: 75 | console.log(blue('')); 76 | [ 77 | 'ls', 78 | 'cp', 79 | 'clear' 80 | ].forEach((cmd) => {console.log(cmd)}); 81 | break; 82 | } 83 | } 84 | 85 | // main 86 | async function main(category, version, type) { 87 | client.main(category, version, type).then((data) => { 88 | let table = new xtable({data}); 89 | table.run(); 90 | table.out([ 91 | 'fileName', 92 | 'fileType', 93 | 'version', 94 | 'releaseDate', 95 | 'fileSize', 96 | 'canDownload' 97 | ]); 98 | }).catch((error) => { 99 | //console.log('[CLI-ERROR]: ' + error.message); 100 | console.log(error.message); 101 | }); 102 | } 103 | 104 | // list 105 | async function list() { 106 | client.getProducts().then((result) => {; 107 | console.log(blue('')); 108 | result.forEach((item) => { 109 | console.log(item.product); 110 | }); 111 | }); 112 | } 113 | 114 | async function cmdClear() { 115 | await client.clear(); 116 | } 117 | 118 | async function cmdPath(string) { 119 | // need to normalise path to [category, version, type] before calling main 120 | //console.log('Path test [' + string + ']'); 121 | let path = string.split('/'); 122 | //console.log(JSON.stringify(path, null, "\t")); 123 | if(path.length == 1) { 124 | if(path[0].length > 0) { 125 | //console.log('Path size 1: ' + path[0]); 126 | main(path[0]); 127 | } 128 | } else if(path.length == 2) { 129 | if(path[1].length > 0) { 130 | //console.log('Path size 2: ' + path[0]); 131 | main(path[0], path[1]); 132 | } else { 133 | //console.log('Training Slash!! - call VERSIONS: ' + path[0]); 134 | let result = await client.getProductVersions(path[0]); 135 | let table = new xtable({ 136 | data: result.versions 137 | }); 138 | table.run(); 139 | table.out([ 140 | 'id', 141 | 'name', 142 | 'slugUrl', 143 | 'isSelected' 144 | ]); 145 | } 146 | } else if(path.length == 3) { 147 | if(path[2].length > 0) { 148 | //console.log('Path size 3: ' + path[0]); 149 | main(path[0], path[1], path[2]); 150 | } else { 151 | //console.log('Training Slash!! - call TYPES'); 152 | let data = [ 153 | {id: 'PRODUCT_BINARY'}, 154 | {id: 'DRIVERS_TOOLS'}, 155 | {id: 'OPEN_SOURCE'}, 156 | {id: 'CUSTOM_ISO'}, 157 | {id: 'ADDONS'} 158 | ]; 159 | let table = new xtable({data}); 160 | table.run(); 161 | table.out(['id']); 162 | } 163 | } else { 164 | console.log('INVALID PATH'); 165 | } 166 | } 167 | 168 | async function cmdGet(fileName) { // broken, fix 169 | let nstream = new streamer(); 170 | client.getDownload(fileName).then(async(file) => { 171 | await nstream.download(file.downloadURL, file.fileName, fs.createWriteStream(filesdir + '/' + fileName)); 172 | let md5Local = md5File.sync(filesdir + '/' + fileName) 173 | if(file.md5checksum == md5Local) { 174 | console.log('MD5 MATCH: local[ ' + green(md5Local) + ' ] remote [ ' + green(file.md5checksum) + ' ]'); 175 | } else { 176 | console.log('MD5 FAIL!: local[ ' + green(md5Local) + ' ] remote [ ' + green(file.md5checksum) + ' ]'); 177 | } 178 | }).catch((error) => { 179 | console.log(error.message); 180 | }); 181 | } 182 | 183 | async function cmdFile() { 184 | // load fileList and retrieve file details 185 | if(fs.existsSync(statedir + '/fileList.json')) { 186 | let data = require(statedir + '/fileList.json'); 187 | let table = new xtable({data}); 188 | table.run(); 189 | table.out([ 190 | 'fileName', 191 | 'fileType', 192 | 'version', 193 | 'releaseDate', 194 | 'fileSize', 195 | 'canDownload' 196 | ]); 197 | } 198 | } 199 | 200 | -------------------------------------------------------------------------------- /lib/xcell.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const self = xcell.prototype; 3 | 4 | // constructor 5 | function xcell(opts) { 6 | this.data = opts.data.map(a => ({...a})); // deep copy 7 | this.view = []; 8 | this.maps = []; 9 | } 10 | module.exports = xcell; 11 | 12 | // add map 13 | self.addMap = function(field, rule) { 14 | this.maps.push({ 15 | 'field': field, 16 | 'rule': rule 17 | }); 18 | return this; 19 | }; 20 | 21 | // add filter 22 | self.addFilter = function(f) { 23 | return this.addMap(f.field, (val) => { 24 | if(typeof(val) === 'string' && val.match(new RegExp(f.value, 'i'))) { 25 | return val; 26 | } else { 27 | return null; 28 | } 29 | }); 30 | }; 31 | 32 | // check type and translate record 33 | self.modify = function(m, item) { 34 | if(typeof(m.rule) === 'function') { 35 | return (item[m.field] = m.rule(item[m.field])); 36 | } else { 37 | return (item[m.field] = m.rule); 38 | } 39 | }; 40 | 41 | // filter and transform current view 42 | self.run = function(data = this.data) { 43 | return this.view = data.filter((item) => { 44 | return this.maps.every((m) => { 45 | return this.modify(m, item); 46 | }); 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /lib/xtable.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var xcell = require('./xcell.js'); 3 | var self = xtable.prototype; 4 | 5 | // colours 6 | const chalk = require('chalk'); 7 | const red = chalk.bold.red; 8 | const orange = chalk.keyword('orange'); 9 | const green = chalk.green; 10 | const blue = chalk.blueBright; 11 | const cyan = chalk.cyan; 12 | 13 | // rework as class 14 | 15 | // constructor 16 | function xtable(opts) { // Object.assign? 17 | this.cache = {}; 18 | this.view = []; 19 | this.header = opts.header; 20 | this.cell = new xcell({ 21 | data: opts.data 22 | }); 23 | this.data = this.cell.data; 24 | this.column = opts.column; 25 | this.filters = []; 26 | } 27 | module.exports = xtable; 28 | 29 | self.out = function(cols) { 30 | if(this.cell.data.length > 0) { 31 | if(!this.header) { // learn cols from first record 32 | this.header = []; 33 | Object.keys(this.cell.data[0]).forEach((item) => { 34 | this.header.push(item); 35 | }); 36 | } 37 | if(!cols) { 38 | cols = this.header; 39 | } 40 | let col = {}; 41 | cols.forEach((item) => { 42 | col[item] = '<' + item + '>'; 43 | }); 44 | this.runColWidth(col); 45 | 46 | // scan widths 47 | if(this.view.length == 0) { 48 | this.run(); 49 | } 50 | this.view.forEach((item) => { 51 | this.runColWidth(item); 52 | }); 53 | 54 | // build header string 55 | let headString = ''; 56 | let dashString = ''; 57 | let spacer = ' '; 58 | let gap = spacer.repeat(2); 59 | cols.forEach((item) => { 60 | if(headString.length > 0) { 61 | headString += gap; 62 | dashString += gap; 63 | } 64 | headString += '<' + item + '>' + spacer.repeat(this.cache[item] - (item.length + 2)); 65 | dashString += '-'.repeat(this.cache[item]); 66 | }); 67 | console.log(blue(headString)); 68 | //console.log(dashString); 69 | 70 | // build data string 71 | this.view.forEach((item) => { 72 | let dataString = ''; 73 | cols.forEach((col) => { 74 | if(dataString.length > 0) { 75 | dataString += gap; 76 | } 77 | if(item[col]) { 78 | dataString += item[col] + spacer.repeat(this.cache[col] - self.getLength(item[col])); 79 | } else { 80 | dataString += spacer.repeat(this.cache[col]); 81 | } 82 | }); 83 | console.log(dataString); 84 | }); 85 | 86 | // display filter 87 | //console.error(blue('[ ' + this.view.length + '/' + this.data.length + ' ] entries - filter [ ' + this.filterString() + ' ]')); 88 | //console.error(blue('[ ' + green(this.view.length + '/' + this.data.length) + ' ] entries - filter [ ' + green(this.filterString()) + ' ]')); 89 | } 90 | } 91 | 92 | // determine maximum string length for column 93 | self.runColWidth = function(item) { 94 | for(let key in item) { // per record 95 | if(value = item[key]) { 96 | let length = self.getLength(value); 97 | if(!this.cache[key] || this.cache[key] < length) { 98 | this.cache[key] = length; 99 | } 100 | } 101 | } 102 | }; 103 | 104 | // determine maximum string length for column 105 | self.getLength = function(value) { 106 | switch(typeof(value)) { 107 | case "number": 108 | return value.toString().length; 109 | break; 110 | case "string": 111 | return value.length; 112 | break; 113 | } 114 | }; 115 | 116 | // stringify this.filters[]; 117 | self.filterString = function() { 118 | let string = ''; 119 | let comma = ''; 120 | this.filters.map((filter) => { 121 | string += comma + filter.field + ':' + filter.value; 122 | comma = ','; 123 | }); 124 | return string; 125 | }; 126 | 127 | // parse and construct filter objects 128 | self.buildFilters = function(string) { 129 | let filters = []; 130 | var rgxFilter = new RegExp('([^,:]+):([^,:]*)', 'g'); 131 | while(m = rgxFilter.exec(string)) { 132 | let val1 = m[1]; 133 | let val2 = m[2]; 134 | filters.push({ 135 | field: val1, 136 | value: val2 137 | }); 138 | } 139 | if(filters.length == 0) { 140 | if(!string) string = ''; 141 | filters.push({ 142 | field: 'name', 143 | value: string 144 | }); 145 | } 146 | filters.forEach((filter) => { 147 | this.addFilter(filter); 148 | }); 149 | }; 150 | 151 | // add map 152 | self.addMap = function(field, mapper) { 153 | this.cell.addMap(field, mapper); 154 | return this; 155 | }; 156 | 157 | // add filter 158 | self.addFilter = function(filter) { 159 | this.filters.push(filter); 160 | this.cell.addFilter(filter); 161 | return this; 162 | }; 163 | 164 | // filter and transform current view 165 | self.run = function(data = this.data) { 166 | this.view = this.cell.run(data); 167 | }; 168 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Andrew Obersnel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vmw-cli", 3 | "version": "2.1.2", 4 | "description": "CLI interface for my.vmware.com", 5 | "license": "MIT", 6 | "repository": "apnex/vmw-cli", 7 | "author": { 8 | "name": "Andrew Obersnel" 9 | }, 10 | "engines": { 11 | "node": ">=8" 12 | }, 13 | "bin": { 14 | "vmw-cli": "./lib/vmw.cli.js" 15 | }, 16 | "files": [ 17 | "/lib" 18 | ], 19 | "keywords": [ 20 | "myvmw", 21 | "vmware", 22 | "vmw-cli", 23 | "vmw", 24 | "nodejs", 25 | "" 26 | ], 27 | "dependencies": { 28 | "vmw-sdk": "*", 29 | "chalk": "*", 30 | "got": "*", 31 | "md5-file": "*", 32 | "node-persist": "*", 33 | "p-map": "=4.0.0", 34 | "progress": "*", 35 | "tough-cookie": "*", 36 | "tough-cookie-file-store": "*" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | function start { 4 | vmw-cli "${@}" 5 | } 6 | 7 | #if [ -z "$1" ]; then 8 | # start "${@}" 9 | #else 10 | case ${1} in 11 | shell) 12 | cat /root/vmw-cli 13 | ;; 14 | complete) 15 | cat /root/vmw.complete 16 | ;; 17 | *) 18 | start "${@}" 19 | ;; 20 | esac 21 | #fi 22 | -------------------------------------------------------------------------------- /vmw-cli: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [[ $0 =~ ^(.*)/([^/]+)$ ]]; then 3 | WORKDIR="${BASH_REMATCH[1]}" 4 | CALLED="${BASH_REMATCH[2]}" 5 | fi 6 | 7 | ## cli name 8 | CNAME="vmw-cli" 9 | 10 | ## list local shell env variables to be passed to container here 11 | LENV=() 12 | LENV+=("VMWUSER") 13 | LENV+=("VMWPASS") 14 | LENV+=("PWD") 15 | 16 | ## return specific running container env value 17 | CENV=() 18 | cget() { 19 | for ITEM in ${CENV[@]}; do 20 | if [[ ${ITEM} =~ ([0-9a-zA-Z]+)=(.*)\" ]]; then 21 | if [[ "${BASH_REMATCH[1]}" == "${1}" ]]; then 22 | printf "${BASH_REMATCH[2]}" 23 | fi 24 | fi 25 | done 26 | } 27 | 28 | ## compare defined local values against running container 29 | lget() { 30 | local PASS=1 31 | for ITEM in ${LENV[@]}; do 32 | local LVAL=${!ITEM} 33 | local CVAL=$(cget ${ITEM}) 34 | if [[ -n ${LVAL} && ${LVAL} != ${CVAL} ]]; then 35 | PASS=0 36 | fi 37 | done 38 | printf ${PASS} 39 | } 40 | 41 | ## force stop and clear any dangling containers 42 | cstop() { 43 | docker rm -f ${CNAME} 2>/dev/null 44 | docker rm -v $(docker ps -qa -f name=${CNAME} -f status=exited) 2>/dev/null 45 | } 46 | 47 | ## start new instance of container with fresh env vars 48 | cstart() { 49 | ## build env string 50 | local DSTRING 51 | for ITEM in ${LENV[@]}; do 52 | local LVAL=${!ITEM} 53 | if [[ -n ${LVAL} ]]; then 54 | DSTRING+=" -e ${ITEM}" 55 | fi 56 | done 57 | DSTRING+=" -v ${PWD}:/files" 58 | docker rm -v $(docker ps -qa -f name=${CNAME} -f status=exited) 2>/dev/null 59 | docker run -itd --name ${CNAME} ${DSTRING} --entrypoint=/bin/sh apnex/${CNAME} 1>&2 60 | } 61 | 62 | ## handle autocomplete - don't attach psuedo-tty 63 | cexec() { 64 | docker exec ${CNAME} ${CNAME} "${@}" 65 | } 66 | 67 | ## exec normal user commands 68 | texec() { 69 | docker exec -t ${CNAME} ${CNAME} "${@}" 70 | } 71 | 72 | # main 73 | case "${1}" in 74 | 'stop') ## force stop 75 | printf "[apnex/${CNAME}] stopping\n" 1>&2 76 | cstop 77 | ;; 78 | *) # launch + persist (run + exec) 79 | RUNNING=$(docker ps -q -f name="${CNAME}") 80 | if [[ -n "$RUNNING" ]]; then 81 | CENV=$(docker inspect -f "{{json .Config.Env }}" ${CNAME} | tr "," "\n") 82 | if [[ $(lget) == 0 ]]; then 83 | # env match failed, restart container with new env 84 | cstop 85 | cstart 86 | fi 87 | else 88 | printf "[apnex/${CALLED}] not running - now starting\n" 1>&2 89 | cstart 90 | fi 91 | if [[ "${1}" = "ac" ]]; then 92 | cexec "${@:2}" ## called from vmw.complete 93 | else 94 | texec "${@}" ## called from shell 95 | fi 96 | ;; 97 | esac 98 | -------------------------------------------------------------------------------- /vmw.complete: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | _temp_bind() { ## temporarily change a bunch of bind terminal settings 3 | set -o emacs 4 | bind "set completion-display-width 0" 5 | bind "set history-preserve-point on" 6 | bind "set show-all-if-ambiguous on" 7 | bind "set show-all-if-unmodified on" 8 | bind "set colored-completion-prefix on" 9 | bind "set colored-stats on" 10 | bind "set page-completions off" 11 | bind "set completion-query-items 0" 12 | bind "set skip-completed-text on" 13 | CLIBIND=1 ## flag shell enable 14 | } 15 | _temp_base_complete() { 16 | local CMDFILE=$1 17 | local CUR PRV 18 | local ARRAY=() 19 | COMPREPLY=() 20 | CUR="${COMP_WORDS[COMP_CWORD]}" 21 | PRV="${COMP_WORDS[COMP_CWORD-1]}" 22 | CYAN='\033[0;36m' # cyan 23 | NC='\033[0m' # no colour 24 | BB='\u001B[94m' # blueBright 25 | BBC='\u001B[39m' # close 26 | 27 | if [[ -z ${CLIBIND} ]]; then 28 | _temp_bind ## don't set repeatedly set this as it freezes shell 29 | fi 30 | local IFS=$'\n' 31 | local PARAMLIST 32 | local CURPARAM 33 | local CURKEY 34 | 35 | if [[ ${#COMP_WORDS[@]} -le 2 ]]; then 36 | ARRAY=($(vmw-cli ac 2>/dev/null | tr -d '\r')) # handle CRLF in tty 37 | else # resolve params 38 | if [[ ${#COMP_WORDS[@]} -eq 3 ]]; then 39 | case ${PRV} in 40 | "ls") 41 | ARRAY=($(vmw-cli ac ls 2>/dev/null | tr -d '\r')) # handle CRLF in tty 42 | ;; 43 | "cp") 44 | ARRAY=($(vmw-cli ac ls . 2>/dev/null | tr -d '\r')) # handle CRLF in tty 45 | ;; 46 | esac 47 | fi 48 | fi 49 | 50 | local HEADER="${ARRAY[0]}" 51 | local VALUES=("${ARRAY[@]:1}") 52 | local SUGGESTIONS=($(compgen -W "${VALUES[*]}" -- "${CUR}")) 53 | if [ "${#SUGGESTIONS[@]}" -ge "2" ]; then # print header/values 54 | if [[ -n ${CURKEY} ]]; then 55 | printf "${BB}<${CURKEY}>${BBC}" 1>&2 56 | #_get_params 57 | fi 58 | printf "\n${BB}${HEADER}${BBC}" 1>&2 59 | #for I in "${!SUGGESTIONS[@]}"; do 60 | # SUGGESTIONS[$I]="$(printf '%*s' "-$COLUMNS" "${SUGGESTIONS[$I]}")" 61 | #done 62 | COMPREPLY=("${SUGGESTIONS[@]}") 63 | else 64 | if [ "${#SUGGESTIONS[@]}" == "1" ]; then 65 | local ID="${SUGGESTIONS[0]%%\ *}" 66 | COMPREPLY=("$ID") 67 | fi 68 | fi 69 | return 0 70 | } 71 | _vmw_complete() { 72 | _temp_base_complete 73 | } 74 | complete -o nosort -F _vmw_complete vmw-cli 75 | --------------------------------------------------------------------------------