diff --git a/packages/@ionic/cli/src/commands/package/build.ts b/packages/@ionic/cli/src/commands/package/build.ts index 22d7006612..529717ed1d 100644 --- a/packages/@ionic/cli/src/commands/package/build.ts +++ b/packages/@ionic/cli/src/commands/package/build.ts @@ -23,7 +23,7 @@ const APP_STORE_COMPATIBLE_TYPES = ['release', 'app-store', 'enterprise']; const BUILD_TYPES = ANDROID_BUILD_TYPES.concat(IOS_BUILD_TYPES); const TARGET_PLATFORM = ['Android', 'iOS - Xcode 11 (Preferred)', 'iOS - Xcode 10']; -interface PackageBuild { +export interface PackageBuild { job_id: number; id: string; caller_id: number; @@ -43,6 +43,7 @@ interface PackageBuild { native_config_name: string; distribution_credential_name: string; job: any; + distribution_trace: string; } interface DownloadUrl { diff --git a/packages/@ionic/cli/src/commands/package/deploy.ts b/packages/@ionic/cli/src/commands/package/deploy.ts new file mode 100644 index 0000000000..04dc2e8750 --- /dev/null +++ b/packages/@ionic/cli/src/commands/package/deploy.ts @@ -0,0 +1,251 @@ +import { + CommandLineInputs, + CommandLineOptions, + LOGGER_LEVELS, + combine, + validators, +} from '@ionic/cli-framework'; +import { columnar } from '@ionic/cli-framework/utils/format'; +import { sleep } from '@ionic/utils-process'; +import * as chalk from 'chalk'; + +import { CommandMetadata } from '../../definitions'; +import { isSuperAgentError } from '../../guards'; +import { input, strong } from '../../lib/color'; +import { Command } from '../../lib/command'; +import { FatalException } from '../../lib/errors'; + +import { PackageBuild } from './build'; + +interface BinaryDeployment { + id: number; + user: any; + build: any; + type: string; + distributionCredential: any; + destination: string; + message: string; + distributionBuildId: number; + status: string; +} + +interface DistributionBuild { + job_id: number; + id: string; + caller_id: number; + created: string; + state: string; + distribution_credential_name: string; + package_build: PackageBuild; + binary_deployment: BinaryDeployment; + distribution_trace: string; +} + +export class DeployCommand extends Command { + async getMetadata(): Promise { + const dashUrl = this.env.config.getDashUrl(); + + return { + name: 'build', + type: 'project', + summary: 'Deploys a binary to a destination, such as an app store using Appflow', + description: ` +This command deploys a binary to a destination using Ionic Appflow. While running, the remote log is printed to the terminal. + +The command takes two parameters: the numeric ID of the package build that previously created the binary and the name of the destination where the binary is going to be deployed. +Both can be retrieved from the Dashboard[^dashboard]. + `, + footnotes: [ + { + id: 'dashboard', + url: dashUrl, + }, + ], + exampleCommands: ['123456789 "My app store destination"'], + inputs: [ + { + name: 'build-id', + summary: `The build id of the desired successful package build`, + validators: [validators.required, validators.numeric], + }, + { + name: 'destination', + summary: + 'The destination to deploy the build artifact to the app store', + validators: [validators.required], + }, + ], + }; + } + + async preRun( + inputs: CommandLineInputs, + options: CommandLineOptions + ): Promise { + if (!inputs[0]) { + const buildIdInputInput = await this.env.prompt({ + type: 'input', + name: 'build-id', + message: `The build ID on Appflow:`, + validate: v => combine(validators.required, validators.numeric)(v), + }); + inputs[0] = buildIdInputInput; + } + + if (!inputs[1]) { + const destinationInputInput = await this.env.prompt({ + type: 'input', + name: 'destination', + message: `The destination to deploy the build artifact to the app store:`, + validate: v => combine(validators.required)(v), + }); + inputs[1] = destinationInputInput; + } + } + + async run( + inputs: CommandLineInputs, + options: CommandLineOptions + ): Promise { + if (!this.project) { + throw new FatalException( + `Cannot run ${input( + 'ionic package build' + )} outside a project directory.` + ); + } + + const token = this.env.session.getUserToken(); + const appflowId = await this.project.requireAppflowId(); + const [buildId, destination] = inputs; + + let build: + | DistributionBuild + | PackageBuild = await this.createDeploymentBuild( + appflowId, + token, + buildId, + destination + ); + const distBuildID = build.job_id; + + const details = columnar( + [ + ['App ID', strong(appflowId)], + ['Deployment ID', strong(build.binary_deployment.id.toString())], + ['Package Build ID', strong(buildId.toString())], + ['Destination', strong(build.distribution_credential_name)], + ], + { vsep: ':' } + ); + + this.env.log.ok(`Deployment initiated\n` + details + '\n\n'); + + build = await this.tailBuildLog(appflowId, distBuildID, token); + if (build.state !== 'success') { + throw new Error('Build failed'); + } + } + + async createDeploymentBuild( + appflowId: string, + token: string, + buildId: string, + destination: string + ): Promise { + const { req } = await this.env.client.make( + 'POST', + `/apps/${appflowId}/distributions/verbose_post` + ); + req.set('Authorization', `Bearer ${token}`).send({ + package_build_id: buildId, + distribution_credential_name: destination, + }); + + try { + const res = await this.env.client.do(req); + return res.data as DistributionBuild; + } catch (e) { + if (isSuperAgentError(e)) { + if (e.response.status === 401) { + this.env.log.error('Try logging out and back in again.'); + } + const apiErrorMessage = + e.response.body.error && e.response.body.error.message + ? e.response.body.error.message + : 'Api Error'; + throw new FatalException( + `Unable to create deployment build: ` + apiErrorMessage + ); + } else { + throw e; + } + } + } + + async tailBuildLog( + appflowId: string, + buildId: number, + token: string + ): Promise { + let build; + let start = 0; + const ws = this.env.log.createWriteStream(LOGGER_LEVELS.INFO, false); + + let isCreatedMessage = false; + while ( + !(build && (build.state === 'success' || build.state === 'failed')) + ) { + await sleep(5000); + build = await this.getGenericBuild(appflowId, buildId, token); + if (build && build.state === 'created' && !isCreatedMessage) { + ws.write( + chalk.yellow( + 'Concurrency limit reached: build will start as soon as other builds finish.' + ) + ); + isCreatedMessage = true; + } + const trace = build.distribution_trace; + if (trace && trace.length > start) { + ws.write(trace.substring(start)); + start = trace.length; + } + } + ws.end(); + + return build; + } + + async getGenericBuild( + appflowId: string, + buildId: number, + token: string + ): Promise { + const { req } = await this.env.client.make( + 'GET', + `/apps/${appflowId}/builds/${buildId}` + ); + req.set('Authorization', `Bearer ${token}`).send(); + + try { + const res = await this.env.client.do(req); + return res.data as PackageBuild; + } catch (e) { + if (isSuperAgentError(e)) { + if (e.response.status === 401) { + this.env.log.error('Try logging out and back in again.'); + } + const apiErrorMessage = + e.response.body.error && e.response.body.error.message + ? e.response.body.error.message + : 'Api Error'; + throw new FatalException( + `Unable to get build ${buildId}: ` + apiErrorMessage + ); + } else { + throw e; + } + } + } +} diff --git a/packages/@ionic/cli/src/commands/package/index.ts b/packages/@ionic/cli/src/commands/package/index.ts index fca17f33f1..9e756229d7 100644 --- a/packages/@ionic/cli/src/commands/package/index.ts +++ b/packages/@ionic/cli/src/commands/package/index.ts @@ -9,7 +9,7 @@ export class PackageNamespace extends Namespace { name: 'package', summary: 'Appflow package functionality', description: ` -Interface to execute commands about package builds on Ionic Appflow. +Interface to execute commands about package builds and deployments on Ionic Appflow. Appflow package documentation: - Overview: ${strong('https://ion.link/appflow-package-docs')} @@ -21,6 +21,7 @@ Appflow package documentation: async getCommands(): Promise { return new CommandMap([ ['build', async () => { const { BuildCommand } = await import('./build'); return new BuildCommand(this); }], + ['deploy', async () => { const { DeployCommand } = await import('./deploy'); return new DeployCommand(this); }], ]); } }