@capawesome/cli 4.18.4 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,523 @@
1
+ import { DEFAULT_CONSOLE_BASE_URL } from '../../config/consts.js';
2
+ import appAutomationsService from '../../services/app-automations.js';
3
+ import appCertificatesService from '../../services/app-certificates.js';
4
+ import appChannelsService from '../../services/app-channels.js';
5
+ import appConfigurationsService from '../../services/app-configurations.js';
6
+ import appDestinationsService from '../../services/app-destinations.js';
7
+ import appEnvironmentsService from '../../services/app-environments.js';
8
+ import appGoogleServiceAccountKeysService from '../../services/app-google-service-account-keys.js';
9
+ import appProvisioningProfilesService from '../../services/app-provisioning-profiles.js';
10
+ import appsService from '../../services/apps.js';
11
+ import gitConnectionsService from '../../services/git-connections.js';
12
+ import { generateUniqueName, isNameTaken } from '../../utils/app-import.js';
13
+ import { parseAppflowExport } from '../../utils/appflow-export.js';
14
+ import { withAuth } from '../../utils/auth.js';
15
+ import { isInteractive } from '../../utils/environment.js';
16
+ import { getMessageFromUnknownError, UserError } from '../../utils/error.js';
17
+ import { isReadable } from '../../utils/file.js';
18
+ import { prompt, promptOrganizationSelection } from '../../utils/prompt.js';
19
+ import zip from '../../utils/zip.js';
20
+ import { defineCommand, defineOptions } from 'zodline';
21
+ import consola from 'consola';
22
+ import fs from 'fs';
23
+ import os from 'os';
24
+ import path from 'path';
25
+ import { z } from 'zod';
26
+ const SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM'];
27
+ // Maps the providers of `parseGitRemoteUrl` to the providers of git connections.
28
+ const GIT_CONNECTION_PROVIDERS = { azure: 'azure_devops' };
29
+ export default defineCommand({
30
+ description: 'Import apps from an Ionic Appflow export.',
31
+ options: defineOptions(z.object({
32
+ dryRun: z.boolean().optional().describe('Preview the import without creating any resources.'),
33
+ file: z.string().optional().describe('Path to the export file (.zip).'),
34
+ include: z
35
+ .array(z.string())
36
+ .optional()
37
+ .describe('Filter which apps to import by name or ID. Can be specified multiple times or comma-separated. Defaults to all apps.'),
38
+ ionicAppType: z
39
+ .enum(['capacitor', 'cordova'], {
40
+ message: 'Ionic app type must be either `capacitor` or `cordova`.',
41
+ })
42
+ .optional()
43
+ .describe('The actual app type of apps exported with the ambiguous app type `ionic`. Prompted for each app if not provided.'),
44
+ json: z.boolean().optional().describe('Output in JSON format.'),
45
+ organizationId: z.string().optional().describe('ID of the organization to import the apps into.'),
46
+ })),
47
+ action: withAuth(async (options, args) => {
48
+ let { dryRun, file, include, ionicAppType, json, organizationId } = options;
49
+ if (!file) {
50
+ if (!isInteractive()) {
51
+ consola.error('You must provide the export file path when running in non-interactive environment.');
52
+ process.exit(1);
53
+ }
54
+ file = await prompt('Enter the path to the export file (.zip):', { type: 'text' });
55
+ if (!file) {
56
+ consola.error('You must provide an export file path.');
57
+ process.exit(1);
58
+ }
59
+ }
60
+ if (!zip.isZipped(file)) {
61
+ consola.error('The export file must be a `.zip` file.');
62
+ process.exit(1);
63
+ }
64
+ const fileReadable = await isReadable(file);
65
+ if (!fileReadable) {
66
+ consola.error(`The export file does not exist or is not accessible: ${file}`);
67
+ process.exit(1);
68
+ }
69
+ if (!organizationId) {
70
+ if (!isInteractive()) {
71
+ consola.error('You must provide an organization ID when running in non-interactive environment.');
72
+ process.exit(1);
73
+ }
74
+ organizationId = await promptOrganizationSelection({
75
+ message: 'Which organization do you want to import the apps into?',
76
+ });
77
+ }
78
+ const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'capawesome-appflow-import-'));
79
+ // The extracted export contains secrets, so it must also be removed when the
80
+ // process exits via `process.exit` (e.g. a cancelled prompt) or a signal,
81
+ // which would skip the `finally` block.
82
+ const removeTempDirectory = () => fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 3 });
83
+ const handleSignal = (signal) => {
84
+ removeTempDirectory();
85
+ // Re-raise the signal to keep the default exit code (128 + signal number).
86
+ process.kill(process.pid, signal);
87
+ };
88
+ process.once('exit', removeTempDirectory);
89
+ for (const signal of SIGNALS) {
90
+ process.once(signal, handleSignal);
91
+ }
92
+ try {
93
+ await zip.unzipToFolder(fs.readFileSync(file), tempDirectory);
94
+ const { apps, skippedApps, warnings } = await parseAppflowExport(tempDirectory);
95
+ for (const warning of warnings) {
96
+ consola.warn(warning);
97
+ }
98
+ const { selectedApps, selectedSkippedApps } = await selectApps(apps, skippedApps, include);
99
+ if (selectedApps.length === 0 && selectedSkippedApps.length === 0) {
100
+ throw new UserError('No apps found in the export that match the provided filters.');
101
+ }
102
+ const { resolvedApps, skippedApps: unresolvedApps } = await resolveIonicAppTypes(selectedApps, ionicAppType);
103
+ selectedSkippedApps.push(...unresolvedApps);
104
+ await assignUniqueAppNames(resolvedApps, organizationId);
105
+ const unconnectedGitProviders = await findUnconnectedGitProviders(resolvedApps, organizationId);
106
+ for (const provider of unconnectedGitProviders) {
107
+ const appCount = resolvedApps.filter((app) => app.repository?.provider === provider).length;
108
+ consola.warn(`The organization has no \`${provider}\` git connection, so the repositories of ${appCount} app(s) will not be linked. Connect the git provider at ${DEFAULT_CONSOLE_BASE_URL}/organizations/${organizationId}/git before running the import to link them automatically, or link the repositories manually in the Capawesome Cloud Console afterwards.`);
109
+ }
110
+ const outcomes = [];
111
+ for (const app of resolvedApps) {
112
+ const outcome = {
113
+ app,
114
+ errors: [],
115
+ created: {
116
+ automations: 0,
117
+ certificates: 0,
118
+ channels: 0,
119
+ configurations: 0,
120
+ destinations: 0,
121
+ environments: 0,
122
+ },
123
+ };
124
+ outcomes.push(outcome);
125
+ if (dryRun) {
126
+ continue;
127
+ }
128
+ await importApp(organizationId, outcome, unconnectedGitProviders);
129
+ }
130
+ const errorCount = outcomes.reduce((count, outcome) => count + outcome.errors.length, 0);
131
+ if (json) {
132
+ printJsonSummary(outcomes, selectedSkippedApps, warnings, dryRun === true);
133
+ }
134
+ else {
135
+ printSummary(outcomes, selectedSkippedApps, dryRun === true, file);
136
+ }
137
+ if (errorCount > 0) {
138
+ process.exitCode = 1;
139
+ }
140
+ }
141
+ finally {
142
+ removeTempDirectory();
143
+ process.off('exit', removeTempDirectory);
144
+ for (const signal of SIGNALS) {
145
+ process.off(signal, handleSignal);
146
+ }
147
+ }
148
+ }),
149
+ });
150
+ const selectApps = async (apps, skippedApps, include) => {
151
+ const filters = include
152
+ ?.flatMap((value) => value.split(','))
153
+ .map((value) => value.trim())
154
+ .filter((value) => value.length > 0);
155
+ if (filters && filters.length > 0) {
156
+ const matchesFilter = (sourceId, sourceName) => filters.some((filter) => filter === sourceId || filter === sourceName);
157
+ for (const filter of filters) {
158
+ const matched = apps.some((app) => app.sourceId === filter || app.sourceName === filter) ||
159
+ skippedApps.some((app) => app.sourceId === filter || app.sourceName === filter);
160
+ if (!matched) {
161
+ consola.warn(`No app in the export matches the filter \`${filter}\`.`);
162
+ }
163
+ }
164
+ return {
165
+ selectedApps: apps.filter((app) => matchesFilter(app.sourceId, app.sourceName)),
166
+ selectedSkippedApps: skippedApps.filter((app) => matchesFilter(app.sourceId, app.sourceName)),
167
+ };
168
+ }
169
+ if (isInteractive() && apps.length > 1) {
170
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
171
+ const selectedIds = await prompt('Which apps do you want to import?', {
172
+ type: 'multiselect',
173
+ options: apps.map((app) => ({ label: `${app.sourceName} (${app.sourceId})`, value: app.sourceId })),
174
+ initial: apps.map((app) => app.sourceId),
175
+ });
176
+ return {
177
+ selectedApps: apps.filter((app) => selectedIds.includes(app.sourceId)),
178
+ selectedSkippedApps: skippedApps,
179
+ };
180
+ }
181
+ return { selectedApps: apps, selectedSkippedApps: skippedApps };
182
+ };
183
+ const resolveIonicAppTypes = async (apps, ionicAppType) => {
184
+ const resolvedApps = [];
185
+ const skippedApps = [];
186
+ for (const app of apps) {
187
+ if (app.sourceAppType !== 'ionic') {
188
+ resolvedApps.push(app);
189
+ continue;
190
+ }
191
+ let type = ionicAppType;
192
+ if (!type && isInteractive()) {
193
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
194
+ type = await prompt(`The app \`${app.sourceName}\` was exported with the app type \`ionic\` which can mean Capacitor or Cordova. Which framework does the app use?`, {
195
+ type: 'select',
196
+ options: [
197
+ { label: 'Capacitor', value: 'capacitor' },
198
+ { label: 'Cordova', value: 'cordova' },
199
+ ],
200
+ });
201
+ }
202
+ if (!type) {
203
+ skippedApps.push({
204
+ sourceId: app.sourceId,
205
+ sourceName: app.sourceName,
206
+ reason: 'The app was exported with the ambiguous app type `ionic` which can mean Capacitor or Cordova. Provide the `--ionic-app-type` option or run the import interactively.',
207
+ retryLater: false,
208
+ });
209
+ continue;
210
+ }
211
+ app.type = type;
212
+ app.notes.push(`The app type \`ionic\` was resolved to \`${type}\`.`);
213
+ resolvedApps.push(app);
214
+ }
215
+ return { resolvedApps, skippedApps };
216
+ };
217
+ const assignUniqueAppNames = async (apps, organizationId) => {
218
+ const takenNames = [];
219
+ const limit = 50;
220
+ let offset = 0;
221
+ while (true) {
222
+ const page = await appsService.findAll({ organizationId, limit, offset });
223
+ takenNames.push(...page.map((app) => app.name));
224
+ if (page.length < limit) {
225
+ break;
226
+ }
227
+ offset += limit;
228
+ }
229
+ for (const app of apps) {
230
+ app.name = generateUniqueName(app.sourceName, takenNames);
231
+ takenNames.push(app.name);
232
+ if (app.name !== app.sourceName) {
233
+ app.renames.push(`The app \`${app.sourceName}\` was renamed to \`${app.name}\` because its name is already taken in the organization.`);
234
+ }
235
+ }
236
+ };
237
+ const findUnconnectedGitProviders = async (apps, organizationId) => {
238
+ const providers = new Set(apps.flatMap((app) => (app.repository ? [app.repository.provider] : [])));
239
+ const unconnectedProviders = new Set();
240
+ for (const provider of providers) {
241
+ const gitConnections = await gitConnectionsService.findAll({
242
+ organizationId,
243
+ provider: GIT_CONNECTION_PROVIDERS[provider] ?? provider,
244
+ limit: 1,
245
+ });
246
+ if (gitConnections.length === 0) {
247
+ unconnectedProviders.add(provider);
248
+ }
249
+ }
250
+ return unconnectedProviders;
251
+ };
252
+ const importApp = async (organizationId, outcome, unconnectedGitProviders) => {
253
+ const { app } = outcome;
254
+ consola.start(`Importing app \`${app.sourceName}\`...`);
255
+ let appId;
256
+ try {
257
+ const createdApp = await appsService.create({ name: app.name, organizationId, type: app.type });
258
+ appId = createdApp.id;
259
+ outcome.id = appId;
260
+ consola.success(`Created app \`${app.name}\`.`);
261
+ }
262
+ catch (error) {
263
+ outcome.errors.push(`Failed to create app: ${getMessageFromUnknownError(error)}`);
264
+ consola.error(`Failed to create app \`${app.name}\`.`);
265
+ return;
266
+ }
267
+ if (app.latestBuildNumber && app.latestBuildNumber > 0) {
268
+ const nextAppBuildNumber = app.latestBuildNumber + 1;
269
+ try {
270
+ await appsService.update({ appId, nextAppBuildNumber });
271
+ app.notes.push(`The next build number was set to \`${nextAppBuildNumber}\` to continue after the latest build number \`${app.latestBuildNumber}\`.`);
272
+ consola.success(`Set the next build number to ${nextAppBuildNumber}.`);
273
+ }
274
+ catch (error) {
275
+ outcome.errors.push(`Failed to set the next build number: ${getMessageFromUnknownError(error)}`);
276
+ }
277
+ }
278
+ for (const certificate of app.certificates) {
279
+ try {
280
+ const provisioningProfileIds = [];
281
+ for (const profilePath of certificate.provisioningProfilePaths) {
282
+ const profile = await appProvisioningProfilesService.create({
283
+ appId,
284
+ buffer: fs.readFileSync(profilePath),
285
+ fileName: path.basename(profilePath),
286
+ });
287
+ provisioningProfileIds.push(profile.id);
288
+ }
289
+ const createdCertificate = await appCertificatesService.create({
290
+ appId,
291
+ buffer: fs.readFileSync(certificate.filePath),
292
+ fileName: path.basename(certificate.filePath),
293
+ name: certificate.name,
294
+ platform: certificate.platform,
295
+ password: certificate.password,
296
+ keyAlias: certificate.keyAlias,
297
+ keyPassword: certificate.keyPassword,
298
+ });
299
+ if (provisioningProfileIds.length > 0) {
300
+ await appProvisioningProfilesService.updateMany({
301
+ appId,
302
+ ids: provisioningProfileIds,
303
+ appCertificateId: createdCertificate.id,
304
+ });
305
+ }
306
+ outcome.created.certificates++;
307
+ consola.success(`Created certificate \`${certificate.name}\`.`);
308
+ }
309
+ catch (error) {
310
+ outcome.errors.push(`Failed to create certificate \`${certificate.name}\`: ${getMessageFromUnknownError(error)}`);
311
+ }
312
+ }
313
+ for (const environment of app.environments) {
314
+ try {
315
+ const createdEnvironment = await appEnvironmentsService.create({ appId, name: environment.name });
316
+ if (environment.variables.length > 0) {
317
+ await appEnvironmentsService.setVariables({
318
+ appId,
319
+ environmentId: createdEnvironment.id,
320
+ variables: environment.variables,
321
+ });
322
+ }
323
+ if (environment.secrets.length > 0) {
324
+ await appEnvironmentsService.setSecrets({
325
+ appId,
326
+ environmentId: createdEnvironment.id,
327
+ secrets: environment.secrets,
328
+ });
329
+ }
330
+ outcome.created.environments++;
331
+ consola.success(`Created environment \`${environment.name}\`.`);
332
+ }
333
+ catch (error) {
334
+ outcome.errors.push(`Failed to create environment \`${environment.name}\`: ${getMessageFromUnknownError(error)}`);
335
+ }
336
+ }
337
+ let existingChannelNames = [];
338
+ try {
339
+ const existingChannels = await appChannelsService.findAll({ appId });
340
+ existingChannelNames = existingChannels.map((channel) => channel.name);
341
+ }
342
+ catch {
343
+ // If the lookup fails, channel creation errors are reported individually below.
344
+ }
345
+ for (const channel of app.channels) {
346
+ if (isNameTaken(channel, existingChannelNames)) {
347
+ outcome.created.channels++;
348
+ continue;
349
+ }
350
+ try {
351
+ await appChannelsService.create({ appId, name: channel });
352
+ outcome.created.channels++;
353
+ consola.success(`Created channel \`${channel}\`.`);
354
+ }
355
+ catch (error) {
356
+ outcome.errors.push(`Failed to create channel \`${channel}\`: ${getMessageFromUnknownError(error)}`);
357
+ }
358
+ }
359
+ for (const configuration of app.configurations) {
360
+ try {
361
+ await appConfigurationsService.create({
362
+ appId,
363
+ name: configuration.name,
364
+ displayName: configuration.displayName,
365
+ packageName: configuration.packageName,
366
+ });
367
+ outcome.created.configurations++;
368
+ consola.success(`Created configuration \`${configuration.name}\`.`);
369
+ }
370
+ catch (error) {
371
+ outcome.errors.push(`Failed to create configuration \`${configuration.name}\`: ${getMessageFromUnknownError(error)}`);
372
+ }
373
+ }
374
+ for (const destination of app.destinations) {
375
+ try {
376
+ let appGoogleServiceAccountKeyId;
377
+ if (destination.googleServiceAccountKeyPath) {
378
+ const key = await appGoogleServiceAccountKeysService.create({
379
+ appId,
380
+ buffer: fs.readFileSync(destination.googleServiceAccountKeyPath),
381
+ fileName: path.basename(destination.googleServiceAccountKeyPath),
382
+ });
383
+ appGoogleServiceAccountKeyId = key.id;
384
+ }
385
+ await appDestinationsService.create({
386
+ appId,
387
+ name: destination.name,
388
+ platform: destination.platform,
389
+ androidPackageName: destination.androidPackageName,
390
+ androidBuildArtifactType: destination.androidBuildArtifactType,
391
+ androidReleaseStatus: destination.androidReleaseStatus,
392
+ googlePlayTrack: destination.googlePlayTrack,
393
+ appGoogleServiceAccountKeyId,
394
+ appleId: destination.appleId,
395
+ appleAppId: destination.appleAppId,
396
+ appleTeamId: destination.appleTeamId,
397
+ appleAppPassword: destination.appleAppPassword,
398
+ });
399
+ outcome.created.destinations++;
400
+ consola.success(`Created destination \`${destination.name}\`.`);
401
+ }
402
+ catch (error) {
403
+ outcome.errors.push(`Failed to create destination \`${destination.name}\`: ${getMessageFromUnknownError(error)}`);
404
+ }
405
+ }
406
+ for (const automation of app.automations) {
407
+ try {
408
+ await appAutomationsService.create({
409
+ appId,
410
+ name: automation.name,
411
+ platform: automation.platform,
412
+ triggerType: 'branch',
413
+ triggerPatterns: [automation.triggerPattern],
414
+ buildType: automation.buildType,
415
+ enabled: automation.enabled,
416
+ appCertificateName: automation.appCertificateName,
417
+ appChannelName: automation.appChannelName,
418
+ appConfigurationName: automation.appConfigurationName,
419
+ appDestinationName: automation.appDestinationName,
420
+ appEnvironmentName: automation.appEnvironmentName,
421
+ });
422
+ outcome.created.automations++;
423
+ consola.success(`Created automation \`${automation.name}\`.`);
424
+ }
425
+ catch (error) {
426
+ outcome.errors.push(`Failed to create automation \`${automation.name}\`: ${getMessageFromUnknownError(error)}`);
427
+ }
428
+ }
429
+ if (app.repository && unconnectedGitProviders.has(app.repository.provider)) {
430
+ app.notes.push(`The repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\` was not linked because the organization has no \`${app.repository.provider}\` git connection.`);
431
+ }
432
+ else if (app.repository) {
433
+ try {
434
+ await appsService.linkRepository({
435
+ appId,
436
+ ownerSlug: app.repository.ownerSlug,
437
+ provider: app.repository.provider,
438
+ repositorySlug: app.repository.repositorySlug,
439
+ projectSlug: app.repository.projectSlug,
440
+ });
441
+ consola.success(`Linked repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\`.`);
442
+ }
443
+ catch (error) {
444
+ app.notes.push(`The repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\` could not be linked: ${getMessageFromUnknownError(error)} Make sure the git provider is connected in the Capawesome Cloud Console and link the repository manually.`);
445
+ }
446
+ }
447
+ };
448
+ const printSummary = (outcomes, skippedApps, dryRun, file) => {
449
+ consola.log('');
450
+ if (dryRun) {
451
+ consola.info('Dry run. No resources were created.');
452
+ }
453
+ console.table(outcomes.map((outcome) => ({
454
+ App: outcome.app.sourceName,
455
+ 'Imported as': dryRun || outcome.id ? outcome.app.name : '—',
456
+ Certificates: formatCount(outcome.created.certificates, outcome.app.certificates.length, dryRun),
457
+ Environments: formatCount(outcome.created.environments, outcome.app.environments.length, dryRun),
458
+ Channels: formatCount(outcome.created.channels, outcome.app.channels.length, dryRun),
459
+ Configurations: formatCount(outcome.created.configurations, outcome.app.configurations.length, dryRun),
460
+ Destinations: formatCount(outcome.created.destinations, outcome.app.destinations.length, dryRun),
461
+ Automations: formatCount(outcome.created.automations, outcome.app.automations.length, dryRun),
462
+ Errors: outcome.errors.length,
463
+ })));
464
+ for (const outcome of outcomes) {
465
+ if (outcome.app.notes.length === 0 && outcome.app.renames.length === 0 && outcome.errors.length === 0) {
466
+ continue;
467
+ }
468
+ consola.log(`\n${outcome.app.sourceName}:`);
469
+ for (const note of [...outcome.app.notes, ...outcome.app.renames]) {
470
+ consola.warn(note);
471
+ }
472
+ for (const error of outcome.errors) {
473
+ consola.error(error);
474
+ }
475
+ }
476
+ if (outcomes.some((outcome) => outcome.app.renames.length > 0)) {
477
+ consola.info('Some resources were renamed because their names were already taken. Make sure to update any CI/CD workflows or scripts that reference the old names.');
478
+ }
479
+ for (const outcome of outcomes) {
480
+ if (outcome.id) {
481
+ consola.info(`App URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}`);
482
+ }
483
+ }
484
+ const retryLaterApps = skippedApps.filter((app) => app.retryLater);
485
+ const otherSkippedApps = skippedApps.filter((app) => !app.retryLater);
486
+ for (const app of otherSkippedApps) {
487
+ consola.warn(`The app \`${app.sourceName}\` was skipped: ${app.reason}`);
488
+ }
489
+ if (retryLaterApps.length > 0) {
490
+ const includeValues = retryLaterApps
491
+ .map((app) => (app.sourceName.includes(',') ? app.sourceId : app.sourceName))
492
+ .join(',');
493
+ consola.info(`The apps ${retryLaterApps.map((app) => `\`${app.sourceName}\``).join(', ')} were skipped because their app type is not yet supported. Support is coming soon. You can import the remaining apps later by running:\n\nnpx @capawesome/cli apps:import --file "${file}" --include "${includeValues}"`);
494
+ }
495
+ if (!dryRun) {
496
+ consola.success('Import completed.');
497
+ }
498
+ };
499
+ const formatCount = (created, total, dryRun) => {
500
+ return dryRun ? `${total}` : `${created}/${total}`;
501
+ };
502
+ const printJsonSummary = (outcomes, skippedApps, warnings, dryRun) => {
503
+ console.log(JSON.stringify({
504
+ dryRun,
505
+ warnings,
506
+ apps: outcomes.map((outcome) => ({
507
+ id: outcome.id ?? null,
508
+ name: outcome.app.name,
509
+ sourceId: outcome.app.sourceId,
510
+ sourceName: outcome.app.sourceName,
511
+ webUrl: outcome.id ? `${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}` : null,
512
+ created: outcome.created,
513
+ notes: outcome.app.notes,
514
+ renames: outcome.app.renames,
515
+ errors: outcome.errors,
516
+ })),
517
+ skippedApps: skippedApps.map((app) => ({
518
+ sourceId: app.sourceId,
519
+ sourceName: app.sourceName,
520
+ reason: app.reason,
521
+ })),
522
+ }, null, 2));
523
+ };