-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(publish): upload apps to App Hub (#532)
* feat(publish): implement upload of version to App Hub * fix: remove appId as required param * fix: update CI missing params error * refactor: cleanup * fix: cleanup * fix(refactor): rename apiKey to apikey * docs(publish): add docs for publish command * docs: update apikey option name * fix: validate config-file * fix: use more specific env-var * fix: remove check for minVersion - refactor reoslveBundle * refactor: fixes for review
- Loading branch information
Showing
2 changed files
with
318 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,274 @@ | ||
const path = require('path') | ||
const { reporter, chalk } = require('@dhis2/cli-helpers-engine') | ||
const FormData = require('form-data') | ||
const fs = require('fs-extra') | ||
const inquirer = require('inquirer') | ||
const { createClient } = require('../lib/httpClient') | ||
const parseConfig = require('../lib/parseConfig') | ||
const makePaths = require('../lib/paths') | ||
|
||
const constructUploadUrl = appId => `/api/v1/apps/${appId}/versions` | ||
|
||
const isValidServerVersion = v => !!/(\d+)\.(\d+)/.exec(v) | ||
|
||
const dumpHttpError = (message, response) => { | ||
if (!response) { | ||
reporter.error(message) | ||
return | ||
} | ||
|
||
reporter.error( | ||
message, | ||
response.status, | ||
typeof response.data === 'object' | ||
? response.data.message | ||
: response.statusText | ||
) | ||
reporter.debugErr('Error details', response.data) | ||
} | ||
|
||
const requiredFields = new Set(['id', 'version', 'minDHIS2Version']) | ||
const configFieldValidations = { | ||
minDHIS2Version: v => | ||
isValidServerVersion(v) ? true : 'Invalid server version', | ||
maxDHIS2Version: v => | ||
isValidServerVersion(v) ? true : 'Invalid server version', | ||
} | ||
|
||
const validateFields = ( | ||
config, | ||
fieldNames = ['id', 'version', 'minDHIS2Version'] | ||
) => { | ||
fieldNames.forEach(fieldName => { | ||
const fieldValue = config[fieldName] | ||
if ( | ||
requiredFields.has(fieldName) && | ||
(fieldValue == null || fieldValue === '') | ||
) { | ||
reporter.error( | ||
`${fieldName} not found in config. Add an ${chalk.bold( | ||
fieldName | ||
)}-field to ${chalk.bold('d2.config.js')}` | ||
) | ||
process.exit(1) | ||
} | ||
const fieldValidation = configFieldValidations[fieldName] | ||
if (fieldValidation) { | ||
const valid = fieldValidation(fieldValue) | ||
if (typeof valid === 'string') { | ||
reporter.error(`${fieldName}: ${valid}`) | ||
process.exit(1) | ||
} else if (!valid) { | ||
reporter.error(`Invalid ${fieldName}`) | ||
process.exit(1) | ||
} | ||
} | ||
}) | ||
} | ||
|
||
const resolveBundleFromParams = (cwd, params) => { | ||
const appBundle = {} | ||
try { | ||
const filePath = path.resolve(cwd, params.file) | ||
if (!fs.statSync(filePath).isFile()) { | ||
reporter.error(`${params.file} is not a file`) | ||
process.exit(1) | ||
} | ||
appBundle.id = params.appId | ||
appBundle.version = params.fileVersion | ||
appBundle.path = filePath | ||
appBundle.name = path.basename(filePath) | ||
appBundle.minDHIS2Version = params.minDHIS2Version | ||
appBundle.maxDHIS2Version = params.maxDHIS2Version | ||
return appBundle | ||
} catch (e) { | ||
reporter.error(`File does not exist at ${params.file}`) | ||
process.exit(1) | ||
} | ||
} | ||
|
||
const resolveBundleFromAppConfig = cwd => { | ||
// resolve file from built-bundle | ||
const appBundle = {} | ||
const paths = makePaths(cwd) | ||
const builtAppConfig = parseConfig(paths) | ||
validateFields(builtAppConfig) | ||
|
||
appBundle.id = builtAppConfig.id | ||
appBundle.version = builtAppConfig.version | ||
appBundle.path = path.relative( | ||
cwd, | ||
paths.buildAppBundle | ||
.replace(/{{name}}/, builtAppConfig.name) | ||
.replace(/{{version}}/, builtAppConfig.version) | ||
) | ||
appBundle.name = builtAppConfig.name | ||
appBundle.minDHIS2Version = builtAppConfig.minDHIS2Version | ||
appBundle.maxDHIS2Version = builtAppConfig.maxDHIS2Version | ||
|
||
if (!fs.existsSync(appBundle.path)) { | ||
reporter.error( | ||
`App bundle does not exist, run ${chalk.bold( | ||
'd2-app-scripts build' | ||
)} before deploying.` | ||
) | ||
process.exit(1) | ||
} | ||
return appBundle | ||
} | ||
|
||
const resolveBundle = (cwd, params) => { | ||
if (params.file) { | ||
return resolveBundleFromParams(cwd, params) | ||
} | ||
return resolveBundleFromAppConfig(cwd) | ||
} | ||
|
||
const promptForConfig = async params => { | ||
const apiKey = params.apikey || process.env.D2_APP_HUB_API_KEY | ||
if (process.env.CI && !apiKey) { | ||
reporter.error('Prompt disabled in CI mode - missing apikey parameter.') | ||
process.exit(1) | ||
} | ||
|
||
const responses = await inquirer.prompt([ | ||
{ | ||
type: 'input', | ||
name: 'apikey', | ||
message: 'App Hub API-key', | ||
when: () => !apiKey, | ||
}, | ||
{ | ||
type: 'input', | ||
name: 'appId', | ||
message: 'App Hub App-id', | ||
when: () => params.file && !params.appId, | ||
}, | ||
{ | ||
type: 'input', | ||
name: 'fileVersion', | ||
message: 'Version of the app', | ||
when: () => params.file && !params.fileVersion, | ||
}, | ||
{ | ||
type: 'input', | ||
name: 'minDHIS2Version', | ||
message: 'Minimum DHIS2 version supported', | ||
when: () => params.file && !params.minDHIS2Version, | ||
validate: v => | ||
isValidServerVersion(v) ? true : 'Invalid server version', | ||
}, | ||
{ | ||
type: 'input', | ||
name: 'maxDHIS2Version', | ||
message: 'Maximum DHIS2 version supported', | ||
when: () => params.file && !params.maxDHIS2Version, | ||
validate: v => | ||
!v || isValidServerVersion(v) ? true : 'Invalid server version', | ||
}, | ||
]) | ||
|
||
return { | ||
...params, | ||
apikey: apiKey, | ||
...responses, | ||
} | ||
} | ||
|
||
const handler = async ({ cwd = process.cwd(), ...params }) => { | ||
const publishConfig = await promptForConfig(params) | ||
|
||
const appBundle = resolveBundle(cwd, publishConfig) | ||
const uploadAppUrl = constructUploadUrl(appBundle.id) | ||
|
||
const client = createClient({ | ||
baseUrl: publishConfig.baseUrl, | ||
headers: { | ||
'x-api-key': publishConfig.apikey, | ||
}, | ||
}) | ||
|
||
const versionData = { | ||
version: appBundle.version, | ||
minDhisVersion: appBundle.minDHIS2Version, | ||
maxDhisVersion: appBundle.maxDHIS2Version || '', | ||
channel: publishConfig.channel, | ||
} | ||
|
||
const formData = new FormData() | ||
formData.append('file', fs.createReadStream(appBundle.path)) | ||
formData.append('version', JSON.stringify(versionData)) | ||
|
||
try { | ||
reporter.print( | ||
`Uploading app bundle to ${publishConfig.baseUrl + uploadAppUrl}` | ||
) | ||
reporter.debug('Upload with version data', versionData) | ||
|
||
await client.post(uploadAppUrl, formData, { | ||
headers: formData.getHeaders(), | ||
timeout: 300000, // Ensure we have enough time to upload a large zip file | ||
}) | ||
reporter.info( | ||
`Successfully published ${appBundle.name} with version ${appBundle.version}` | ||
) | ||
} catch (e) { | ||
if (e.isAxiosError) { | ||
dumpHttpError('Failed to upload app, HTTP error', e.response) | ||
} else { | ||
reporter.error(e) | ||
} | ||
process.exit(1) | ||
} | ||
} | ||
|
||
const command = { | ||
command: 'publish', | ||
alias: 'p', | ||
desc: 'Deploy the built application to a specific DHIS2 instance', | ||
builder: yargs => | ||
yargs.options({ | ||
apikey: { | ||
alias: 'k', | ||
type: 'string', | ||
description: 'The API-key to use for authentication', | ||
}, | ||
channel: { | ||
alias: 'c', | ||
description: 'The channel to publish the app-version to', | ||
default: 'stable', | ||
}, | ||
baseUrl: { | ||
alias: 'b', | ||
description: 'The base-url of the App Hub instance', | ||
default: 'https://apps.dhis2.org', | ||
}, | ||
minDHIS2Version: { | ||
type: 'string', | ||
description: 'The minimum version of DHIS2 the app supports', | ||
}, | ||
maxDHIS2Version: { | ||
type: 'string', | ||
description: 'The maximum version of DHIS2 the app supports', | ||
}, | ||
appId: { | ||
type: 'string', | ||
description: | ||
'Only used with --file option. The App Hub ID for the App to publish to', | ||
implies: 'file', | ||
}, | ||
file: { | ||
description: | ||
'Path to the file to upload. This skips automatic resolution of the built app and uses this file-path to upload', | ||
}, | ||
'file-version': { | ||
type: 'string', | ||
description: | ||
'Only used with --file option. The semantic version of the app uploaded', | ||
implies: 'file', | ||
}, | ||
}), | ||
handler, | ||
} | ||
|
||
module.exports = command |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
# d2 app scripts publish | ||
|
||
Publishes a built application bundle to the [App Hub](https://apps.dhis2.org/). | ||
|
||
You must run `d2 app scripts build` **before** running `publish` | ||
|
||
Note that you need an App Hub API key before using this command. The API key is used to identify the user uploading the app. You can generate an API key after logging into the [App Hub](https://apps.dhis2.org/). | ||
|
||
This command can only upload _versions_ to an app that **already** exists on the App Hub. The app must have an `id`-field in the `d2.config.js` which matches the id of the app on App Hub. | ||
|
||
This command will prompt the user for information not found in `d2.config.js` or in the parameters. The API key can be specified with the `D2_APP_HUB_API_KEY` environment variable. For example, the following will publish the app without waiting for user input, given that `id` is in the config-file. | ||
|
||
```sh | ||
> d2 app scripts publish | ||
> export D2_APIKEY=xyz | ||
> d2 app scripts publish --minVersion 2.34 --maxVersion 2.36 | ||
``` | ||
|
||
## Usage | ||
|
||
```sh | ||
> d2 app scripts publish --help | ||
d2-app-scripts publish | ||
|
||
Deploy the built application to a specific DHIS2 instance | ||
|
||
Options: | ||
--cwd working directory to use (defaults to cwd) | ||
--version Show version number [boolean] | ||
--config Path to JSON config file | ||
--apikey, -k The API-key to use for authentication [string] | ||
--channel, -c The channel to publish the app-version to [default: "stable"] | ||
--baseUrl, -b The base-url of the App Hub instance | ||
[default: "https://apps.dhis2.org"] | ||
--minVersion The minimum version of DHIS2 the app supports [string] | ||
--maxVersion The maximum version of DHIS2 the app supports [string] | ||
--appId Only used with --file option. The App Hub ID for the App to | ||
publish to [string] | ||
--file Path to the file to upload. This skips automatic resolution of | ||
the built app and uses this file-path to upload | ||
--file-version Only used with --file option. The semantic version of the app | ||
uploaded [string] | ||
-h, --help Show help [boolean] | ||
``` |