@capawesome/cli 4.18.3 → 4.19.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,425 @@
1
+ import { getMessageFromUnknownError, UserError } from '../utils/error.js';
2
+ import { parseGitRemoteUrl } from '../utils/git.js';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { z } from 'zod';
6
+ const SUPPORTED_MANIFEST_MAJOR_VERSION = 'v1';
7
+ const WEBHOOKS_DOCS_URL = 'https://capawesome.io/docs/cloud/webhooks/';
8
+ const LIVE_UPDATES_DOCS_URL = 'https://capawesome.io/docs/cloud/live-updates/';
9
+ const SUPPORTED_BUILD_TYPES = ['ad-hoc', 'app-store', 'debug', 'development', 'enterprise', 'release', 'simulator'];
10
+ const manifestSchema = z.object({
11
+ version: z.string().nullish(),
12
+ });
13
+ const appDetailSchema = z.object({
14
+ id: z.string(),
15
+ name: z.string(),
16
+ appType: z.string(),
17
+ latestBuildNumber: z.number().nullish(),
18
+ });
19
+ const repoAssociationSchema = z.union([
20
+ z.object({
21
+ gitProvider: z.string(),
22
+ cloneUrl: z.string(),
23
+ }),
24
+ z.array(z.unknown()),
25
+ ]);
26
+ const environmentsSchema = z.array(z.object({
27
+ id: z.number(),
28
+ name: z.string(),
29
+ vars: z.record(z.string(), z.string()).nullish(),
30
+ secrets: z.record(z.string(), z.string()).nullish(),
31
+ }));
32
+ const liveUpdateChannelsSchema = z.array(z.object({
33
+ id: z.string(),
34
+ name: z.string(),
35
+ }));
36
+ const nativeConfigsSchema = z.array(z.object({
37
+ id: z.number(),
38
+ name: z.string(),
39
+ configs: z
40
+ .object({
41
+ base: z
42
+ .object({
43
+ name: z.string().nullish(),
44
+ bundle_id: z.string().nullish(),
45
+ })
46
+ .nullish(),
47
+ ionic: z.record(z.string(), z.unknown()).nullish(),
48
+ })
49
+ .nullish(),
50
+ }));
51
+ const nativeBuildAutomationsSchema = z.array(z.object({
52
+ name: z.string(),
53
+ gitBranch: z.string(),
54
+ platform: z.enum(['android', 'ios']),
55
+ buildType: z.string().nullish(),
56
+ environmentId: z.number().nullish(),
57
+ webhook: z.string().nullish(),
58
+ automationEnabled: z.boolean(),
59
+ nativeConfigId: z.number().nullish(),
60
+ signingCertificateId: z.number().nullish(),
61
+ destinationId: z.number().nullish(),
62
+ }));
63
+ const webBuildAutomationsSchema = z.array(z.object({
64
+ name: z.string(),
65
+ gitBranch: z.string(),
66
+ environmentId: z.number().nullish(),
67
+ webhook: z.string().nullish(),
68
+ automationEnabled: z.boolean(),
69
+ channelIds: z.array(z.string()).nullish(),
70
+ webPreviewEnabled: z.boolean().nullish(),
71
+ }));
72
+ const androidSigningCertificateSchema = z.object({
73
+ id: z.number(),
74
+ name: z.string(),
75
+ keystoreFile: z.string(),
76
+ keystorePassword: z.string(),
77
+ keyAlias: z.string(),
78
+ keyPassword: z.string(),
79
+ });
80
+ const iosSigningCertificateSchema = z.object({
81
+ id: z.number(),
82
+ name: z.string(),
83
+ p12File: z.string(),
84
+ p12Password: z.string(),
85
+ provisioningProfiles: z.array(z.string()).nullish(),
86
+ });
87
+ const playStoreDestinationSchema = z.object({
88
+ id: z.number(),
89
+ name: z.string(),
90
+ artifactType: z.string().nullish(),
91
+ packageName: z.string().nullish(),
92
+ track: z.string().nullish(),
93
+ releaseStatus: z.string().nullish(),
94
+ });
95
+ const appStoreDestinationSchema = z.object({
96
+ id: z.number(),
97
+ name: z.string(),
98
+ appleId: z.string().nullish(),
99
+ appAppleId: z.string().nullish(),
100
+ appPassword: z.string().nullish(),
101
+ teamId: z.string().nullish(),
102
+ });
103
+ export const parseAppflowExport = async (directory) => {
104
+ const appsDirectory = path.join(directory, 'apps');
105
+ if (!fs.existsSync(appsDirectory) || !fs.statSync(appsDirectory).isDirectory()) {
106
+ throw new UserError('The provided file does not look like an Ionic Appflow export. It must contain an `apps` directory.');
107
+ }
108
+ const apps = [];
109
+ const skippedApps = [];
110
+ const warnings = [];
111
+ const manifest = parseJsonFileIfExists(path.join(directory, 'manifest.json'), manifestSchema);
112
+ const majorVersion = manifest?.version?.split('.')[0];
113
+ if (majorVersion && majorVersion !== SUPPORTED_MANIFEST_MAJOR_VERSION) {
114
+ warnings.push(`The export has the format version \`${manifest?.version}\` which is not yet supported by this CLI version. The import may be incomplete. Please update the CLI to the latest version.`);
115
+ }
116
+ const appFolders = fs
117
+ .readdirSync(appsDirectory, { withFileTypes: true })
118
+ .filter((entry) => entry.isDirectory())
119
+ .map((entry) => path.join(appsDirectory, entry.name));
120
+ for (const appFolder of appFolders) {
121
+ try {
122
+ const detail = parseJsonFile(path.join(appFolder, 'app-detail.json'), appDetailSchema);
123
+ const type = mapAppType(detail.appType);
124
+ if (!type) {
125
+ skippedApps.push({
126
+ sourceId: detail.id,
127
+ sourceName: detail.name,
128
+ reason: `App type \`${detail.appType}\` is not yet supported.`,
129
+ retryLater: detail.appType === 'react_native' || detail.appType === 'flutter',
130
+ });
131
+ continue;
132
+ }
133
+ apps.push(parseApp(appFolder, detail, type));
134
+ }
135
+ catch (error) {
136
+ skippedApps.push({
137
+ sourceId: '',
138
+ sourceName: path.basename(appFolder),
139
+ reason: getMessageFromUnknownError(error),
140
+ retryLater: false,
141
+ });
142
+ }
143
+ }
144
+ return { apps, skippedApps, warnings };
145
+ };
146
+ const mapAppType = (appType) => {
147
+ switch (appType) {
148
+ case 'android':
149
+ case 'capacitor':
150
+ case 'cordova':
151
+ case 'ios':
152
+ return appType;
153
+ case 'ionic':
154
+ // Placeholder only: `ionic` can mean Capacitor or Cordova. The import command
155
+ // resolves the actual type based on the `sourceAppType` before importing.
156
+ return 'capacitor';
157
+ default:
158
+ return undefined;
159
+ }
160
+ };
161
+ const parseApp = (appFolder, detail, type) => {
162
+ const notes = [];
163
+ const environments = parseJsonFileIfExists(path.join(appFolder, 'environments.json'), environmentsSchema) ?? [];
164
+ const channels = parseJsonFileIfExists(path.join(appFolder, 'live-update-channels.json'), liveUpdateChannelsSchema) ?? [];
165
+ const nativeConfigs = parseJsonFileIfExists(path.join(appFolder, 'native-configs.json'), nativeConfigsSchema) ?? [];
166
+ const certificates = parseCertificates(appFolder, notes);
167
+ const destinations = parseDestinations(appFolder);
168
+ return {
169
+ sourceId: detail.id,
170
+ sourceName: detail.name,
171
+ sourceAppType: detail.appType,
172
+ name: detail.name,
173
+ type,
174
+ latestBuildNumber: detail.latestBuildNumber ?? undefined,
175
+ notes,
176
+ automations: parseAutomations(appFolder, {
177
+ certificateNamesById: toNameMap(certificates.map((certificate) => ({ id: certificate.id, name: certificate.certificate.name }))),
178
+ channelNamesById: new Map(channels.map((channel) => [channel.id, channel.name])),
179
+ configurationNamesById: toNameMap(nativeConfigs),
180
+ destinationNamesById: toNameMap(destinations.map((destination) => ({ id: destination.id, name: destination.destination.name }))),
181
+ environmentNamesById: toNameMap(environments),
182
+ }, notes),
183
+ certificates: certificates.map((certificate) => certificate.certificate),
184
+ channels: channels.map((channel) => channel.name),
185
+ configurations: parseConfigurations(nativeConfigs, notes),
186
+ destinations: destinations.map((destination) => destination.destination),
187
+ environments: environments.map((environment) => ({
188
+ name: environment.name,
189
+ variables: Object.entries(environment.vars ?? {}).map(([key, value]) => ({ key, value })),
190
+ secrets: Object.entries(environment.secrets ?? {}).map(([key, value]) => ({ key, value })),
191
+ })),
192
+ repository: parseRepository(appFolder, notes),
193
+ };
194
+ };
195
+ const parseRepository = (appFolder, notes) => {
196
+ const repoAssociation = parseJsonFileIfExists(path.join(appFolder, 'repo-association.json'), repoAssociationSchema);
197
+ if (!repoAssociation || Array.isArray(repoAssociation)) {
198
+ return null;
199
+ }
200
+ try {
201
+ return parseGitRemoteUrl(repoAssociation.cloneUrl);
202
+ }
203
+ catch {
204
+ notes.push(`The git repository \`${repoAssociation.cloneUrl}\` (provider \`${repoAssociation.gitProvider}\`) is not supported and was not linked. You can link a repository manually in the Capawesome Cloud Console.`);
205
+ return null;
206
+ }
207
+ };
208
+ const parseConfigurations = (nativeConfigs, notes) => {
209
+ const configurationsWithLiveUpdateSettings = nativeConfigs.filter((config) => config.configs?.ionic);
210
+ if (configurationsWithLiveUpdateSettings.length > 0) {
211
+ notes.push(`The native configurations ${configurationsWithLiveUpdateSettings.map((config) => `\`${config.name}\``).join(', ')} contain Live Update plugin settings which are not part of configurations in Capawesome Cloud. Configure the Live Update plugin in your app instead (see ${LIVE_UPDATES_DOCS_URL}).`);
212
+ }
213
+ return nativeConfigs.map((config) => ({
214
+ name: config.name,
215
+ displayName: config.configs?.base?.name ?? undefined,
216
+ packageName: config.configs?.base?.bundle_id ?? undefined,
217
+ }));
218
+ };
219
+ const parseCertificates = (appFolder, notes) => {
220
+ const certificates = [];
221
+ for (const folder of getSubfolders(path.join(appFolder, 'signing-certificates', 'android'))) {
222
+ const metadata = parseJsonFile(path.join(folder, 'android-signing-certificate.json'), androidSigningCertificateSchema);
223
+ const filePath = resolvePathWithinFolder(folder, metadata.keystoreFile);
224
+ if (!filePath) {
225
+ notes.push(`The signing certificate \`${metadata.name}\` was skipped because the file path \`${metadata.keystoreFile}\` is invalid.`);
226
+ continue;
227
+ }
228
+ if (!fs.existsSync(filePath)) {
229
+ notes.push(`The signing certificate \`${metadata.name}\` was skipped because the file \`${metadata.keystoreFile}\` is missing in the export.`);
230
+ continue;
231
+ }
232
+ certificates.push({
233
+ id: metadata.id,
234
+ certificate: {
235
+ name: metadata.name,
236
+ platform: 'android',
237
+ filePath,
238
+ password: metadata.keystorePassword,
239
+ keyAlias: metadata.keyAlias,
240
+ keyPassword: metadata.keyPassword,
241
+ provisioningProfilePaths: [],
242
+ },
243
+ });
244
+ }
245
+ for (const folder of getSubfolders(path.join(appFolder, 'signing-certificates', 'ios'))) {
246
+ const metadata = parseJsonFile(path.join(folder, 'ios-signing-certificate.json'), iosSigningCertificateSchema);
247
+ const filePath = resolvePathWithinFolder(folder, metadata.p12File);
248
+ if (!filePath) {
249
+ notes.push(`The signing certificate \`${metadata.name}\` was skipped because the file path \`${metadata.p12File}\` is invalid.`);
250
+ continue;
251
+ }
252
+ if (!fs.existsSync(filePath)) {
253
+ notes.push(`The signing certificate \`${metadata.name}\` was skipped because the file \`${metadata.p12File}\` is missing in the export.`);
254
+ continue;
255
+ }
256
+ const provisioningProfilePaths = [];
257
+ for (const profileFile of new Set(metadata.provisioningProfiles ?? [])) {
258
+ const profilePath = resolvePathWithinFolder(folder, profileFile);
259
+ if (!profilePath) {
260
+ notes.push(`The provisioning profile \`${profileFile}\` of the signing certificate \`${metadata.name}\` has an invalid file path and was skipped.`);
261
+ continue;
262
+ }
263
+ if (!fs.existsSync(profilePath)) {
264
+ notes.push(`The provisioning profile \`${profileFile}\` of the signing certificate \`${metadata.name}\` is missing in the export and was skipped.`);
265
+ continue;
266
+ }
267
+ provisioningProfilePaths.push(profilePath);
268
+ }
269
+ certificates.push({
270
+ id: metadata.id,
271
+ certificate: {
272
+ name: metadata.name,
273
+ platform: 'ios',
274
+ filePath,
275
+ password: metadata.p12Password,
276
+ provisioningProfilePaths,
277
+ },
278
+ });
279
+ }
280
+ return certificates;
281
+ };
282
+ const parseDestinations = (appFolder) => {
283
+ const destinations = [];
284
+ for (const folder of getSubfolders(path.join(appFolder, 'store-destinations', 'android'))) {
285
+ const metadata = parseJsonFile(path.join(folder, 'play-store-destination.json'), playStoreDestinationSchema);
286
+ const googleServiceAccountKeyPath = path.join(folder, 'json-key.json');
287
+ destinations.push({
288
+ id: metadata.id,
289
+ destination: {
290
+ name: metadata.name,
291
+ platform: 'android',
292
+ androidPackageName: metadata.packageName ?? undefined,
293
+ androidBuildArtifactType: metadata.artifactType === 'aab' || metadata.artifactType === 'apk' ? metadata.artifactType : undefined,
294
+ androidReleaseStatus: metadata.releaseStatus === 'completed' || metadata.releaseStatus === 'draft'
295
+ ? metadata.releaseStatus
296
+ : undefined,
297
+ googlePlayTrack: metadata.track ?? undefined,
298
+ googleServiceAccountKeyPath: fs.existsSync(googleServiceAccountKeyPath)
299
+ ? googleServiceAccountKeyPath
300
+ : undefined,
301
+ },
302
+ });
303
+ }
304
+ for (const folder of getSubfolders(path.join(appFolder, 'store-destinations', 'ios'))) {
305
+ const metadata = parseJsonFile(path.join(folder, 'app-store-destination.json'), appStoreDestinationSchema);
306
+ destinations.push({
307
+ id: metadata.id,
308
+ destination: {
309
+ name: metadata.name,
310
+ platform: 'ios',
311
+ appleId: metadata.appleId ?? undefined,
312
+ appleAppId: metadata.appAppleId ?? undefined,
313
+ appleTeamId: metadata.teamId ?? undefined,
314
+ appleAppPassword: metadata.appPassword ?? undefined,
315
+ },
316
+ });
317
+ }
318
+ return destinations;
319
+ };
320
+ const parseAutomations = (appFolder, references, notes) => {
321
+ const automations = [];
322
+ const nativeAutomations = parseJsonFileIfExists(path.join(appFolder, 'native-build-automations.json'), nativeBuildAutomationsSchema) ?? [];
323
+ const webAutomations = parseJsonFileIfExists(path.join(appFolder, 'web-build-automations.json'), webBuildAutomationsSchema) ?? [];
324
+ const automationsWithWebhook = [...nativeAutomations, ...webAutomations].filter((automation) => automation.webhook);
325
+ if (automationsWithWebhook.length > 0) {
326
+ notes.push(`The webhooks of the automations ${automationsWithWebhook.map((automation) => `\`${automation.name}\``).join(', ')} were not imported because Capawesome Cloud supports webhooks at the app level instead. Please add them manually (see ${WEBHOOKS_DOCS_URL}).`);
327
+ }
328
+ for (const automation of nativeAutomations) {
329
+ let buildType = automation.buildType ?? undefined;
330
+ if (buildType && !SUPPORTED_BUILD_TYPES.includes(buildType)) {
331
+ notes.push(`The automation \`${automation.name}\` has the unsupported build type \`${buildType}\` which was skipped.`);
332
+ buildType = undefined;
333
+ }
334
+ automations.push({
335
+ name: automation.name,
336
+ platform: automation.platform,
337
+ triggerPattern: automation.gitBranch,
338
+ buildType,
339
+ enabled: automation.automationEnabled,
340
+ appCertificateName: resolveName(references.certificateNamesById, automation.signingCertificateId, automation.name, 'signing certificate', notes),
341
+ appConfigurationName: resolveName(references.configurationNamesById, automation.nativeConfigId, automation.name, 'native configuration', notes),
342
+ appDestinationName: resolveName(references.destinationNamesById, automation.destinationId, automation.name, 'destination', notes),
343
+ appEnvironmentName: resolveName(references.environmentNamesById, automation.environmentId, automation.name, 'environment', notes),
344
+ });
345
+ }
346
+ for (const automation of webAutomations) {
347
+ if (automation.webPreviewEnabled) {
348
+ notes.push(`The automation \`${automation.name}\` has web previews enabled which are not supported by Capawesome Cloud.`);
349
+ }
350
+ const channelNames = (automation.channelIds ?? [])
351
+ .map((channelId) => resolveName(references.channelNamesById, channelId, automation.name, 'channel', notes))
352
+ .filter((name) => !!name);
353
+ const appEnvironmentName = resolveName(references.environmentNamesById, automation.environmentId, automation.name, 'environment', notes);
354
+ if (channelNames.length === 0) {
355
+ automations.push({
356
+ name: automation.name,
357
+ platform: 'web',
358
+ triggerPattern: automation.gitBranch,
359
+ enabled: automation.automationEnabled,
360
+ appEnvironmentName,
361
+ });
362
+ continue;
363
+ }
364
+ for (const channelName of channelNames) {
365
+ automations.push({
366
+ name: channelNames.length > 1 ? `${automation.name} (${channelName})` : automation.name,
367
+ platform: 'web',
368
+ triggerPattern: automation.gitBranch,
369
+ enabled: automation.automationEnabled,
370
+ appChannelName: channelName,
371
+ appEnvironmentName,
372
+ });
373
+ }
374
+ }
375
+ return automations;
376
+ };
377
+ const resolveName = (namesById, id, automationName, label, notes) => {
378
+ if (id === null || id === undefined) {
379
+ return undefined;
380
+ }
381
+ const name = namesById.get(id);
382
+ if (!name) {
383
+ notes.push(`The automation \`${automationName}\` references an unknown ${label} which was skipped.`);
384
+ return undefined;
385
+ }
386
+ return name;
387
+ };
388
+ const toNameMap = (entities) => {
389
+ return new Map(entities.map((entity) => [entity.id, entity.name]));
390
+ };
391
+ // Guards against path traversal: the file names come from the export's JSON and
392
+ // must never resolve outside the folder they were extracted to.
393
+ const resolvePathWithinFolder = (folder, fileName) => {
394
+ const filePath = path.resolve(folder, fileName);
395
+ return filePath.startsWith(folder + path.sep) ? filePath : undefined;
396
+ };
397
+ const getSubfolders = (directory) => {
398
+ if (!fs.existsSync(directory)) {
399
+ return [];
400
+ }
401
+ return fs
402
+ .readdirSync(directory, { withFileTypes: true })
403
+ .filter((entry) => entry.isDirectory())
404
+ .map((entry) => path.join(directory, entry.name));
405
+ };
406
+ const parseJsonFile = (filePath, schema) => {
407
+ let json;
408
+ try {
409
+ json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
410
+ }
411
+ catch {
412
+ throw new UserError(`The export file \`${path.basename(filePath)}\` is missing or contains invalid JSON.`);
413
+ }
414
+ const result = schema.safeParse(json);
415
+ if (!result.success) {
416
+ throw new UserError(`The export file \`${path.basename(filePath)}\` has an unexpected format.`);
417
+ }
418
+ return result.data;
419
+ };
420
+ const parseJsonFileIfExists = (filePath, schema) => {
421
+ if (!fs.existsSync(filePath)) {
422
+ return undefined;
423
+ }
424
+ return parseJsonFile(filePath, schema);
425
+ };