@capawesome/cli 4.17.3 → 4.18.0-dev.9810ff51.1785743282
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.
- package/CHANGELOG.md +9 -0
- package/dist/commands/apps/automations/create.js +141 -0
- package/dist/commands/apps/automations/create.test.js +162 -0
- package/dist/commands/apps/automations/delete.js +66 -0
- package/dist/commands/apps/automations/delete.test.js +127 -0
- package/dist/commands/apps/automations/get.js +62 -0
- package/dist/commands/apps/automations/get.test.js +118 -0
- package/dist/commands/apps/automations/list.js +46 -0
- package/dist/commands/apps/automations/list.test.js +92 -0
- package/dist/commands/apps/automations/update.js +118 -0
- package/dist/commands/apps/automations/update.test.js +124 -0
- package/dist/commands/apps/builds/create.js +31 -1
- package/dist/commands/apps/builds/create.test.js +15 -0
- package/dist/commands/apps/builds/run.js +265 -0
- package/dist/commands/apps/configurations/create.js +51 -0
- package/dist/commands/apps/configurations/create.test.js +120 -0
- package/dist/commands/apps/configurations/delete.js +61 -0
- package/dist/commands/apps/configurations/delete.test.js +112 -0
- package/dist/commands/apps/configurations/get.js +65 -0
- package/dist/commands/apps/configurations/get.test.js +119 -0
- package/dist/commands/apps/configurations/list.js +39 -0
- package/dist/commands/apps/configurations/list.test.js +94 -0
- package/dist/commands/apps/configurations/update.js +61 -0
- package/dist/commands/apps/configurations/update.test.js +122 -0
- package/dist/commands/apps/import.js +419 -0
- package/dist/commands/apps/import.test.js +308 -0
- package/dist/index.js +13 -0
- package/dist/services/app-automations.js +64 -0
- package/dist/services/app-configurations.js +77 -0
- package/dist/types/app-automation.js +1 -0
- package/dist/types/app-configuration.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/android-emulator.js +170 -0
- package/dist/utils/app-import.js +10 -0
- package/dist/utils/appflow-export.js +391 -0
- package/dist/utils/appflow-export.test.js +277 -0
- package/dist/utils/ios-simulator.js +57 -0
- package/dist/utils/zip.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,391 @@
|
|
|
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 WEBHOOKS_DOCS_URL = 'https://capawesome.io/docs/cloud/webhooks/';
|
|
7
|
+
const LIVE_UPDATES_DOCS_URL = 'https://capawesome.io/docs/cloud/live-updates/';
|
|
8
|
+
const SUPPORTED_BUILD_TYPES = ['ad-hoc', 'app-store', 'debug', 'development', 'enterprise', 'release', 'simulator'];
|
|
9
|
+
const appDetailSchema = z.object({
|
|
10
|
+
id: z.string(),
|
|
11
|
+
name: z.string(),
|
|
12
|
+
appType: z.string(),
|
|
13
|
+
});
|
|
14
|
+
const repoAssociationSchema = z.union([
|
|
15
|
+
z.object({
|
|
16
|
+
gitProvider: z.string(),
|
|
17
|
+
cloneUrl: z.string(),
|
|
18
|
+
}),
|
|
19
|
+
z.array(z.unknown()),
|
|
20
|
+
]);
|
|
21
|
+
const environmentsSchema = z.array(z.object({
|
|
22
|
+
id: z.number(),
|
|
23
|
+
name: z.string(),
|
|
24
|
+
vars: z.record(z.string(), z.string()).nullish(),
|
|
25
|
+
secrets: z.record(z.string(), z.string()).nullish(),
|
|
26
|
+
}));
|
|
27
|
+
const liveUpdateChannelsSchema = z.array(z.object({
|
|
28
|
+
id: z.string(),
|
|
29
|
+
name: z.string(),
|
|
30
|
+
}));
|
|
31
|
+
const nativeConfigsSchema = z.array(z.object({
|
|
32
|
+
id: z.number(),
|
|
33
|
+
name: z.string(),
|
|
34
|
+
configs: z
|
|
35
|
+
.object({
|
|
36
|
+
base: z
|
|
37
|
+
.object({
|
|
38
|
+
name: z.string().nullish(),
|
|
39
|
+
bundle_id: z.string().nullish(),
|
|
40
|
+
})
|
|
41
|
+
.nullish(),
|
|
42
|
+
ionic: z.record(z.string(), z.unknown()).nullish(),
|
|
43
|
+
})
|
|
44
|
+
.nullish(),
|
|
45
|
+
}));
|
|
46
|
+
const nativeBuildAutomationsSchema = z.array(z.object({
|
|
47
|
+
name: z.string(),
|
|
48
|
+
gitBranch: z.string(),
|
|
49
|
+
platform: z.enum(['android', 'ios']),
|
|
50
|
+
buildType: z.string().nullish(),
|
|
51
|
+
environmentId: z.number().nullish(),
|
|
52
|
+
webhook: z.string().nullish(),
|
|
53
|
+
automationEnabled: z.boolean(),
|
|
54
|
+
nativeConfigId: z.number().nullish(),
|
|
55
|
+
signingCertificateId: z.number().nullish(),
|
|
56
|
+
destinationId: z.number().nullish(),
|
|
57
|
+
}));
|
|
58
|
+
const webBuildAutomationsSchema = z.array(z.object({
|
|
59
|
+
name: z.string(),
|
|
60
|
+
gitBranch: z.string(),
|
|
61
|
+
environmentId: z.number().nullish(),
|
|
62
|
+
webhook: z.string().nullish(),
|
|
63
|
+
automationEnabled: z.boolean(),
|
|
64
|
+
channelIds: z.array(z.string()).nullish(),
|
|
65
|
+
webPreviewEnabled: z.boolean().nullish(),
|
|
66
|
+
}));
|
|
67
|
+
const androidSigningCertificateSchema = z.object({
|
|
68
|
+
id: z.number(),
|
|
69
|
+
name: z.string(),
|
|
70
|
+
keystoreFile: z.string(),
|
|
71
|
+
keystorePassword: z.string(),
|
|
72
|
+
keyAlias: z.string(),
|
|
73
|
+
keyPassword: z.string(),
|
|
74
|
+
});
|
|
75
|
+
const iosSigningCertificateSchema = z.object({
|
|
76
|
+
id: z.number(),
|
|
77
|
+
name: z.string(),
|
|
78
|
+
p12File: z.string(),
|
|
79
|
+
p12Password: z.string(),
|
|
80
|
+
provisioningProfiles: z.array(z.string()).nullish(),
|
|
81
|
+
});
|
|
82
|
+
const playStoreDestinationSchema = z.object({
|
|
83
|
+
id: z.number(),
|
|
84
|
+
name: z.string(),
|
|
85
|
+
artifactType: z.string().nullish(),
|
|
86
|
+
packageName: z.string().nullish(),
|
|
87
|
+
track: z.string().nullish(),
|
|
88
|
+
});
|
|
89
|
+
const appStoreDestinationSchema = z.object({
|
|
90
|
+
id: z.number(),
|
|
91
|
+
name: z.string(),
|
|
92
|
+
appleId: z.string().nullish(),
|
|
93
|
+
appAppleId: z.string().nullish(),
|
|
94
|
+
appPassword: z.string().nullish(),
|
|
95
|
+
teamId: z.string().nullish(),
|
|
96
|
+
});
|
|
97
|
+
export const parseAppflowExport = async (directory) => {
|
|
98
|
+
const appsDirectory = path.join(directory, 'apps');
|
|
99
|
+
if (!fs.existsSync(appsDirectory) || !fs.statSync(appsDirectory).isDirectory()) {
|
|
100
|
+
throw new UserError('The provided file does not look like an Ionic Appflow export. It must contain an `apps` directory.');
|
|
101
|
+
}
|
|
102
|
+
const apps = [];
|
|
103
|
+
const skippedApps = [];
|
|
104
|
+
const appFolders = fs
|
|
105
|
+
.readdirSync(appsDirectory, { withFileTypes: true })
|
|
106
|
+
.filter((entry) => entry.isDirectory())
|
|
107
|
+
.map((entry) => path.join(appsDirectory, entry.name));
|
|
108
|
+
for (const appFolder of appFolders) {
|
|
109
|
+
try {
|
|
110
|
+
const detail = parseJsonFile(path.join(appFolder, 'app-detail.json'), appDetailSchema);
|
|
111
|
+
const type = mapAppType(detail.appType);
|
|
112
|
+
if (!type) {
|
|
113
|
+
skippedApps.push({
|
|
114
|
+
sourceId: detail.id,
|
|
115
|
+
sourceName: detail.name,
|
|
116
|
+
reason: `App type \`${detail.appType}\` is not yet supported.`,
|
|
117
|
+
retryLater: detail.appType === 'react_native' || detail.appType === 'flutter',
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
apps.push(parseApp(appFolder, detail, type));
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
skippedApps.push({
|
|
125
|
+
sourceId: '',
|
|
126
|
+
sourceName: path.basename(appFolder),
|
|
127
|
+
reason: getMessageFromUnknownError(error),
|
|
128
|
+
retryLater: false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { apps, skippedApps };
|
|
133
|
+
};
|
|
134
|
+
const mapAppType = (appType) => {
|
|
135
|
+
switch (appType) {
|
|
136
|
+
case 'android':
|
|
137
|
+
case 'capacitor':
|
|
138
|
+
case 'cordova':
|
|
139
|
+
case 'ios':
|
|
140
|
+
return appType;
|
|
141
|
+
case 'ionic':
|
|
142
|
+
return 'capacitor';
|
|
143
|
+
default:
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const parseApp = (appFolder, detail, type) => {
|
|
148
|
+
const notes = [];
|
|
149
|
+
if (detail.appType === 'ionic') {
|
|
150
|
+
notes.push('App type `ionic` was mapped to `capacitor`.');
|
|
151
|
+
}
|
|
152
|
+
const environments = parseJsonFileIfExists(path.join(appFolder, 'environments.json'), environmentsSchema) ?? [];
|
|
153
|
+
const channels = parseJsonFileIfExists(path.join(appFolder, 'live-update-channels.json'), liveUpdateChannelsSchema) ?? [];
|
|
154
|
+
const nativeConfigs = parseJsonFileIfExists(path.join(appFolder, 'native-configs.json'), nativeConfigsSchema) ?? [];
|
|
155
|
+
const certificates = parseCertificates(appFolder, notes);
|
|
156
|
+
const destinations = parseDestinations(appFolder);
|
|
157
|
+
return {
|
|
158
|
+
sourceId: detail.id,
|
|
159
|
+
sourceName: detail.name,
|
|
160
|
+
name: detail.name,
|
|
161
|
+
type,
|
|
162
|
+
notes,
|
|
163
|
+
automations: parseAutomations(appFolder, {
|
|
164
|
+
certificateNamesById: toNameMap(certificates.map((certificate) => ({ id: certificate.id, name: certificate.certificate.name }))),
|
|
165
|
+
channelNamesById: new Map(channels.map((channel) => [channel.id, channel.name])),
|
|
166
|
+
configurationNamesById: toNameMap(nativeConfigs),
|
|
167
|
+
destinationNamesById: toNameMap(destinations.map((destination) => ({ id: destination.id, name: destination.destination.name }))),
|
|
168
|
+
environmentNamesById: toNameMap(environments),
|
|
169
|
+
}, notes),
|
|
170
|
+
certificates: certificates.map((certificate) => certificate.certificate),
|
|
171
|
+
channels: channels.map((channel) => channel.name),
|
|
172
|
+
configurations: parseConfigurations(nativeConfigs, notes),
|
|
173
|
+
destinations: destinations.map((destination) => destination.destination),
|
|
174
|
+
environments: environments.map((environment) => ({
|
|
175
|
+
name: environment.name,
|
|
176
|
+
variables: Object.entries(environment.vars ?? {}).map(([key, value]) => ({ key, value })),
|
|
177
|
+
secrets: Object.entries(environment.secrets ?? {}).map(([key, value]) => ({ key, value })),
|
|
178
|
+
})),
|
|
179
|
+
repository: parseRepository(appFolder, notes),
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
const parseRepository = (appFolder, notes) => {
|
|
183
|
+
const repoAssociation = parseJsonFileIfExists(path.join(appFolder, 'repo-association.json'), repoAssociationSchema);
|
|
184
|
+
if (!repoAssociation || Array.isArray(repoAssociation)) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return parseGitRemoteUrl(repoAssociation.cloneUrl);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
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.`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const parseConfigurations = (nativeConfigs, notes) => {
|
|
196
|
+
const configurationsWithLiveUpdateSettings = nativeConfigs.filter((config) => config.configs?.ionic);
|
|
197
|
+
if (configurationsWithLiveUpdateSettings.length > 0) {
|
|
198
|
+
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}).`);
|
|
199
|
+
}
|
|
200
|
+
return nativeConfigs.map((config) => ({
|
|
201
|
+
name: config.name,
|
|
202
|
+
displayName: config.configs?.base?.name ?? undefined,
|
|
203
|
+
packageName: config.configs?.base?.bundle_id ?? undefined,
|
|
204
|
+
}));
|
|
205
|
+
};
|
|
206
|
+
const parseCertificates = (appFolder, notes) => {
|
|
207
|
+
const certificates = [];
|
|
208
|
+
for (const folder of getSubfolders(path.join(appFolder, 'signing-certificates', 'android'))) {
|
|
209
|
+
const metadata = parseJsonFile(path.join(folder, 'android-signing-certificate.json'), androidSigningCertificateSchema);
|
|
210
|
+
const filePath = path.join(folder, metadata.keystoreFile);
|
|
211
|
+
if (!fs.existsSync(filePath)) {
|
|
212
|
+
notes.push(`The signing certificate \`${metadata.name}\` was skipped because the file \`${metadata.keystoreFile}\` is missing in the export.`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
certificates.push({
|
|
216
|
+
id: metadata.id,
|
|
217
|
+
certificate: {
|
|
218
|
+
name: metadata.name,
|
|
219
|
+
platform: 'android',
|
|
220
|
+
filePath,
|
|
221
|
+
password: metadata.keystorePassword,
|
|
222
|
+
keyAlias: metadata.keyAlias,
|
|
223
|
+
keyPassword: metadata.keyPassword,
|
|
224
|
+
provisioningProfilePaths: [],
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
for (const folder of getSubfolders(path.join(appFolder, 'signing-certificates', 'ios'))) {
|
|
229
|
+
const metadata = parseJsonFile(path.join(folder, 'ios-signing-certificate.json'), iosSigningCertificateSchema);
|
|
230
|
+
const filePath = path.join(folder, metadata.p12File);
|
|
231
|
+
if (!fs.existsSync(filePath)) {
|
|
232
|
+
notes.push(`The signing certificate \`${metadata.name}\` was skipped because the file \`${metadata.p12File}\` is missing in the export.`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const provisioningProfilePaths = [];
|
|
236
|
+
for (const profileFile of new Set(metadata.provisioningProfiles ?? [])) {
|
|
237
|
+
const profilePath = path.join(folder, profileFile);
|
|
238
|
+
if (!fs.existsSync(profilePath)) {
|
|
239
|
+
notes.push(`The provisioning profile \`${profileFile}\` of the signing certificate \`${metadata.name}\` is missing in the export and was skipped.`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
provisioningProfilePaths.push(profilePath);
|
|
243
|
+
}
|
|
244
|
+
certificates.push({
|
|
245
|
+
id: metadata.id,
|
|
246
|
+
certificate: {
|
|
247
|
+
name: metadata.name,
|
|
248
|
+
platform: 'ios',
|
|
249
|
+
filePath,
|
|
250
|
+
password: metadata.p12Password,
|
|
251
|
+
provisioningProfilePaths,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return certificates;
|
|
256
|
+
};
|
|
257
|
+
const parseDestinations = (appFolder) => {
|
|
258
|
+
const destinations = [];
|
|
259
|
+
for (const folder of getSubfolders(path.join(appFolder, 'store-destinations', 'android'))) {
|
|
260
|
+
const metadata = parseJsonFile(path.join(folder, 'play-store-destination.json'), playStoreDestinationSchema);
|
|
261
|
+
const googleServiceAccountKeyPath = path.join(folder, 'json-key.json');
|
|
262
|
+
destinations.push({
|
|
263
|
+
id: metadata.id,
|
|
264
|
+
destination: {
|
|
265
|
+
name: metadata.name,
|
|
266
|
+
platform: 'android',
|
|
267
|
+
androidPackageName: metadata.packageName ?? undefined,
|
|
268
|
+
androidBuildArtifactType: metadata.artifactType === 'aab' || metadata.artifactType === 'apk' ? metadata.artifactType : undefined,
|
|
269
|
+
googlePlayTrack: metadata.track ?? undefined,
|
|
270
|
+
googleServiceAccountKeyPath: fs.existsSync(googleServiceAccountKeyPath)
|
|
271
|
+
? googleServiceAccountKeyPath
|
|
272
|
+
: undefined,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
for (const folder of getSubfolders(path.join(appFolder, 'store-destinations', 'ios'))) {
|
|
277
|
+
const metadata = parseJsonFile(path.join(folder, 'app-store-destination.json'), appStoreDestinationSchema);
|
|
278
|
+
destinations.push({
|
|
279
|
+
id: metadata.id,
|
|
280
|
+
destination: {
|
|
281
|
+
name: metadata.name,
|
|
282
|
+
platform: 'ios',
|
|
283
|
+
appleId: metadata.appleId ?? undefined,
|
|
284
|
+
appleAppId: metadata.appAppleId ?? undefined,
|
|
285
|
+
appleTeamId: metadata.teamId ?? undefined,
|
|
286
|
+
appleAppPassword: metadata.appPassword ?? undefined,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return destinations;
|
|
291
|
+
};
|
|
292
|
+
const parseAutomations = (appFolder, references, notes) => {
|
|
293
|
+
const automations = [];
|
|
294
|
+
const nativeAutomations = parseJsonFileIfExists(path.join(appFolder, 'native-build-automations.json'), nativeBuildAutomationsSchema) ?? [];
|
|
295
|
+
const webAutomations = parseJsonFileIfExists(path.join(appFolder, 'web-build-automations.json'), webBuildAutomationsSchema) ?? [];
|
|
296
|
+
const automationsWithWebhook = [...nativeAutomations, ...webAutomations].filter((automation) => automation.webhook);
|
|
297
|
+
if (automationsWithWebhook.length > 0) {
|
|
298
|
+
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}).`);
|
|
299
|
+
}
|
|
300
|
+
for (const automation of nativeAutomations) {
|
|
301
|
+
let buildType = automation.buildType ?? undefined;
|
|
302
|
+
if (buildType && !SUPPORTED_BUILD_TYPES.includes(buildType)) {
|
|
303
|
+
notes.push(`The automation \`${automation.name}\` has the unsupported build type \`${buildType}\` which was skipped.`);
|
|
304
|
+
buildType = undefined;
|
|
305
|
+
}
|
|
306
|
+
automations.push({
|
|
307
|
+
name: automation.name,
|
|
308
|
+
platform: automation.platform,
|
|
309
|
+
triggerPattern: automation.gitBranch,
|
|
310
|
+
buildType,
|
|
311
|
+
enabled: automation.automationEnabled,
|
|
312
|
+
appCertificateName: resolveName(references.certificateNamesById, automation.signingCertificateId, automation.name, 'signing certificate', notes),
|
|
313
|
+
appConfigurationName: resolveName(references.configurationNamesById, automation.nativeConfigId, automation.name, 'native configuration', notes),
|
|
314
|
+
appDestinationName: resolveName(references.destinationNamesById, automation.destinationId, automation.name, 'destination', notes),
|
|
315
|
+
appEnvironmentName: resolveName(references.environmentNamesById, automation.environmentId, automation.name, 'environment', notes),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
for (const automation of webAutomations) {
|
|
319
|
+
if (automation.webPreviewEnabled) {
|
|
320
|
+
notes.push(`The automation \`${automation.name}\` has web previews enabled which are not supported by Capawesome Cloud.`);
|
|
321
|
+
}
|
|
322
|
+
const channelNames = (automation.channelIds ?? [])
|
|
323
|
+
.map((channelId) => resolveName(references.channelNamesById, channelId, automation.name, 'channel', notes))
|
|
324
|
+
.filter((name) => !!name);
|
|
325
|
+
const appEnvironmentName = resolveName(references.environmentNamesById, automation.environmentId, automation.name, 'environment', notes);
|
|
326
|
+
if (channelNames.length === 0) {
|
|
327
|
+
automations.push({
|
|
328
|
+
name: automation.name,
|
|
329
|
+
platform: 'web',
|
|
330
|
+
triggerPattern: automation.gitBranch,
|
|
331
|
+
enabled: automation.automationEnabled,
|
|
332
|
+
appEnvironmentName,
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
for (const channelName of channelNames) {
|
|
337
|
+
automations.push({
|
|
338
|
+
name: channelNames.length > 1 ? `${automation.name} (${channelName})` : automation.name,
|
|
339
|
+
platform: 'web',
|
|
340
|
+
triggerPattern: automation.gitBranch,
|
|
341
|
+
enabled: automation.automationEnabled,
|
|
342
|
+
appChannelName: channelName,
|
|
343
|
+
appEnvironmentName,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return automations;
|
|
348
|
+
};
|
|
349
|
+
const resolveName = (namesById, id, automationName, label, notes) => {
|
|
350
|
+
if (id === null || id === undefined) {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
const name = namesById.get(id);
|
|
354
|
+
if (!name) {
|
|
355
|
+
notes.push(`The automation \`${automationName}\` references an unknown ${label} which was skipped.`);
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
return name;
|
|
359
|
+
};
|
|
360
|
+
const toNameMap = (entities) => {
|
|
361
|
+
return new Map(entities.map((entity) => [entity.id, entity.name]));
|
|
362
|
+
};
|
|
363
|
+
const getSubfolders = (directory) => {
|
|
364
|
+
if (!fs.existsSync(directory)) {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
return fs
|
|
368
|
+
.readdirSync(directory, { withFileTypes: true })
|
|
369
|
+
.filter((entry) => entry.isDirectory())
|
|
370
|
+
.map((entry) => path.join(directory, entry.name));
|
|
371
|
+
};
|
|
372
|
+
const parseJsonFile = (filePath, schema) => {
|
|
373
|
+
let json;
|
|
374
|
+
try {
|
|
375
|
+
json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
throw new UserError(`The export file \`${path.basename(filePath)}\` is missing or contains invalid JSON.`);
|
|
379
|
+
}
|
|
380
|
+
const result = schema.safeParse(json);
|
|
381
|
+
if (!result.success) {
|
|
382
|
+
throw new UserError(`The export file \`${path.basename(filePath)}\` has an unexpected format.`);
|
|
383
|
+
}
|
|
384
|
+
return result.data;
|
|
385
|
+
};
|
|
386
|
+
const parseJsonFileIfExists = (filePath, schema) => {
|
|
387
|
+
if (!fs.existsSync(filePath)) {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
return parseJsonFile(filePath, schema);
|
|
391
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { parseAppflowExport } from '../utils/appflow-export.js';
|
|
2
|
+
import { UserError } from '../utils/error.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
describe('appflow-export', () => {
|
|
8
|
+
let exportDirectory;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
exportDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'appflow-export-test-'));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
fs.rmSync(exportDirectory, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
const writeAppFiles = (appFolder, files) => {
|
|
16
|
+
for (const [fileName, content] of Object.entries(files)) {
|
|
17
|
+
const filePath = path.join(exportDirectory, 'apps', appFolder, fileName);
|
|
18
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
19
|
+
if (typeof content === 'string' || Buffer.isBuffer(content)) {
|
|
20
|
+
fs.writeFileSync(filePath, content);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
fs.writeFileSync(filePath, JSON.stringify(content));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
it('should throw a user error if the export does not contain an apps directory', async () => {
|
|
28
|
+
await expect(parseAppflowExport(exportDirectory)).rejects.toThrow(UserError);
|
|
29
|
+
});
|
|
30
|
+
it('should parse a fully configured app', async () => {
|
|
31
|
+
writeAppFiles('My App-6668c18c', {
|
|
32
|
+
'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
|
|
33
|
+
'repo-association.json': {
|
|
34
|
+
gitProvider: 'github',
|
|
35
|
+
gitUsername: 'robingenz',
|
|
36
|
+
cloneUrl: 'https://github.com/robingenz/appflow-export-test.git',
|
|
37
|
+
},
|
|
38
|
+
'environments.json': [
|
|
39
|
+
{ id: 33513, name: 'Development', vars: { DEBUG: 'true' }, secrets: null },
|
|
40
|
+
{ id: 33515, name: 'Production', vars: {}, secrets: { API_KEY: 'secret' } },
|
|
41
|
+
],
|
|
42
|
+
'live-update-channels.json': [
|
|
43
|
+
{ id: 'd33204c6', name: 'Production' },
|
|
44
|
+
{ id: '1e168146', name: 'Staging' },
|
|
45
|
+
],
|
|
46
|
+
'native-configs.json': [
|
|
47
|
+
{
|
|
48
|
+
id: 41087,
|
|
49
|
+
name: 'Development',
|
|
50
|
+
configs: {
|
|
51
|
+
base: { name: 'My App (Dev)', bundle_id: 'dev.robingenz.app.dev' },
|
|
52
|
+
ionic: { channel_name: 'Development', update_method: 'background' },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{ id: 41089, name: 'Barebones', configs: { base: { bundle_id: 'dev.robingenz.app.bare' } } },
|
|
56
|
+
],
|
|
57
|
+
'native-build-automations.json': [
|
|
58
|
+
{
|
|
59
|
+
name: 'Android Release',
|
|
60
|
+
gitBranch: 'main',
|
|
61
|
+
type: 'package',
|
|
62
|
+
platform: 'android',
|
|
63
|
+
buildType: 'release',
|
|
64
|
+
environmentId: 33515,
|
|
65
|
+
webhook: 'https://example.com/webhook',
|
|
66
|
+
automationEnabled: true,
|
|
67
|
+
nativeConfigId: 41087,
|
|
68
|
+
signingCertificateId: 154267,
|
|
69
|
+
destinationId: null,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'iOS Store',
|
|
73
|
+
gitBranch: 'main',
|
|
74
|
+
type: 'package',
|
|
75
|
+
platform: 'ios',
|
|
76
|
+
buildType: 'development',
|
|
77
|
+
environmentId: 99999,
|
|
78
|
+
webhook: null,
|
|
79
|
+
automationEnabled: false,
|
|
80
|
+
nativeConfigId: null,
|
|
81
|
+
signingCertificateId: null,
|
|
82
|
+
destinationId: null,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
'web-build-automations.json': [
|
|
86
|
+
{
|
|
87
|
+
name: 'Web Prod',
|
|
88
|
+
gitBranch: 'main',
|
|
89
|
+
type: 'deploy',
|
|
90
|
+
platform: 'web-deploy',
|
|
91
|
+
environmentId: 33515,
|
|
92
|
+
webhook: null,
|
|
93
|
+
automationEnabled: true,
|
|
94
|
+
channelIds: ['1e168146', 'd33204c6'],
|
|
95
|
+
webPreviewEnabled: true,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
'signing-certificates/android/Debug-154267/android-signing-certificate.json': {
|
|
99
|
+
id: 154267,
|
|
100
|
+
name: 'Debug',
|
|
101
|
+
keystoreFile: 'keystore.jks',
|
|
102
|
+
keystorePassword: 'test1234',
|
|
103
|
+
keyAlias: 'key',
|
|
104
|
+
keyPassword: 'test1234',
|
|
105
|
+
},
|
|
106
|
+
'signing-certificates/android/Debug-154267/keystore.jks': Buffer.from('keystore'),
|
|
107
|
+
'signing-certificates/ios/Dev-154266/ios-signing-certificate.json': {
|
|
108
|
+
id: 154266,
|
|
109
|
+
name: 'Dev',
|
|
110
|
+
p12File: 'ios-certificate.p12',
|
|
111
|
+
p12Password: '123456',
|
|
112
|
+
provisioningProfiles: ['Test.mobileprovision'],
|
|
113
|
+
},
|
|
114
|
+
'signing-certificates/ios/Dev-154266/ios-certificate.p12': Buffer.from('p12'),
|
|
115
|
+
'signing-certificates/ios/Dev-154266/Test.mobileprovision': Buffer.from('profile'),
|
|
116
|
+
'store-destinations/android/Prod-27890/play-store-destination.json': {
|
|
117
|
+
id: 27890,
|
|
118
|
+
name: 'Prod',
|
|
119
|
+
artifactType: 'aab',
|
|
120
|
+
packageName: 'dev.robingenz.app',
|
|
121
|
+
track: 'internal',
|
|
122
|
+
},
|
|
123
|
+
'store-destinations/android/Prod-27890/json-key.json': { type: 'service_account' },
|
|
124
|
+
});
|
|
125
|
+
const { apps, skippedApps } = await parseAppflowExport(exportDirectory);
|
|
126
|
+
expect(skippedApps).toEqual([]);
|
|
127
|
+
expect(apps).toHaveLength(1);
|
|
128
|
+
const app = apps[0];
|
|
129
|
+
expect(app.sourceId).toBe('6668c18c');
|
|
130
|
+
expect(app.sourceName).toBe('My App');
|
|
131
|
+
expect(app.type).toBe('capacitor');
|
|
132
|
+
expect(app.repository).toEqual({
|
|
133
|
+
ownerSlug: 'robingenz',
|
|
134
|
+
provider: 'github',
|
|
135
|
+
repositorySlug: 'appflow-export-test',
|
|
136
|
+
projectSlug: undefined,
|
|
137
|
+
});
|
|
138
|
+
expect(app.environments).toEqual([
|
|
139
|
+
{ name: 'Development', variables: [{ key: 'DEBUG', value: 'true' }], secrets: [] },
|
|
140
|
+
{ name: 'Production', variables: [], secrets: [{ key: 'API_KEY', value: 'secret' }] },
|
|
141
|
+
]);
|
|
142
|
+
expect(app.channels).toEqual(['Production', 'Staging']);
|
|
143
|
+
expect(app.configurations).toEqual([
|
|
144
|
+
{ name: 'Development', displayName: 'My App (Dev)', packageName: 'dev.robingenz.app.dev' },
|
|
145
|
+
{ name: 'Barebones', displayName: undefined, packageName: 'dev.robingenz.app.bare' },
|
|
146
|
+
]);
|
|
147
|
+
expect(app.certificates).toHaveLength(2);
|
|
148
|
+
expect(app.certificates[0]).toMatchObject({
|
|
149
|
+
name: 'Debug',
|
|
150
|
+
platform: 'android',
|
|
151
|
+
password: 'test1234',
|
|
152
|
+
keyAlias: 'key',
|
|
153
|
+
keyPassword: 'test1234',
|
|
154
|
+
});
|
|
155
|
+
expect(app.certificates[1]).toMatchObject({ name: 'Dev', platform: 'ios', password: '123456' });
|
|
156
|
+
expect(app.certificates[1].provisioningProfilePaths).toHaveLength(1);
|
|
157
|
+
expect(app.destinations).toEqual([
|
|
158
|
+
{
|
|
159
|
+
name: 'Prod',
|
|
160
|
+
platform: 'android',
|
|
161
|
+
androidPackageName: 'dev.robingenz.app',
|
|
162
|
+
androidBuildArtifactType: 'aab',
|
|
163
|
+
googlePlayTrack: 'internal',
|
|
164
|
+
googleServiceAccountKeyPath: expect.stringContaining('json-key.json'),
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
expect(app.automations).toEqual([
|
|
168
|
+
{
|
|
169
|
+
name: 'Android Release',
|
|
170
|
+
platform: 'android',
|
|
171
|
+
triggerPattern: 'main',
|
|
172
|
+
buildType: 'release',
|
|
173
|
+
enabled: true,
|
|
174
|
+
appCertificateName: 'Debug',
|
|
175
|
+
appConfigurationName: 'Development',
|
|
176
|
+
appDestinationName: undefined,
|
|
177
|
+
appEnvironmentName: 'Production',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'iOS Store',
|
|
181
|
+
platform: 'ios',
|
|
182
|
+
triggerPattern: 'main',
|
|
183
|
+
buildType: 'development',
|
|
184
|
+
enabled: false,
|
|
185
|
+
appCertificateName: undefined,
|
|
186
|
+
appConfigurationName: undefined,
|
|
187
|
+
appDestinationName: undefined,
|
|
188
|
+
appEnvironmentName: undefined,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'Web Prod (Staging)',
|
|
192
|
+
platform: 'web',
|
|
193
|
+
triggerPattern: 'main',
|
|
194
|
+
enabled: true,
|
|
195
|
+
appChannelName: 'Staging',
|
|
196
|
+
appEnvironmentName: 'Production',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'Web Prod (Production)',
|
|
200
|
+
platform: 'web',
|
|
201
|
+
triggerPattern: 'main',
|
|
202
|
+
enabled: true,
|
|
203
|
+
appChannelName: 'Production',
|
|
204
|
+
appEnvironmentName: 'Production',
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
expect(app.notes).toHaveLength(4);
|
|
208
|
+
expect(app.notes).toContainEqual(expect.stringContaining('webhook'));
|
|
209
|
+
expect(app.notes).toContainEqual(expect.stringContaining('unknown environment'));
|
|
210
|
+
expect(app.notes).toContainEqual(expect.stringContaining('web previews'));
|
|
211
|
+
expect(app.notes).toContainEqual(expect.stringContaining('Live Update plugin settings'));
|
|
212
|
+
});
|
|
213
|
+
it('should map the app type `ionic` to `capacitor` with a note', async () => {
|
|
214
|
+
writeAppFiles('Legacy App-11111111', {
|
|
215
|
+
'app-detail.json': { id: '11111111', name: 'Legacy App', appType: 'ionic' },
|
|
216
|
+
});
|
|
217
|
+
const { apps } = await parseAppflowExport(exportDirectory);
|
|
218
|
+
expect(apps).toHaveLength(1);
|
|
219
|
+
expect(apps[0].type).toBe('capacitor');
|
|
220
|
+
expect(apps[0].notes).toContainEqual(expect.stringContaining('`ionic`'));
|
|
221
|
+
});
|
|
222
|
+
it('should skip apps with an unsupported app type', async () => {
|
|
223
|
+
writeAppFiles('RN App-22222222', {
|
|
224
|
+
'app-detail.json': { id: '22222222', name: 'RN App', appType: 'react_native' },
|
|
225
|
+
});
|
|
226
|
+
writeAppFiles('Other App-33333333', {
|
|
227
|
+
'app-detail.json': { id: '33333333', name: 'Other App', appType: 'unknown' },
|
|
228
|
+
});
|
|
229
|
+
const { apps, skippedApps } = await parseAppflowExport(exportDirectory);
|
|
230
|
+
expect(apps).toEqual([]);
|
|
231
|
+
expect(skippedApps).toContainEqual({
|
|
232
|
+
sourceId: '22222222',
|
|
233
|
+
sourceName: 'RN App',
|
|
234
|
+
reason: expect.stringContaining('react_native'),
|
|
235
|
+
retryLater: true,
|
|
236
|
+
});
|
|
237
|
+
expect(skippedApps).toContainEqual({
|
|
238
|
+
sourceId: '33333333',
|
|
239
|
+
sourceName: 'Other App',
|
|
240
|
+
reason: expect.stringContaining('unknown'),
|
|
241
|
+
retryLater: false,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
it('should handle an empty repo association serialized as an array', async () => {
|
|
245
|
+
writeAppFiles('No Repo-44444444', {
|
|
246
|
+
'app-detail.json': { id: '44444444', name: 'No Repo', appType: 'capacitor' },
|
|
247
|
+
'repo-association.json': [],
|
|
248
|
+
});
|
|
249
|
+
const { apps } = await parseAppflowExport(exportDirectory);
|
|
250
|
+
expect(apps[0].repository).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
it('should skip a signing certificate with a missing file', async () => {
|
|
253
|
+
writeAppFiles('Broken Cert-55555555', {
|
|
254
|
+
'app-detail.json': { id: '55555555', name: 'Broken Cert', appType: 'capacitor' },
|
|
255
|
+
'signing-certificates/android/Debug-1/android-signing-certificate.json': {
|
|
256
|
+
id: 1,
|
|
257
|
+
name: 'Debug',
|
|
258
|
+
keystoreFile: 'keystore.jks',
|
|
259
|
+
keystorePassword: 'test',
|
|
260
|
+
keyAlias: 'key',
|
|
261
|
+
keyPassword: 'test',
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const { apps } = await parseAppflowExport(exportDirectory);
|
|
265
|
+
expect(apps[0].certificates).toEqual([]);
|
|
266
|
+
expect(apps[0].notes).toContainEqual(expect.stringContaining('keystore.jks'));
|
|
267
|
+
});
|
|
268
|
+
it('should skip an app with an invalid app detail file', async () => {
|
|
269
|
+
writeAppFiles('Invalid-66666666', {
|
|
270
|
+
'app-detail.json': 'not json',
|
|
271
|
+
});
|
|
272
|
+
const { apps, skippedApps } = await parseAppflowExport(exportDirectory);
|
|
273
|
+
expect(apps).toEqual([]);
|
|
274
|
+
expect(skippedApps).toHaveLength(1);
|
|
275
|
+
expect(skippedApps[0].retryLater).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
});
|