voltra 2.0.0 → 2.1.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.
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,317 @@
1
+ import type { CLI_DEFAULTS } from './defaults'
2
+
3
+ export type VoltraPlatform = 'android' | 'ios'
4
+
5
+ /**
6
+ * Per-locale widget copy or build-time initial state paths.
7
+ * This intentionally matches the existing Expo plugin contract.
8
+ */
9
+ export type WidgetLocalizedValue = Record<string, string>
10
+
11
+ /** Widget display text, either as a single string or localized by locale identifier. */
12
+ export type WidgetLabel = string | WidgetLocalizedValue
13
+
14
+ /** Path to a widget initial state module, either as a single path or localized by locale identifier. */
15
+ export type WidgetInitialStatePath = string | WidgetLocalizedValue
16
+
17
+ export interface AndroidWidgetServerUpdateConfig {
18
+ /** Server endpoint that returns widget state updates. */
19
+ url: string
20
+ /** Refresh interval, in minutes, for fetching server updates. */
21
+ intervalMinutes?: number
22
+ /** Whether fetched updates should trigger an immediate widget refresh. */
23
+ refresh?: boolean
24
+ }
25
+
26
+ export interface AndroidWidgetAppIntentParameter {
27
+ /** Configuration key surfaced to env.configuration. */
28
+ name: string
29
+ /** Optional label for runtime configuration UIs. */
30
+ title?: string
31
+ /** Default value used before runtime configuration overrides it. */
32
+ default?: string
33
+ }
34
+
35
+ export interface AndroidWidgetAppIntentConfig {
36
+ /** Parameters surfaced to env.configuration for Dynamic Widgets. */
37
+ parameters: AndroidWidgetAppIntentParameter[]
38
+ }
39
+
40
+ export interface AndroidWidgetConfig {
41
+ /** Stable widget identifier used in generated files and registrations. */
42
+ id: string
43
+ /** User-facing widget name shown by the launcher. */
44
+ displayName: WidgetLabel
45
+ /** User-facing widget description shown by the launcher. */
46
+ description: WidgetLabel
47
+ /** Minimum widget width in dp. */
48
+ minWidth?: number
49
+ /** Minimum widget height in dp. */
50
+ minHeight?: number
51
+ /** Minimum widget width in launcher grid cells. */
52
+ minCellWidth?: number
53
+ /** Minimum widget height in launcher grid cells. */
54
+ minCellHeight?: number
55
+ /** Default widget width in launcher grid cells. */
56
+ targetCellWidth: number
57
+ /** Default widget height in launcher grid cells. */
58
+ targetCellHeight: number
59
+ /** Supported resize directions for the Android widget. */
60
+ resizeMode?: 'none' | 'horizontal' | 'vertical' | 'horizontal|vertical'
61
+ /** Launcher surfaces where the widget can be placed. */
62
+ widgetCategory?: 'home_screen' | 'keyguard' | 'home_screen|keyguard'
63
+ /** Path to the build-time initial state module for this widget. */
64
+ initialStatePath?: WidgetInitialStatePath
65
+ /** Project-relative Dynamic Widget entry module. */
66
+ entry?: string
67
+ /** Server-driven update settings for this widget. */
68
+ serverUpdate?: AndroidWidgetServerUpdateConfig
69
+ /** Path to the preview image shown in widget pickers. */
70
+ previewImage?: string
71
+ /** Path to a preview layout XML file shown in widget pickers. */
72
+ previewLayout?: string
73
+ /** Dynamic Widget configuration parameters surfaced to env.configuration. */
74
+ appIntent?: AndroidWidgetAppIntentConfig
75
+ }
76
+
77
+ export type IOSWidgetFamily =
78
+ | 'systemSmall'
79
+ | 'systemMedium'
80
+ | 'systemLarge'
81
+ | 'systemExtraLarge'
82
+ | 'accessoryCircular'
83
+ | 'accessoryRectangular'
84
+ | 'accessoryInline'
85
+
86
+ export interface IOSWidgetServerUpdateConfig {
87
+ /** Server endpoint that returns widget state updates. */
88
+ url: string
89
+ /** Refresh interval, in minutes, for fetching server updates. */
90
+ intervalMinutes?: number
91
+ /** Whether fetched updates should trigger an immediate widget refresh. */
92
+ refresh?: boolean
93
+ }
94
+
95
+ export interface IOSWidgetAppIntentParameter {
96
+ /** Configuration key surfaced to env.configuration. */
97
+ name: string
98
+ /** Label shown in the native Edit Widget sheet. */
99
+ title: string
100
+ /** Default value used before the user configures the widget. */
101
+ default?: string
102
+ }
103
+
104
+ export interface IOSWidgetAppIntentConfig {
105
+ /** Parameters exposed through AppIntentConfiguration for Dynamic Widgets. */
106
+ parameters: IOSWidgetAppIntentParameter[]
107
+ }
108
+
109
+ export interface IOSWidgetConfig {
110
+ /** Stable widget identifier used in generated files and registrations. */
111
+ id: string
112
+ /** User-facing widget name shown in iOS widget configuration UI. */
113
+ displayName: WidgetLabel
114
+ /** User-facing widget description shown in iOS widget configuration UI. */
115
+ description: WidgetLabel
116
+ /** Supported iOS widget families for this widget. */
117
+ supportedFamilies?: IOSWidgetFamily[]
118
+ /** Path to the build-time initial state module for this widget. */
119
+ initialStatePath?: WidgetInitialStatePath
120
+ /** Project-relative Dynamic Widget entry module. */
121
+ entry?: string
122
+ /** Server-driven update settings for this widget. */
123
+ serverUpdate?: IOSWidgetServerUpdateConfig
124
+ /** Dynamic Widget AppIntent configuration. */
125
+ appIntent?: IOSWidgetAppIntentConfig
126
+ }
127
+
128
+ export interface AndroidProjectOverrides {
129
+ /** Root directory of the Android native project. Defaults to `android/` under `projectRoot`. */
130
+ rootDir?: string
131
+ /** Android app module name. Defaults to `app` when it can be inferred. */
132
+ appModuleName?: string
133
+ /** Explicit path to the AndroidManifest.xml file for the app module. */
134
+ manifestPath?: string
135
+ /** Explicit Android package name if it cannot be derived from the project files. */
136
+ packageName?: string
137
+ }
138
+
139
+ export interface IOSProjectOverrides {
140
+ /** Root directory of the iOS native project. Defaults to `ios/` under `projectRoot`. */
141
+ rootDir?: string
142
+ /** Explicit path to the `.xcodeproj` directory to use for discovery. */
143
+ xcodeprojPath?: string
144
+ /** Main application target name when the Xcode project has multiple app targets. */
145
+ mainTargetName?: string
146
+ /** Explicit path to the app target Info.plist file. */
147
+ infoPlistPath?: string
148
+ /** Explicit path to the main app entitlements file. */
149
+ entitlementsPath?: string
150
+ /** Explicit path to the Podfile. */
151
+ podfilePath?: string
152
+ }
153
+
154
+ /**
155
+ * Public CLI config for Android. This stays close to the current Expo plugin props,
156
+ * with explicit project discovery overrides added for native projects.
157
+ */
158
+ export interface VoltraAndroidConfig {
159
+ /** Whether to add the Android notification permission and related setup. */
160
+ enableNotifications?: boolean
161
+ /** Android widgets to generate and register. */
162
+ widgets?: AndroidWidgetConfig[]
163
+ /** Font files that should be bundled for Android widget rendering. */
164
+ fonts?: string[]
165
+ /** Directory containing user-provided images for Android widgets. */
166
+ userImagesPath?: string
167
+ /** Native Android project discovery overrides. */
168
+ project?: AndroidProjectOverrides
169
+ }
170
+
171
+ /**
172
+ * Public CLI config for iOS. This stays close to the current Expo plugin props,
173
+ * with explicit project discovery overrides added for native projects.
174
+ */
175
+ export interface VoltraIOSConfig {
176
+ /** Whether to enable push-notification-related iOS setup for widgets and Live Activities. */
177
+ enablePushNotifications?: boolean
178
+ /** App Group identifier used to share data between the app and widget extension. */
179
+ groupIdentifier?: string
180
+ /** iOS widgets to generate and register. */
181
+ widgets?: IOSWidgetConfig[]
182
+ /** Minimum iOS deployment target for generated widget targets. */
183
+ deploymentTarget?: string
184
+ /** Override for the generated widget extension target name. */
185
+ targetName?: string
186
+ /** Font files that should be bundled for iOS widget rendering. */
187
+ fonts?: string[]
188
+ /** Directory containing user-provided images for iOS widgets. */
189
+ userImagesPath?: string
190
+ /** Keychain access group shared by the app and extension. */
191
+ keychainGroup?: string
192
+ /** Native iOS project discovery overrides. */
193
+ project?: IOSProjectOverrides
194
+ }
195
+
196
+ export interface VoltraConfig {
197
+ /** Root directory used to resolve relative paths in the Voltra config. Defaults to the config file directory. */
198
+ projectRoot?: string
199
+ /** Android-specific Voltra configuration. */
200
+ android?: VoltraAndroidConfig
201
+ /** iOS-specific Voltra configuration. */
202
+ ios?: VoltraIOSConfig
203
+ }
204
+
205
+ export interface LoadedVoltraConfig {
206
+ /** Parsed Voltra config object loaded from disk. */
207
+ config: VoltraConfig
208
+ /** Absolute path to the loaded config file, when the config came from a file. */
209
+ configPath?: string
210
+ /** Directory that contained the loaded config source. */
211
+ configDir: string
212
+ }
213
+
214
+ export interface NormalizedAndroidWidgetServerUpdateConfig {
215
+ /** Server endpoint that returns widget state updates. */
216
+ url: string
217
+ /** Refresh interval, in minutes, for fetching server updates. */
218
+ intervalMinutes: number
219
+ /** Whether fetched updates should trigger an immediate widget refresh. */
220
+ refresh: boolean
221
+ }
222
+
223
+ export interface NormalizedAndroidWidgetConfig extends Omit<AndroidWidgetConfig, 'serverUpdate'> {
224
+ /** Server-driven update settings after defaults have been applied. */
225
+ serverUpdate?: NormalizedAndroidWidgetServerUpdateConfig
226
+ }
227
+
228
+ export interface NormalizedIOSWidgetServerUpdateConfig {
229
+ /** Server endpoint that returns widget state updates. */
230
+ url: string
231
+ /** Refresh interval, in minutes, for fetching server updates. */
232
+ intervalMinutes: number
233
+ /** Whether fetched updates should trigger an immediate widget refresh. */
234
+ refresh: boolean
235
+ }
236
+
237
+ export interface NormalizedIOSWidgetConfig extends Omit<IOSWidgetConfig, 'serverUpdate' | 'supportedFamilies'> {
238
+ /** Supported iOS widget families after defaults have been applied. */
239
+ supportedFamilies: IOSWidgetFamily[]
240
+ /** Server-driven update settings after defaults have been applied. */
241
+ serverUpdate?: NormalizedIOSWidgetServerUpdateConfig
242
+ }
243
+
244
+ export interface NormalizedAndroidProjectConfig {
245
+ /** Absolute Android project root directory, if overridden. */
246
+ rootDir?: string
247
+ /** Resolved Android app module name. */
248
+ appModuleName?: string
249
+ /** Absolute path to AndroidManifest.xml, if overridden. */
250
+ manifestPath?: string
251
+ /** Explicit Android package name, if overridden. */
252
+ packageName?: string
253
+ }
254
+
255
+ export interface NormalizedIOSProjectConfig {
256
+ /** Absolute iOS project root directory, if overridden. */
257
+ rootDir?: string
258
+ /** Absolute path to the `.xcodeproj` directory, if overridden. */
259
+ xcodeprojPath?: string
260
+ /** Explicit main iOS application target name, if overridden. */
261
+ mainTargetName?: string
262
+ /** Absolute path to the Info.plist file, if overridden. */
263
+ infoPlistPath?: string
264
+ /** Absolute path to the entitlements file, if overridden. */
265
+ entitlementsPath?: string
266
+ /** Absolute path to the Podfile, if overridden. */
267
+ podfilePath?: string
268
+ }
269
+
270
+ export interface NormalizedVoltraAndroidConfig {
271
+ /** Whether Android notification setup should be applied. */
272
+ enableNotifications: boolean
273
+ /** Android widgets after validation and normalization. */
274
+ widgets: NormalizedAndroidWidgetConfig[]
275
+ /** Absolute font file paths for Android widgets. */
276
+ fonts: string[]
277
+ /** Absolute path to the Android user images directory. */
278
+ userImagesPath: string
279
+ /** Normalized Android native project discovery overrides. */
280
+ project: NormalizedAndroidProjectConfig
281
+ }
282
+
283
+ export interface NormalizedVoltraIOSConfig {
284
+ /** Whether iOS push-notification-related setup should be applied. */
285
+ enablePushNotifications: boolean
286
+ /** App Group identifier used to share data between the app and extension. */
287
+ groupIdentifier?: string
288
+ /** iOS widgets after validation and normalization. */
289
+ widgets: NormalizedIOSWidgetConfig[]
290
+ /** Effective iOS deployment target for generated widget targets. */
291
+ deploymentTarget: string
292
+ /** Effective widget extension target name override, if provided. */
293
+ targetName?: string
294
+ /** Absolute font file paths for iOS widgets. */
295
+ fonts: string[]
296
+ /** Absolute path to the iOS user images directory. */
297
+ userImagesPath: string
298
+ /** Keychain access group shared by the app and extension. */
299
+ keychainGroup?: string
300
+ /** Normalized iOS native project discovery overrides. */
301
+ project: NormalizedIOSProjectConfig
302
+ }
303
+
304
+ export interface NormalizedVoltraConfig {
305
+ /** Absolute path to the loaded config file, when the config came from a file. */
306
+ configPath?: string
307
+ /** Directory that contained the loaded config source. */
308
+ configDir: string
309
+ /** Absolute root directory used for resolving all project-relative paths. */
310
+ projectRoot: string
311
+ /** Normalized Android-specific Voltra configuration. */
312
+ android?: NormalizedVoltraAndroidConfig
313
+ /** Normalized iOS-specific Voltra configuration. */
314
+ ios?: NormalizedVoltraIOSConfig
315
+ }
316
+
317
+ export type CliDefaults = typeof CLI_DEFAULTS
@@ -0,0 +1,21 @@
1
+ declare module 'cosmiconfig' {
2
+ export interface CosmiconfigResult {
3
+ config: unknown
4
+ filepath: string
5
+ isEmpty?: boolean
6
+ }
7
+
8
+ export interface CosmiconfigExplorer {
9
+ search(searchFrom?: string): Promise<CosmiconfigResult | null>
10
+ load(filepath: string): Promise<CosmiconfigResult | null>
11
+ }
12
+
13
+ export interface CosmiconfigOptions {
14
+ searchPlaces?: string[]
15
+ loaders?: Record<string, unknown>
16
+ }
17
+
18
+ export const defaultLoaders: Record<string, unknown>
19
+
20
+ export function cosmiconfig(moduleName: string, options?: CosmiconfigOptions): CosmiconfigExplorer
21
+ }
@@ -0,0 +1,75 @@
1
+ import path from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+
4
+ import type { VoltraPlatform } from '../config/types'
5
+
6
+ const PLATFORM_PACKAGE_NAMES: Record<VoltraPlatform, string> = {
7
+ android: '@use-voltra/android',
8
+ ios: '@use-voltra/ios',
9
+ }
10
+
11
+ const PLATFORM_CLIENT_PACKAGE_NAMES: Record<VoltraPlatform, string> = {
12
+ android: '@use-voltra/android-client',
13
+ ios: '@use-voltra/ios-client',
14
+ }
15
+
16
+ export function getPlatformPackageName(platform: VoltraPlatform): string {
17
+ return PLATFORM_PACKAGE_NAMES[platform]
18
+ }
19
+
20
+ export function getPlatformClientPackageName(platform: VoltraPlatform): string {
21
+ return PLATFORM_CLIENT_PACKAGE_NAMES[platform]
22
+ }
23
+
24
+ export function isPlatformPackageInstalled(projectRoot: string, platform: VoltraPlatform): boolean {
25
+ return getMissingPlatformPackages(projectRoot, platform).length === 0
26
+ }
27
+
28
+ export function getMissingPlatformPackages(projectRoot: string, platform: VoltraPlatform): string[] {
29
+ const projectRequire = createProjectRequire(projectRoot)
30
+ const packageNames = getRequiredPlatformPackageNames(platform)
31
+
32
+ return packageNames.filter((packageName) => !canResolvePackage(projectRequire, packageName))
33
+ }
34
+
35
+ export function requirePlatformPackage<TPackage>(projectRoot: string, platform: VoltraPlatform): TPackage {
36
+ return createProjectRequire(projectRoot)(getPlatformPackageName(platform)) as TPackage
37
+ }
38
+
39
+ export function getMissingPlatformPackageMessage(
40
+ platform: VoltraPlatform,
41
+ packageNames = getRequiredPlatformPackageNames(platform)
42
+ ): string {
43
+ const packageLabel = packageNames.length === 1 ? 'package' : 'packages'
44
+ const verb = packageNames.length === 1 ? 'is' : 'are'
45
+ const pronoun = packageNames.length === 1 ? 'it' : 'them'
46
+
47
+ return `Required ${packageLabel} ${formatPackageList(
48
+ packageNames
49
+ )} ${verb} not installed in the app project. Install ${pronoun} because voltra.config includes a ${platform} config block.`
50
+ }
51
+
52
+ function getRequiredPlatformPackageNames(platform: VoltraPlatform): string[] {
53
+ return [getPlatformPackageName(platform), getPlatformClientPackageName(platform)]
54
+ }
55
+
56
+ function canResolvePackage(projectRequire: NodeRequire, packageName: string): boolean {
57
+ try {
58
+ projectRequire.resolve(`${packageName}/package.json`)
59
+ return true
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ function formatPackageList(packageNames: string[]): string {
66
+ if (packageNames.length === 1) {
67
+ return packageNames[0]
68
+ }
69
+
70
+ return `${packageNames.slice(0, -1).join(', ')} and ${packageNames[packageNames.length - 1]}`
71
+ }
72
+
73
+ function createProjectRequire(projectRoot: string): NodeRequire {
74
+ return createRequire(path.join(projectRoot, 'package.json'))
75
+ }
@@ -0,0 +1,233 @@
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 { NormalizedAndroidProjectConfig } from '../config/types'
9
+
10
+ const ANDROID_MANIFEST_FILE_NAME = 'AndroidManifest.xml'
11
+
12
+ export interface AndroidProjectDiscovery {
13
+ androidRoot: string
14
+ appModuleName: string
15
+ appModuleRoot: string
16
+ manifestPath: string
17
+ buildGradlePath: string
18
+ packageName: string
19
+ }
20
+
21
+ export class AndroidProjectDiscoveryError extends VoltraCliError {
22
+ constructor(message: string) {
23
+ super(message, 'VOLTRA_ANDROID_DISCOVERY_FAILED')
24
+ this.name = 'AndroidProjectDiscoveryError'
25
+ }
26
+ }
27
+
28
+ export async function discoverAndroidProject(
29
+ projectRoot: string,
30
+ config: NormalizedAndroidProjectConfig
31
+ ): Promise<AndroidProjectDiscovery> {
32
+ const androidRoot = await resolveAndroidRoot(projectRoot, config)
33
+ const manifestPath = await resolveManifestPath(androidRoot, config)
34
+ const appModuleName = resolveAppModuleName(androidRoot, manifestPath, config.appModuleName)
35
+ const appModuleRoot = path.join(androidRoot, appModuleName)
36
+
37
+ await ensureDirectory(appModuleRoot, `Android app module directory does not exist: ${appModuleRoot}`)
38
+ ensureManifestBelongsToAppModule(appModuleRoot, manifestPath)
39
+
40
+ const buildGradlePath = await resolveBuildGradlePath(appModuleRoot)
41
+ const packageName = config.packageName ?? (await resolvePackageName(buildGradlePath, manifestPath))
42
+
43
+ return {
44
+ androidRoot,
45
+ appModuleName,
46
+ appModuleRoot,
47
+ manifestPath,
48
+ buildGradlePath,
49
+ packageName,
50
+ }
51
+ }
52
+
53
+ async function resolveAndroidRoot(projectRoot: string, config: NormalizedAndroidProjectConfig): Promise<string> {
54
+ const androidRoot = config.rootDir ?? path.join(projectRoot, 'android')
55
+
56
+ await ensureDirectory(
57
+ androidRoot,
58
+ config.rootDir
59
+ ? `Configured Android root directory does not exist: ${androidRoot}`
60
+ : `Android root directory does not exist at ${androidRoot}. Set android.project.rootDir to override the default android/ layout.`
61
+ )
62
+
63
+ return androidRoot
64
+ }
65
+
66
+ async function resolveManifestPath(androidRoot: string, config: NormalizedAndroidProjectConfig): Promise<string> {
67
+ const manifestPath =
68
+ config.manifestPath ??
69
+ path.join(androidRoot, config.appModuleName ?? 'app', 'src', 'main', ANDROID_MANIFEST_FILE_NAME)
70
+
71
+ await ensureFile(
72
+ manifestPath,
73
+ config.manifestPath
74
+ ? `Configured Android manifest does not exist: ${manifestPath}`
75
+ : `Android manifest does not exist at ${manifestPath}. Set android.project.appModuleName or android.project.manifestPath to override the default app/src/main/AndroidManifest.xml layout.`
76
+ )
77
+
78
+ return manifestPath
79
+ }
80
+
81
+ function resolveAppModuleName(
82
+ androidRoot: string,
83
+ manifestPath: string,
84
+ configuredAppModuleName: string | undefined
85
+ ): string {
86
+ if (configuredAppModuleName) {
87
+ return configuredAppModuleName
88
+ }
89
+
90
+ const relativeManifestPath = path.relative(androidRoot, manifestPath)
91
+
92
+ if (relativeManifestPath.startsWith('..') || path.isAbsolute(relativeManifestPath)) {
93
+ throw new AndroidProjectDiscoveryError(
94
+ `Android manifest ${manifestPath} is outside Android root ${androidRoot}. Set android.project.appModuleName to identify the app module explicitly.`
95
+ )
96
+ }
97
+
98
+ const segments = relativeManifestPath.split(path.sep)
99
+
100
+ if (
101
+ segments.length >= 4 &&
102
+ segments[1] === 'src' &&
103
+ segments[2] === 'main' &&
104
+ segments[3] === ANDROID_MANIFEST_FILE_NAME
105
+ ) {
106
+ return segments[0]
107
+ }
108
+
109
+ throw new AndroidProjectDiscoveryError(
110
+ `Could not derive Android app module from manifest path ${manifestPath}. Set android.project.appModuleName explicitly.`
111
+ )
112
+ }
113
+
114
+ async function resolveBuildGradlePath(appModuleRoot: string): Promise<string> {
115
+ const buildGradlePath = path.join(appModuleRoot, 'build.gradle')
116
+ const buildGradleKtsPath = path.join(appModuleRoot, 'build.gradle.kts')
117
+ const hasBuildGradle = await pathExists(buildGradlePath)
118
+ const hasBuildGradleKts = await pathExists(buildGradleKtsPath)
119
+
120
+ if (hasBuildGradle && hasBuildGradleKts) {
121
+ throw new AndroidProjectDiscoveryError(
122
+ `Android app module has both build.gradle and build.gradle.kts: ${appModuleRoot}. Remove the ambiguity before running voltra apply.`
123
+ )
124
+ }
125
+
126
+ if (hasBuildGradle) {
127
+ return buildGradlePath
128
+ }
129
+
130
+ if (hasBuildGradleKts) {
131
+ return buildGradleKtsPath
132
+ }
133
+
134
+ throw new AndroidProjectDiscoveryError(
135
+ `Android app module build file does not exist in ${appModuleRoot}. Expected build.gradle or build.gradle.kts.`
136
+ )
137
+ }
138
+
139
+ function ensureManifestBelongsToAppModule(appModuleRoot: string, manifestPath: string): void {
140
+ const relativeManifestPath = path.relative(appModuleRoot, manifestPath)
141
+
142
+ if (relativeManifestPath.startsWith('..') || path.isAbsolute(relativeManifestPath)) {
143
+ throw new AndroidProjectDiscoveryError(
144
+ `Android manifest ${manifestPath} is outside app module ${appModuleRoot}. Align android.project.appModuleName and android.project.manifestPath so they point at the same module.`
145
+ )
146
+ }
147
+ }
148
+
149
+ async function resolvePackageName(buildGradlePath: string, manifestPath: string): Promise<string> {
150
+ const buildGradle = stripGradleComments(await fs.readFile(buildGradlePath, 'utf8'))
151
+ const namespace = matchGradleStringLiteral(buildGradle, 'namespace')
152
+
153
+ if (namespace) {
154
+ return namespace
155
+ }
156
+
157
+ const applicationId = matchGradleStringLiteral(buildGradle, 'applicationId')
158
+
159
+ if (applicationId) {
160
+ return applicationId
161
+ }
162
+
163
+ const manifest = await fs.readFile(manifestPath, 'utf8')
164
+ const manifestPackage = matchManifestPackage(manifest)
165
+
166
+ if (manifestPackage) {
167
+ return manifestPackage
168
+ }
169
+
170
+ throw new AndroidProjectDiscoveryError(
171
+ `Could not determine Android package name from ${buildGradlePath} or ${manifestPath}. Set android.project.packageName explicitly or add namespace/applicationId to the app module build file.`
172
+ )
173
+ }
174
+
175
+ function matchGradleStringLiteral(content: string, propertyName: string): string | undefined {
176
+ const match = content.match(new RegExp(`\\b${propertyName}\\s*(?:=)?\\s*['"]([^'"]+)['"]`))
177
+
178
+ return match?.[1]
179
+ }
180
+
181
+ function stripGradleComments(content: string): string {
182
+ return content.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
183
+ }
184
+
185
+ function matchManifestPackage(content: string): string | undefined {
186
+ const match = content.match(/<manifest\b[^>]*\bpackage\s*=\s*['"]([^'"]+)['"]/)
187
+
188
+ return match?.[1]
189
+ }
190
+
191
+ async function ensureDirectory(dirPath: string, message: string): Promise<void> {
192
+ const stat = await readPathStat(dirPath)
193
+
194
+ if (!stat) {
195
+ throw new AndroidProjectDiscoveryError(message)
196
+ }
197
+
198
+ if (!stat.isDirectory()) {
199
+ throw new AndroidProjectDiscoveryError(`Expected a directory but found a file: ${dirPath}`)
200
+ }
201
+ }
202
+
203
+ async function ensureFile(filePath: string, message: string): Promise<void> {
204
+ const stat = await readPathStat(filePath)
205
+
206
+ if (!stat) {
207
+ throw new AndroidProjectDiscoveryError(message)
208
+ }
209
+
210
+ if (!stat.isFile()) {
211
+ throw new AndroidProjectDiscoveryError(`Expected a file but found a directory: ${filePath}`)
212
+ }
213
+ }
214
+
215
+ async function pathExists(targetPath: string): Promise<boolean> {
216
+ return (await readPathStat(targetPath)) !== undefined
217
+ }
218
+
219
+ async function readPathStat(targetPath: string): Promise<Stats | undefined> {
220
+ try {
221
+ return await fs.stat(targetPath)
222
+ } catch (error: unknown) {
223
+ if (isNotFoundError(error)) {
224
+ return undefined
225
+ }
226
+
227
+ throw error
228
+ }
229
+ }
230
+
231
+ function isNotFoundError(error: unknown): error is NodeJS.ErrnoException {
232
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
233
+ }