voltra 2.0.0 → 2.1.1

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.
Files changed (84) hide show
  1. package/build/cjs/config/normalize.js +93 -0
  2. package/build/cjs/config/normalize.js.map +1 -1
  3. package/build/cjs/index.js +5 -2
  4. package/build/cjs/index.js.map +1 -1
  5. package/build/cjs/platforms/android/apply.js +11 -2
  6. package/build/cjs/platforms/android/apply.js.map +1 -1
  7. package/build/cjs/platforms/android/generated.js +152 -130
  8. package/build/cjs/platforms/android/generated.js.map +1 -1
  9. package/build/cjs/platforms/android/gradle.js +120 -0
  10. package/build/cjs/platforms/android/gradle.js.map +1 -0
  11. package/build/cjs/platforms/ios/generated.js +246 -142
  12. package/build/cjs/platforms/ios/generated.js.map +1 -1
  13. package/build/cjs/platforms/ios/xcodeTarget.js +68 -0
  14. package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
  15. package/build/cjs/platforms/shared/widgetModule.js +151 -0
  16. package/build/cjs/platforms/shared/widgetModule.js.map +1 -0
  17. package/build/esm/config/normalize.js +93 -0
  18. package/build/esm/config/normalize.js.map +1 -1
  19. package/build/esm/index.js +1 -0
  20. package/build/esm/index.js.map +1 -1
  21. package/build/esm/platforms/android/apply.js +11 -2
  22. package/build/esm/platforms/android/apply.js.map +1 -1
  23. package/build/esm/platforms/android/generated.js +152 -97
  24. package/build/esm/platforms/android/generated.js.map +1 -1
  25. package/build/esm/platforms/android/gradle.js +112 -0
  26. package/build/esm/platforms/android/gradle.js.map +1 -0
  27. package/build/esm/platforms/ios/generated.js +246 -109
  28. package/build/esm/platforms/ios/generated.js.map +1 -1
  29. package/build/esm/platforms/ios/xcodeTarget.js +69 -1
  30. package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
  31. package/build/esm/platforms/shared/widgetModule.js +111 -0
  32. package/build/esm/platforms/shared/widgetModule.js.map +1 -0
  33. package/build/types/config/normalize.d.ts.map +1 -1
  34. package/build/types/config/types.d.ts +32 -0
  35. package/build/types/config/types.d.ts.map +1 -1
  36. package/build/types/index.d.ts +3 -1
  37. package/build/types/index.d.ts.map +1 -1
  38. package/build/types/platforms/android/apply.d.ts.map +1 -1
  39. package/build/types/platforms/android/generated.d.ts.map +1 -1
  40. package/build/types/platforms/android/gradle.d.ts +18 -0
  41. package/build/types/platforms/android/gradle.d.ts.map +1 -0
  42. package/build/types/platforms/ios/generated.d.ts.map +1 -1
  43. package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
  44. package/build/types/platforms/shared/widgetModule.d.ts +9 -0
  45. package/build/types/platforms/shared/widgetModule.d.ts.map +1 -0
  46. package/package.json +2 -1
  47. package/src/apply/index.ts +309 -0
  48. package/src/apply/preflight.ts +172 -0
  49. package/src/bin.ts +14 -0
  50. package/src/commands/apply.ts +114 -0
  51. package/src/config/defaults.ts +34 -0
  52. package/src/config/load.ts +100 -0
  53. package/src/config/normalize.ts +562 -0
  54. package/src/config/types.ts +317 -0
  55. package/src/cosmiconfig.d.ts +21 -0
  56. package/src/dependencies/platformPackages.ts +75 -0
  57. package/src/discovery/android.ts +233 -0
  58. package/src/discovery/ios.ts +579 -0
  59. package/src/fs/path.ts +25 -0
  60. package/src/fs/readWrite.ts +102 -0
  61. package/src/git/status.ts +205 -0
  62. package/src/index.ts +204 -0
  63. package/src/platforms/android/apply.ts +106 -0
  64. package/src/platforms/android/generated.ts +1284 -0
  65. package/src/platforms/android/gradle.ts +155 -0
  66. package/src/platforms/android/manifest.ts +379 -0
  67. package/src/platforms/ios/apply.ts +139 -0
  68. package/src/platforms/ios/deploymentTarget.ts +47 -0
  69. package/src/platforms/ios/entitlements.ts +146 -0
  70. package/src/platforms/ios/generated.ts +1233 -0
  71. package/src/platforms/ios/mainAppEntitlements.ts +11 -0
  72. package/src/platforms/ios/plist.ts +321 -0
  73. package/src/platforms/ios/podfile.ts +255 -0
  74. package/src/platforms/ios/targetName.ts +11 -0
  75. package/src/platforms/ios/xcode.ts +181 -0
  76. package/src/platforms/ios/xcodeTarget.ts +899 -0
  77. package/src/platforms/shared/widgetModule.ts +153 -0
  78. package/src/reporting/clack.ts +67 -0
  79. package/src/reporting/summary.ts +116 -0
  80. package/src/state/diff.ts +21 -0
  81. package/src/state/files.ts +52 -0
  82. package/src/state/load.ts +66 -0
  83. package/src/state/save.ts +31 -0
  84. package/src/xml2js.d.ts +23 -0
@@ -0,0 +1,579 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import type { Stats } from 'node:fs'
5
+
6
+ import { VoltraCliError } from '../reporting/summary'
7
+
8
+ import type { NormalizedIOSProjectConfig } from '../config/types'
9
+
10
+ const PBX_NATIVE_TARGET_SECTION = 'PBXNativeTarget'
11
+ const XC_BUILD_CONFIGURATION_SECTION = 'XCBuildConfiguration'
12
+ const XC_CONFIGURATION_LIST_SECTION = 'XCConfigurationList'
13
+ const IOS_APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application'
14
+
15
+ export interface IOSProjectDiscovery {
16
+ iosRoot: string
17
+ xcodeprojPath: string
18
+ pbxprojPath: string
19
+ podfilePath: string
20
+ mainTargetName: string
21
+ mainTargetCandidates: string[]
22
+ infoPlistPath: string
23
+ entitlementsPath?: string
24
+ }
25
+
26
+ interface ParsedPbxNativeTarget {
27
+ id: string
28
+ name: string
29
+ productType: string
30
+ buildConfigurationListId: string
31
+ }
32
+
33
+ interface ParsedXCConfigurationList {
34
+ id: string
35
+ buildConfigurationIds: string[]
36
+ defaultConfigurationName?: string
37
+ }
38
+
39
+ interface ParsedXCBuildConfiguration {
40
+ id: string
41
+ name: string
42
+ buildSettings: {
43
+ codeSignEntitlements?: string
44
+ infoPlistFile?: string
45
+ productName?: string
46
+ }
47
+ }
48
+
49
+ interface ParsedIOSProject {
50
+ targets: ParsedPbxNativeTarget[]
51
+ configurationLists: Map<string, ParsedXCConfigurationList>
52
+ buildConfigurations: Map<string, ParsedXCBuildConfiguration>
53
+ }
54
+
55
+ export class IOSProjectDiscoveryError extends VoltraCliError {
56
+ constructor(message: string) {
57
+ super(message, 'VOLTRA_IOS_DISCOVERY_FAILED')
58
+ this.name = 'IOSProjectDiscoveryError'
59
+ }
60
+ }
61
+
62
+ export async function discoverIOSProject(
63
+ projectRoot: string,
64
+ config: NormalizedIOSProjectConfig
65
+ ): Promise<IOSProjectDiscovery> {
66
+ const iosRoot = await resolveIOSRoot(projectRoot, config)
67
+ const xcodeprojPath = await resolveXcodeprojPath(iosRoot, config)
68
+ const pbxprojPath = await resolvePbxprojPath(xcodeprojPath)
69
+ const podfilePath = await resolvePodfilePath(iosRoot, config)
70
+ const pbxprojContent = await fs.readFile(pbxprojPath, 'utf8')
71
+ const parsedProject = parseIOSProject(pbxprojContent, pbxprojPath)
72
+ const mainTargetCandidates = getMainTargetCandidates(parsedProject.targets)
73
+ const mainTarget = resolveMainTarget(mainTargetCandidates, config.mainTargetName, pbxprojPath)
74
+ const mainTargetBuildConfigurations = getTargetBuildConfigurations(parsedProject, mainTarget, pbxprojPath)
75
+ const infoPlistPath = await resolveInfoPlistPath(iosRoot, config, mainTarget, mainTargetBuildConfigurations)
76
+ const entitlementsPath = await resolveEntitlementsPath(iosRoot, config, mainTarget, mainTargetBuildConfigurations)
77
+
78
+ return {
79
+ iosRoot,
80
+ xcodeprojPath,
81
+ pbxprojPath,
82
+ podfilePath,
83
+ mainTargetName: mainTarget.name,
84
+ mainTargetCandidates: mainTargetCandidates.map((target) => target.name).sort(),
85
+ infoPlistPath,
86
+ entitlementsPath,
87
+ }
88
+ }
89
+
90
+ async function resolveIOSRoot(projectRoot: string, config: NormalizedIOSProjectConfig): Promise<string> {
91
+ const iosRoot = config.rootDir ?? path.join(projectRoot, 'ios')
92
+
93
+ await ensureDirectory(
94
+ iosRoot,
95
+ config.rootDir
96
+ ? `Configured iOS root directory does not exist: ${iosRoot}`
97
+ : `iOS root directory does not exist at ${iosRoot}. Set ios.project.rootDir to override the default ios/ layout.`
98
+ )
99
+
100
+ return iosRoot
101
+ }
102
+
103
+ async function resolveXcodeprojPath(iosRoot: string, config: NormalizedIOSProjectConfig): Promise<string> {
104
+ if (config.xcodeprojPath) {
105
+ await ensureDirectory(config.xcodeprojPath, `Configured Xcode project does not exist: ${config.xcodeprojPath}`)
106
+
107
+ if (!config.xcodeprojPath.endsWith('.xcodeproj')) {
108
+ throw new IOSProjectDiscoveryError(
109
+ `Configured Xcode project must point to a .xcodeproj directory: ${config.xcodeprojPath}`
110
+ )
111
+ }
112
+
113
+ return config.xcodeprojPath
114
+ }
115
+
116
+ const entries = await fs.readdir(iosRoot, { withFileTypes: true })
117
+ const xcodeprojCandidates = entries
118
+ .filter((entry) => entry.isDirectory() && entry.name.endsWith('.xcodeproj'))
119
+ .map((entry) => path.join(iosRoot, entry.name))
120
+ .sort()
121
+
122
+ if (xcodeprojCandidates.length === 1) {
123
+ return xcodeprojCandidates[0]
124
+ }
125
+
126
+ if (xcodeprojCandidates.length === 0) {
127
+ throw new IOSProjectDiscoveryError(
128
+ `No .xcodeproj was found in ${iosRoot}. Set ios.project.xcodeprojPath to override discovery.`
129
+ )
130
+ }
131
+
132
+ throw new IOSProjectDiscoveryError(
133
+ `Multiple .xcodeproj directories were found in ${iosRoot}: ${xcodeprojCandidates.join(
134
+ ', '
135
+ )}. Set ios.project.xcodeprojPath explicitly.`
136
+ )
137
+ }
138
+
139
+ async function resolvePbxprojPath(xcodeprojPath: string): Promise<string> {
140
+ const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj')
141
+ await ensureFile(pbxprojPath, `Xcode project file is missing at ${pbxprojPath}`)
142
+ return pbxprojPath
143
+ }
144
+
145
+ async function resolvePodfilePath(iosRoot: string, config: NormalizedIOSProjectConfig): Promise<string> {
146
+ const podfilePath = config.podfilePath ?? path.join(iosRoot, 'Podfile')
147
+
148
+ await ensureFile(
149
+ podfilePath,
150
+ config.podfilePath
151
+ ? `Configured Podfile does not exist: ${podfilePath}`
152
+ : `Podfile does not exist at ${podfilePath}. Set ios.project.podfilePath to override the default ios/Podfile path.`
153
+ )
154
+
155
+ return podfilePath
156
+ }
157
+
158
+ function parseIOSProject(content: string, pbxprojPath: string): ParsedIOSProject {
159
+ const nativeTargetSection = getPbxprojSection(content, PBX_NATIVE_TARGET_SECTION, pbxprojPath)
160
+ const configurationListSection = getPbxprojSection(content, XC_CONFIGURATION_LIST_SECTION, pbxprojPath)
161
+ const buildConfigurationSection = getPbxprojSection(content, XC_BUILD_CONFIGURATION_SECTION, pbxprojPath)
162
+
163
+ return {
164
+ targets: parseNativeTargets(nativeTargetSection),
165
+ configurationLists: parseConfigurationLists(configurationListSection),
166
+ buildConfigurations: parseBuildConfigurations(buildConfigurationSection),
167
+ }
168
+ }
169
+
170
+ function getPbxprojSection(content: string, sectionName: string, pbxprojPath: string): string {
171
+ const pattern = new RegExp(`/[*] Begin ${sectionName} section [*]/([\\s\\S]*?)/[*] End ${sectionName} section [*]/`)
172
+ const match = content.match(pattern)
173
+
174
+ if (!match) {
175
+ throw new IOSProjectDiscoveryError(`Could not parse ${sectionName} section from ${pbxprojPath}`)
176
+ }
177
+
178
+ return match[1]
179
+ }
180
+
181
+ function parseNativeTargets(section: string): ParsedPbxNativeTarget[] {
182
+ const targets: ParsedPbxNativeTarget[] = []
183
+ const entryPattern = /([A-Za-z0-9]+) \/\*[^*]+\*\/ = \{([\s\S]*?)\n\t\t\};/g
184
+
185
+ for (const match of section.matchAll(entryPattern)) {
186
+ const [, id, body] = match
187
+ const name = matchPbxprojAssignment(body, 'name')
188
+ const productType = matchPbxprojAssignment(body, 'productType')
189
+ const buildConfigurationListId = matchPbxprojReference(body, 'buildConfigurationList')
190
+
191
+ if (!name || !productType || !buildConfigurationListId) {
192
+ continue
193
+ }
194
+
195
+ targets.push({
196
+ id,
197
+ name: stripPbxprojValue(name),
198
+ productType: stripPbxprojValue(productType),
199
+ buildConfigurationListId,
200
+ })
201
+ }
202
+
203
+ return targets
204
+ }
205
+
206
+ function parseConfigurationLists(section: string): Map<string, ParsedXCConfigurationList> {
207
+ const configurationLists = new Map<string, ParsedXCConfigurationList>()
208
+ const entryPattern = /([A-Za-z0-9]+) \/\*[^*]+\*\/ = \{([\s\S]*?)\n\t\t\};/g
209
+
210
+ for (const match of section.matchAll(entryPattern)) {
211
+ const [, id, body] = match
212
+ const buildConfigurationIds = matchBuildConfigurationIds(body)
213
+
214
+ if (buildConfigurationIds.length === 0) {
215
+ continue
216
+ }
217
+
218
+ configurationLists.set(id, {
219
+ id,
220
+ buildConfigurationIds,
221
+ defaultConfigurationName:
222
+ stripPbxprojValue(matchPbxprojAssignment(body, 'defaultConfigurationName') ?? '') || undefined,
223
+ })
224
+ }
225
+
226
+ return configurationLists
227
+ }
228
+
229
+ function parseBuildConfigurations(section: string): Map<string, ParsedXCBuildConfiguration> {
230
+ const buildConfigurations = new Map<string, ParsedXCBuildConfiguration>()
231
+ const entryPattern = /([A-Za-z0-9]+) \/\*[^*]+\*\/ = \{([\s\S]*?)\n\t\t\};/g
232
+
233
+ for (const match of section.matchAll(entryPattern)) {
234
+ const [, id, body] = match
235
+ const name = matchPbxprojAssignment(body, 'name')
236
+ const buildSettingsBlock = matchBuildSettingsBlock(body)
237
+
238
+ if (!name || !buildSettingsBlock) {
239
+ continue
240
+ }
241
+
242
+ buildConfigurations.set(id, {
243
+ id,
244
+ name: stripPbxprojValue(name),
245
+ buildSettings: {
246
+ codeSignEntitlements: matchBuildSetting(buildSettingsBlock, 'CODE_SIGN_ENTITLEMENTS'),
247
+ infoPlistFile: matchBuildSetting(buildSettingsBlock, 'INFOPLIST_FILE'),
248
+ productName: matchBuildSetting(buildSettingsBlock, 'PRODUCT_NAME'),
249
+ },
250
+ })
251
+ }
252
+
253
+ return buildConfigurations
254
+ }
255
+
256
+ function getMainTargetCandidates(targets: ParsedPbxNativeTarget[]): ParsedPbxNativeTarget[] {
257
+ return targets.filter((target) => target.productType === IOS_APPLICATION_PRODUCT_TYPE)
258
+ }
259
+
260
+ function resolveMainTarget(
261
+ targets: ParsedPbxNativeTarget[],
262
+ configuredMainTargetName: string | undefined,
263
+ pbxprojPath: string
264
+ ): ParsedPbxNativeTarget {
265
+ if (targets.length === 0) {
266
+ throw new IOSProjectDiscoveryError(`No iOS application targets were found in ${pbxprojPath}`)
267
+ }
268
+
269
+ if (configuredMainTargetName) {
270
+ const configuredTarget = targets.find((target) => target.name === configuredMainTargetName)
271
+
272
+ if (configuredTarget) {
273
+ return configuredTarget
274
+ }
275
+
276
+ throw new IOSProjectDiscoveryError(
277
+ `Configured iOS main target '${configuredMainTargetName}' was not found. Available application targets: ${targets
278
+ .map((target) => target.name)
279
+ .sort()
280
+ .join(', ')}`
281
+ )
282
+ }
283
+
284
+ if (targets.length === 1) {
285
+ return targets[0]
286
+ }
287
+
288
+ throw new IOSProjectDiscoveryError(
289
+ `Multiple iOS application targets were found: ${targets
290
+ .map((target) => target.name)
291
+ .sort()
292
+ .join(', ')}. Set ios.project.mainTargetName explicitly.`
293
+ )
294
+ }
295
+
296
+ function getTargetBuildConfigurations(
297
+ project: ParsedIOSProject,
298
+ target: ParsedPbxNativeTarget,
299
+ pbxprojPath: string
300
+ ): ParsedXCBuildConfiguration[] {
301
+ const configurationList = project.configurationLists.get(target.buildConfigurationListId)
302
+
303
+ if (!configurationList) {
304
+ throw new IOSProjectDiscoveryError(
305
+ `Build configuration list ${target.buildConfigurationListId} for target '${target.name}' was not found in ${pbxprojPath}`
306
+ )
307
+ }
308
+
309
+ const missingConfigurationIds = configurationList.buildConfigurationIds.filter(
310
+ (configurationId) => !project.buildConfigurations.has(configurationId)
311
+ )
312
+
313
+ if (missingConfigurationIds.length > 0) {
314
+ throw new IOSProjectDiscoveryError(
315
+ `Build configurations ${missingConfigurationIds.join(', ')} for target '${
316
+ target.name
317
+ }' were not found in ${pbxprojPath}`
318
+ )
319
+ }
320
+
321
+ const buildConfigurations = configurationList.buildConfigurationIds
322
+ .map((configurationId) => project.buildConfigurations.get(configurationId))
323
+ .filter((configuration): configuration is ParsedXCBuildConfiguration => configuration !== undefined)
324
+
325
+ if (buildConfigurations.length === 0) {
326
+ throw new IOSProjectDiscoveryError(
327
+ `No build configurations were found for target '${target.name}' in ${pbxprojPath}`
328
+ )
329
+ }
330
+
331
+ const defaultConfigurationName = configurationList.defaultConfigurationName
332
+
333
+ if (!defaultConfigurationName) {
334
+ return buildConfigurations
335
+ }
336
+
337
+ const defaultConfiguration = buildConfigurations.find(
338
+ (configuration) => configuration.name === defaultConfigurationName
339
+ )
340
+
341
+ return defaultConfiguration
342
+ ? [defaultConfiguration, ...buildConfigurations.filter((configuration) => configuration !== defaultConfiguration)]
343
+ : buildConfigurations
344
+ }
345
+
346
+ async function resolveInfoPlistPath(
347
+ iosRoot: string,
348
+ config: NormalizedIOSProjectConfig,
349
+ target: ParsedPbxNativeTarget,
350
+ buildConfigurations: ParsedXCBuildConfiguration[]
351
+ ): Promise<string> {
352
+ const infoPlistPath =
353
+ config.infoPlistPath ??
354
+ resolveConsistentBuildSettingPath(
355
+ iosRoot,
356
+ target,
357
+ buildConfigurations,
358
+ 'INFOPLIST_FILE',
359
+ (configuration) => configuration.buildSettings.infoPlistFile
360
+ )
361
+
362
+ if (!infoPlistPath) {
363
+ throw new IOSProjectDiscoveryError(
364
+ `Could not determine Info.plist for target '${target.name}'. Set ios.project.infoPlistPath explicitly.`
365
+ )
366
+ }
367
+
368
+ await ensureFile(
369
+ infoPlistPath,
370
+ config.infoPlistPath
371
+ ? `Configured Info.plist does not exist: ${infoPlistPath}`
372
+ : `Discovered Info.plist does not exist: ${infoPlistPath}`
373
+ )
374
+
375
+ return infoPlistPath
376
+ }
377
+
378
+ async function resolveEntitlementsPath(
379
+ iosRoot: string,
380
+ config: NormalizedIOSProjectConfig,
381
+ target: ParsedPbxNativeTarget,
382
+ buildConfigurations: ParsedXCBuildConfiguration[]
383
+ ): Promise<string | undefined> {
384
+ const entitlementsPath =
385
+ config.entitlementsPath ??
386
+ resolveConsistentBuildSettingPath(
387
+ iosRoot,
388
+ target,
389
+ buildConfigurations,
390
+ 'CODE_SIGN_ENTITLEMENTS',
391
+ (configuration) => configuration.buildSettings.codeSignEntitlements
392
+ )
393
+
394
+ if (!entitlementsPath) {
395
+ return undefined
396
+ }
397
+
398
+ await ensureFile(
399
+ entitlementsPath,
400
+ config.entitlementsPath
401
+ ? `Configured entitlements file does not exist: ${entitlementsPath}`
402
+ : `Discovered entitlements file does not exist: ${entitlementsPath}`
403
+ )
404
+
405
+ return entitlementsPath
406
+ }
407
+
408
+ function resolveConsistentBuildSettingPath(
409
+ iosRoot: string,
410
+ target: ParsedPbxNativeTarget,
411
+ buildConfigurations: ParsedXCBuildConfiguration[],
412
+ settingName: string,
413
+ getSettingValue: (configuration: ParsedXCBuildConfiguration) => string | undefined
414
+ ): string | undefined {
415
+ const resolvedPaths = new Map<string, string[]>()
416
+
417
+ for (const configuration of buildConfigurations) {
418
+ const settingValue = getSettingValue(configuration)
419
+
420
+ if (!settingValue) {
421
+ continue
422
+ }
423
+
424
+ const resolvedPath = resolveXcodePathValue(settingValue, iosRoot, target, configuration)
425
+ const configurationNames = resolvedPaths.get(resolvedPath) ?? []
426
+ configurationNames.push(configuration.name)
427
+ resolvedPaths.set(resolvedPath, configurationNames)
428
+ }
429
+
430
+ if (resolvedPaths.size === 0) {
431
+ return undefined
432
+ }
433
+
434
+ if (resolvedPaths.size > 1) {
435
+ throw new IOSProjectDiscoveryError(
436
+ `Target '${target.name}' resolves ${settingName} to multiple paths: ${[...resolvedPaths.entries()]
437
+ .map(([resolvedPath, configurationNames]) => `${resolvedPath} (${configurationNames.join(', ')})`)
438
+ .join('; ')}. Set ios.project.${
439
+ settingName === 'INFOPLIST_FILE' ? 'infoPlistPath' : 'entitlementsPath'
440
+ } explicitly.`
441
+ )
442
+ }
443
+
444
+ return [...resolvedPaths.keys()][0]
445
+ }
446
+
447
+ function resolveXcodePathValue(
448
+ value: string,
449
+ iosRoot: string,
450
+ target: ParsedPbxNativeTarget,
451
+ configuration: ParsedXCBuildConfiguration
452
+ ): string {
453
+ const substitutions = new Map<string, string>([
454
+ ['PROJECT_DIR', iosRoot],
455
+ ['SRCROOT', iosRoot],
456
+ ['TARGET_NAME', target.name],
457
+ ])
458
+
459
+ const productName = configuration.buildSettings.productName
460
+
461
+ if (productName) {
462
+ substitutions.set('PRODUCT_NAME', substituteXcodeVariables(productName, substitutions))
463
+ }
464
+
465
+ const substitutedValue = substituteXcodeVariables(value, substitutions)
466
+
467
+ if (/\$\(|\$\{/.test(substitutedValue)) {
468
+ throw new IOSProjectDiscoveryError(
469
+ `Could not resolve Xcode build setting path '${value}' for target '${target.name}'. Set an explicit ios.project override.`
470
+ )
471
+ }
472
+
473
+ return path.normalize(path.isAbsolute(substitutedValue) ? substitutedValue : path.join(iosRoot, substitutedValue))
474
+ }
475
+
476
+ function substituteXcodeVariables(value: string, substitutions: Map<string, string>): string {
477
+ let nextValue = stripPbxprojValue(value)
478
+
479
+ for (let iteration = 0; iteration < 5; iteration += 1) {
480
+ let didReplace = false
481
+
482
+ nextValue = nextValue.replace(/\$\(([^)]+)\)|\$\{([^}]+)\}/g, (match, groupedName, bracedName) => {
483
+ const variableName = groupedName ?? bracedName
484
+ const substitution = substitutions.get(variableName)
485
+
486
+ if (substitution === undefined) {
487
+ return match
488
+ }
489
+
490
+ didReplace = true
491
+ return substitution
492
+ })
493
+
494
+ if (!didReplace) {
495
+ break
496
+ }
497
+ }
498
+
499
+ return stripPbxprojValue(nextValue)
500
+ }
501
+
502
+ function matchBuildConfigurationIds(body: string): string[] {
503
+ const match = body.match(/buildConfigurations = \(([\s\S]*?)\n\t\t\t\);/)
504
+
505
+ if (!match) {
506
+ return []
507
+ }
508
+
509
+ return [...match[1].matchAll(/([A-Za-z0-9]+) \/\*[^*]+\*\//g)].map((configurationMatch) => configurationMatch[1])
510
+ }
511
+
512
+ function matchBuildSettingsBlock(body: string): string | undefined {
513
+ return body.match(/buildSettings = \{([\s\S]*?)\n\t\t\t\};/)?.[1]
514
+ }
515
+
516
+ function matchBuildSetting(buildSettingsBlock: string, settingName: string): string | undefined {
517
+ return (
518
+ stripPbxprojValue(buildSettingsBlock.match(new RegExp(`\\b${settingName}\\s*=\\s*([^;]+);`))?.[1] ?? '') ||
519
+ undefined
520
+ )
521
+ }
522
+
523
+ function matchPbxprojReference(body: string, fieldName: string): string | undefined {
524
+ return body.match(new RegExp(`\\b${fieldName}\\s*=\\s*([A-Za-z0-9]+)`))?.[1]
525
+ }
526
+
527
+ function matchPbxprojAssignment(body: string, fieldName: string): string | undefined {
528
+ return body.match(new RegExp(`\\b${fieldName}\\s*=\\s*([^;]+);`))?.[1]
529
+ }
530
+
531
+ function stripPbxprojValue(value: string): string {
532
+ const trimmedValue = value.trim()
533
+
534
+ if (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) {
535
+ return trimmedValue.slice(1, -1)
536
+ }
537
+
538
+ return trimmedValue
539
+ }
540
+
541
+ async function ensureDirectory(dirPath: string, message: string): Promise<void> {
542
+ const stat = await readPathStat(dirPath)
543
+
544
+ if (!stat) {
545
+ throw new IOSProjectDiscoveryError(message)
546
+ }
547
+
548
+ if (!stat.isDirectory()) {
549
+ throw new IOSProjectDiscoveryError(`Expected a directory but found a file: ${dirPath}`)
550
+ }
551
+ }
552
+
553
+ async function ensureFile(filePath: string, message: string): Promise<void> {
554
+ const stat = await readPathStat(filePath)
555
+
556
+ if (!stat) {
557
+ throw new IOSProjectDiscoveryError(message)
558
+ }
559
+
560
+ if (!stat.isFile()) {
561
+ throw new IOSProjectDiscoveryError(`Expected a file but found a directory: ${filePath}`)
562
+ }
563
+ }
564
+
565
+ async function readPathStat(targetPath: string): Promise<Stats | undefined> {
566
+ try {
567
+ return await fs.stat(targetPath)
568
+ } catch (error: unknown) {
569
+ if (isNotFoundError(error)) {
570
+ return undefined
571
+ }
572
+
573
+ throw error
574
+ }
575
+ }
576
+
577
+ function isNotFoundError(error: unknown): error is NodeJS.ErrnoException {
578
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
579
+ }
package/src/fs/path.ts ADDED
@@ -0,0 +1,25 @@
1
+ import path from 'node:path'
2
+
3
+ export function normalizeRelativePath(filePath: string): string {
4
+ return filePath.replace(/\\/g, '/')
5
+ }
6
+
7
+ export function resolveFromRoot(rootDir: string, filePath: string): string {
8
+ return path.resolve(rootDir, filePath)
9
+ }
10
+
11
+ export function resolveOptionalFromRoot(rootDir: string, filePath: string | undefined): string | undefined {
12
+ if (!filePath) {
13
+ return undefined
14
+ }
15
+
16
+ return resolveFromRoot(rootDir, filePath)
17
+ }
18
+
19
+ export function toRelativePath(rootDir: string, targetPath: string): string {
20
+ return normalizeRelativePath(path.relative(rootDir, targetPath))
21
+ }
22
+
23
+ export function dirname(filePath: string): string {
24
+ return path.dirname(filePath)
25
+ }
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ const UTF8 = 'utf8'
6
+
7
+ export async function pathExists(filePath: string): Promise<boolean> {
8
+ try {
9
+ await fs.access(filePath)
10
+ return true
11
+ } catch (error: unknown) {
12
+ if (isNotFoundError(error)) {
13
+ return false
14
+ }
15
+
16
+ throw error
17
+ }
18
+ }
19
+
20
+ export async function ensureDirectory(dirPath: string): Promise<void> {
21
+ await fs.mkdir(dirPath, { recursive: true })
22
+ }
23
+
24
+ export async function readTextFile(filePath: string): Promise<string> {
25
+ return fs.readFile(filePath, UTF8)
26
+ }
27
+
28
+ export async function readJsonFile<T>(filePath: string): Promise<T> {
29
+ const content = await readTextFile(filePath)
30
+ return JSON.parse(content) as T
31
+ }
32
+
33
+ export async function writeTextFile(filePath: string, content: string): Promise<void> {
34
+ await ensureDirectory(path.dirname(filePath))
35
+ await writeTextFileAtomic(filePath, content)
36
+ }
37
+
38
+ export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
39
+ await writeTextFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
40
+ }
41
+
42
+ export async function removeFileIfExists(filePath: string): Promise<boolean> {
43
+ try {
44
+ await fs.unlink(filePath)
45
+ return true
46
+ } catch (error: unknown) {
47
+ if (isNotFoundError(error)) {
48
+ return false
49
+ }
50
+
51
+ throw error
52
+ }
53
+ }
54
+
55
+ export async function removeDirectoryIfExists(dirPath: string): Promise<boolean> {
56
+ try {
57
+ await fs.rm(dirPath, { recursive: true, force: false })
58
+ return true
59
+ } catch (error: unknown) {
60
+ if (isNotFoundError(error)) {
61
+ return false
62
+ }
63
+
64
+ throw error
65
+ }
66
+ }
67
+
68
+ export async function removePathIfExists(targetPath: string): Promise<boolean> {
69
+ try {
70
+ const stat = await fs.lstat(targetPath)
71
+
72
+ if (stat.isDirectory()) {
73
+ await fs.rm(targetPath, { recursive: true, force: false })
74
+ } else {
75
+ await fs.unlink(targetPath)
76
+ }
77
+
78
+ return true
79
+ } catch (error: unknown) {
80
+ if (isNotFoundError(error)) {
81
+ return false
82
+ }
83
+
84
+ throw error
85
+ }
86
+ }
87
+
88
+ async function writeTextFileAtomic(filePath: string, content: string): Promise<void> {
89
+ const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`
90
+
91
+ try {
92
+ await fs.writeFile(tempPath, content, UTF8)
93
+ await fs.rename(tempPath, filePath)
94
+ } catch (error: unknown) {
95
+ await removeFileIfExists(tempPath).catch(() => undefined)
96
+ throw error
97
+ }
98
+ }
99
+
100
+ function isNotFoundError(error: unknown): error is NodeJS.ErrnoException {
101
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
102
+ }