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,100 @@
1
+ import { dirname, resolve } from 'node:path'
2
+
3
+ import { cosmiconfig } from 'cosmiconfig'
4
+
5
+ import type { CosmiconfigResult } from 'cosmiconfig'
6
+
7
+ import type { LoadedVoltraConfig, VoltraConfig } from './types'
8
+
9
+ const MODULE_NAME = 'voltra'
10
+
11
+ export class VoltraConfigLoadError extends Error {
12
+ cause?: Error
13
+
14
+ constructor(message: string, cause?: Error) {
15
+ super(message)
16
+ this.name = 'VoltraConfigLoadError'
17
+ this.cause = cause
18
+ }
19
+ }
20
+
21
+ export interface LoadVoltraConfigOptions {
22
+ configPath?: string
23
+ cwd?: string
24
+ }
25
+
26
+ function getExplorer() {
27
+ return cosmiconfig(MODULE_NAME, {
28
+ searchPlaces: [
29
+ 'package.json',
30
+ '.voltrarc',
31
+ '.voltrarc.json',
32
+ '.voltrarc.yaml',
33
+ '.voltrarc.yml',
34
+ '.voltrarc.js',
35
+ '.voltrarc.cjs',
36
+ '.voltrarc.mjs',
37
+ '.voltrarc.ts',
38
+ 'voltra.config.json',
39
+ 'voltra.config.yaml',
40
+ 'voltra.config.yml',
41
+ 'voltra.config.js',
42
+ 'voltra.config.cjs',
43
+ 'voltra.config.mjs',
44
+ 'voltra.config.ts',
45
+ ],
46
+ })
47
+ }
48
+
49
+ function toLoadedConfig(result: CosmiconfigResult): LoadedVoltraConfig {
50
+ if (result.isEmpty) {
51
+ throw new VoltraConfigLoadError(`Voltra config file is empty: ${result.filepath}`)
52
+ }
53
+
54
+ if (!result.config || typeof result.config !== 'object' || Array.isArray(result.config)) {
55
+ throw new VoltraConfigLoadError(`Voltra config must be an object: ${result.filepath}`)
56
+ }
57
+
58
+ return {
59
+ config: result.config as VoltraConfig,
60
+ configPath: result.filepath,
61
+ configDir: dirname(result.filepath),
62
+ }
63
+ }
64
+
65
+ export async function loadVoltraConfig(options: LoadVoltraConfigOptions = {}): Promise<LoadedVoltraConfig> {
66
+ const explorer = getExplorer()
67
+
68
+ try {
69
+ if (options.configPath) {
70
+ const configPath = resolve(options.cwd ?? process.cwd(), options.configPath)
71
+ const result = await explorer.load(configPath)
72
+
73
+ if (!result) {
74
+ throw new VoltraConfigLoadError(`Voltra config not found at ${configPath}`)
75
+ }
76
+
77
+ return toLoadedConfig(result)
78
+ }
79
+
80
+ const result = await explorer.search(options.cwd)
81
+
82
+ if (!result) {
83
+ throw new VoltraConfigLoadError(
84
+ 'No Voltra config found. Checked package.json, .voltrarc*, and voltra.config.* files.'
85
+ )
86
+ }
87
+
88
+ return toLoadedConfig(result)
89
+ } catch (error) {
90
+ if (error instanceof VoltraConfigLoadError) {
91
+ throw error
92
+ }
93
+
94
+ const message = error instanceof Error ? error.message : String(error)
95
+ throw new VoltraConfigLoadError(
96
+ `Failed to load Voltra config: ${message}`,
97
+ error instanceof Error ? error : undefined
98
+ )
99
+ }
100
+ }
@@ -0,0 +1,562 @@
1
+ import path from 'node:path'
2
+
3
+ import { resolveFromRoot } from '../fs/path'
4
+ import { CLI_DEFAULTS } from './defaults'
5
+
6
+ import type {
7
+ AndroidWidgetAppIntentConfig,
8
+ AndroidWidgetConfig,
9
+ IOSWidgetAppIntentConfig,
10
+ IOSWidgetConfig,
11
+ LoadedVoltraConfig,
12
+ NormalizedAndroidWidgetConfig,
13
+ NormalizedVoltraAndroidConfig,
14
+ NormalizedVoltraConfig,
15
+ NormalizedVoltraIOSConfig,
16
+ NormalizedIOSWidgetConfig,
17
+ WidgetInitialStatePath,
18
+ WidgetLabel,
19
+ WidgetLocalizedValue,
20
+ } from './types'
21
+
22
+ const VALID_IOS_WIDGET_FAMILIES = new Set([
23
+ 'systemSmall',
24
+ 'systemMedium',
25
+ 'systemLarge',
26
+ 'systemExtraLarge',
27
+ 'accessoryCircular',
28
+ 'accessoryRectangular',
29
+ 'accessoryInline',
30
+ ])
31
+ const WIDGET_ENTRY_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'])
32
+
33
+ export class VoltraConfigNormalizationError extends Error {
34
+ constructor(message: string) {
35
+ super(message)
36
+ this.name = 'VoltraConfigNormalizationError'
37
+ }
38
+ }
39
+
40
+ function assertObject(value: unknown, context: string): void {
41
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
42
+ throw new VoltraConfigNormalizationError(`${context} must be an object`)
43
+ }
44
+ }
45
+
46
+ function assertRecord(value: unknown, context: string): asserts value is Record<string, unknown> {
47
+ assertObject(value, context)
48
+ }
49
+
50
+ function assertOptionalString(value: unknown, context: string): asserts value is string | undefined {
51
+ if (value !== undefined && typeof value !== 'string') {
52
+ throw new VoltraConfigNormalizationError(`${context} must be a string`)
53
+ }
54
+ }
55
+
56
+ function assertOptionalBoolean(value: unknown, context: string): asserts value is boolean | undefined {
57
+ if (value !== undefined && typeof value !== 'boolean') {
58
+ throw new VoltraConfigNormalizationError(`${context} must be a boolean`)
59
+ }
60
+ }
61
+
62
+ function assertPositiveInteger(value: unknown, context: string): asserts value is number {
63
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
64
+ throw new VoltraConfigNormalizationError(`${context} must be a positive integer`)
65
+ }
66
+ }
67
+
68
+ function assertOptionalPositiveInteger(value: unknown, context: string): asserts value is number | undefined {
69
+ if (value !== undefined) {
70
+ assertPositiveInteger(value, context)
71
+ }
72
+ }
73
+
74
+ function assertNonEmptyString(value: unknown, context: string): asserts value is string {
75
+ if (typeof value !== 'string' || !value.trim()) {
76
+ throw new VoltraConfigNormalizationError(`${context} must be a non-empty string`)
77
+ }
78
+ }
79
+
80
+ function assertOptionalStringArray(value: unknown, context: string): asserts value is string[] | undefined {
81
+ if (value === undefined) {
82
+ return
83
+ }
84
+
85
+ if (!Array.isArray(value)) {
86
+ throw new VoltraConfigNormalizationError(`${context} must be an array of strings`)
87
+ }
88
+
89
+ for (const entry of value) {
90
+ if (typeof entry !== 'string' || !entry.trim()) {
91
+ throw new VoltraConfigNormalizationError(`${context} must contain only non-empty strings`)
92
+ }
93
+ }
94
+ }
95
+
96
+ function resolvePathFromProjectRoot(projectRoot: string, filePath: string): string {
97
+ return path.isAbsolute(filePath) ? filePath : resolveFromRoot(projectRoot, filePath)
98
+ }
99
+
100
+ function resolveOptionalPathFromProjectRoot(projectRoot: string, filePath: string | undefined): string | undefined {
101
+ if (!filePath) {
102
+ return undefined
103
+ }
104
+
105
+ return resolvePathFromProjectRoot(projectRoot, filePath)
106
+ }
107
+
108
+ function isAbsoluteWidgetPath(value: string): boolean {
109
+ return path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)
110
+ }
111
+
112
+ function normalizeWidgetEntry(entry: string): string {
113
+ return path.posix.normalize(entry.replace(/\\/g, '/'))
114
+ }
115
+
116
+ function normalizeWidgetEntryPath(entry: string): string {
117
+ return normalizeWidgetEntry(entry)
118
+ }
119
+
120
+ function normalizeOptionalWidgetEntry(value: unknown, context: string): string | undefined {
121
+ if (value === undefined) {
122
+ return undefined
123
+ }
124
+
125
+ assertNonEmptyString(value, context)
126
+
127
+ if (isAbsoluteWidgetPath(value)) {
128
+ throw new VoltraConfigNormalizationError(`${context} must be a relative path, not an absolute path`)
129
+ }
130
+
131
+ const normalizedEntry = normalizeWidgetEntryPath(value)
132
+
133
+ if (!normalizedEntry || normalizedEntry === '.') {
134
+ throw new VoltraConfigNormalizationError(`${context} must point to a file inside the project root`)
135
+ }
136
+
137
+ if (normalizedEntry === '..' || normalizedEntry.startsWith('../')) {
138
+ throw new VoltraConfigNormalizationError(`${context} must stay within the project root after normalization`)
139
+ }
140
+
141
+ const extension = path.posix.extname(normalizedEntry)
142
+ if (!WIDGET_ENTRY_EXTENSIONS.has(extension)) {
143
+ throw new VoltraConfigNormalizationError(
144
+ `${context} must use a Metro-importable source extension (${Array.from(WIDGET_ENTRY_EXTENSIONS).join(
145
+ ', '
146
+ )}); received '${extension || '(none)'}'`
147
+ )
148
+ }
149
+
150
+ return normalizedEntry
151
+ }
152
+
153
+ function normalizeLocalizedPathMap(
154
+ projectRoot: string,
155
+ value: WidgetLocalizedValue,
156
+ context: string
157
+ ): WidgetLocalizedValue {
158
+ const entries = Object.entries(value)
159
+
160
+ if (entries.length === 0) {
161
+ throw new VoltraConfigNormalizationError(`${context} must not be empty`)
162
+ }
163
+
164
+ return Object.fromEntries(
165
+ entries.map(([locale, localePath]) => {
166
+ assertNonEmptyString(locale, `${context} locale key`)
167
+ assertNonEmptyString(localePath, `${context}.${locale}`)
168
+ return [locale, resolvePathFromProjectRoot(projectRoot, localePath)]
169
+ })
170
+ )
171
+ }
172
+
173
+ function normalizeLabel(value: WidgetLabel, context: string): WidgetLabel {
174
+ if (typeof value === 'string') {
175
+ assertNonEmptyString(value, context)
176
+ return value
177
+ }
178
+
179
+ assertRecord(value, context)
180
+
181
+ const entries = Object.entries(value)
182
+ if (entries.length === 0) {
183
+ throw new VoltraConfigNormalizationError(`${context} must not be empty`)
184
+ }
185
+
186
+ return Object.fromEntries(
187
+ entries.map(([locale, label]) => {
188
+ assertNonEmptyString(locale, `${context} locale key`)
189
+ if (typeof label !== 'string') {
190
+ throw new VoltraConfigNormalizationError(`${context}.${locale} must be a string`)
191
+ }
192
+ assertNonEmptyString(label, `${context}.${locale}`)
193
+ return [locale, label]
194
+ })
195
+ )
196
+ }
197
+
198
+ function normalizeInitialStatePath(
199
+ projectRoot: string,
200
+ value: WidgetInitialStatePath | undefined,
201
+ context: string
202
+ ): WidgetInitialStatePath | undefined {
203
+ if (value === undefined) {
204
+ return undefined
205
+ }
206
+
207
+ if (typeof value === 'string') {
208
+ assertNonEmptyString(value, context)
209
+ return resolvePathFromProjectRoot(projectRoot, value)
210
+ }
211
+
212
+ assertRecord(value, context)
213
+ return normalizeLocalizedPathMap(projectRoot, value, context)
214
+ }
215
+
216
+ function normalizeWidgetParameterName(value: unknown, context: string): string {
217
+ assertNonEmptyString(value, context)
218
+ return value
219
+ }
220
+
221
+ function normalizeAndroidAppIntent(
222
+ value: AndroidWidgetAppIntentConfig | undefined,
223
+ context: string
224
+ ): AndroidWidgetAppIntentConfig | undefined {
225
+ if (value === undefined) {
226
+ return undefined
227
+ }
228
+
229
+ assertObject(value, context)
230
+
231
+ if (!Array.isArray(value.parameters)) {
232
+ throw new VoltraConfigNormalizationError(`${context}.parameters must be an array`)
233
+ }
234
+
235
+ const seenNames = new Set<string>()
236
+ const parameters = value.parameters.map((parameter, index) => {
237
+ const parameterContext = `${context}.parameters[${index}]`
238
+ assertObject(parameter, parameterContext)
239
+ const name = normalizeWidgetParameterName(parameter.name, `${parameterContext}.name`)
240
+ assertOptionalString(parameter.title, `${parameterContext}.title`)
241
+ assertOptionalString(parameter.default, `${parameterContext}.default`)
242
+
243
+ if (seenNames.has(name)) {
244
+ throw new VoltraConfigNormalizationError(`${context}.parameters contains duplicate name '${name}'`)
245
+ }
246
+
247
+ seenNames.add(name)
248
+
249
+ return {
250
+ name,
251
+ title: parameter.title,
252
+ default: parameter.default,
253
+ }
254
+ })
255
+
256
+ return { parameters }
257
+ }
258
+
259
+ function normalizeIOSAppIntent(
260
+ value: IOSWidgetAppIntentConfig | undefined,
261
+ context: string
262
+ ): IOSWidgetAppIntentConfig | undefined {
263
+ if (value === undefined) {
264
+ return undefined
265
+ }
266
+
267
+ assertObject(value, context)
268
+
269
+ if (!Array.isArray(value.parameters)) {
270
+ throw new VoltraConfigNormalizationError(`${context}.parameters must be an array`)
271
+ }
272
+
273
+ const seenNames = new Set<string>()
274
+ const parameters = value.parameters.map((parameter, index) => {
275
+ const parameterContext = `${context}.parameters[${index}]`
276
+ assertObject(parameter, parameterContext)
277
+ const name = normalizeWidgetParameterName(parameter.name, `${parameterContext}.name`)
278
+ assertNonEmptyString(parameter.title, `${parameterContext}.title`)
279
+ assertOptionalString(parameter.default, `${parameterContext}.default`)
280
+
281
+ if (seenNames.has(name)) {
282
+ throw new VoltraConfigNormalizationError(`${context}.parameters contains duplicate name '${name}'`)
283
+ }
284
+
285
+ seenNames.add(name)
286
+
287
+ return {
288
+ name,
289
+ title: parameter.title,
290
+ default: parameter.default,
291
+ }
292
+ })
293
+
294
+ return { parameters }
295
+ }
296
+
297
+ function normalizeServerUpdate(
298
+ serverUpdate: { url: string; intervalMinutes?: number; refresh?: boolean },
299
+ context: string,
300
+ defaultIntervalMinutes: number,
301
+ defaultRefresh: boolean,
302
+ minimumIntervalMinutes: number
303
+ ): { url: string; intervalMinutes: number; refresh: boolean } {
304
+ assertObject(serverUpdate, context)
305
+ assertNonEmptyString(serverUpdate.url, `${context}.url`)
306
+
307
+ if (serverUpdate.intervalMinutes !== undefined) {
308
+ if (typeof serverUpdate.intervalMinutes !== 'number' || !Number.isFinite(serverUpdate.intervalMinutes)) {
309
+ throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be a number`)
310
+ }
311
+
312
+ if (!Number.isInteger(serverUpdate.intervalMinutes)) {
313
+ throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be an integer`)
314
+ }
315
+
316
+ if (serverUpdate.intervalMinutes < minimumIntervalMinutes) {
317
+ throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be at least ${minimumIntervalMinutes}`)
318
+ }
319
+ }
320
+
321
+ if (serverUpdate.refresh !== undefined && typeof serverUpdate.refresh !== 'boolean') {
322
+ throw new VoltraConfigNormalizationError(`${context}.refresh must be a boolean`)
323
+ }
324
+
325
+ return {
326
+ url: serverUpdate.url,
327
+ intervalMinutes: serverUpdate.intervalMinutes ?? defaultIntervalMinutes,
328
+ refresh: serverUpdate.refresh ?? defaultRefresh,
329
+ }
330
+ }
331
+
332
+ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig): NormalizedAndroidWidgetConfig {
333
+ assertObject(widget, 'android.widgets[]')
334
+ assertNonEmptyString(widget.id, 'android.widgets[].id')
335
+ assertValidWidgetId(widget.id, 'android.widgets[].id')
336
+ assertPositiveInteger(widget.targetCellWidth, `android.widgets[${widget.id}].targetCellWidth`)
337
+ assertPositiveInteger(widget.targetCellHeight, `android.widgets[${widget.id}].targetCellHeight`)
338
+ assertOptionalPositiveInteger(widget.minCellWidth, `android.widgets[${widget.id}].minCellWidth`)
339
+ assertOptionalPositiveInteger(widget.minCellHeight, `android.widgets[${widget.id}].minCellHeight`)
340
+
341
+ return {
342
+ ...widget,
343
+ displayName: normalizeLabel(widget.displayName, `android.widgets[${widget.id}].displayName`),
344
+ description: normalizeLabel(widget.description, `android.widgets[${widget.id}].description`),
345
+ entry: normalizeOptionalWidgetEntry(widget.entry, `android.widgets[${widget.id}].entry`),
346
+ initialStatePath: normalizeInitialStatePath(
347
+ projectRoot,
348
+ widget.initialStatePath,
349
+ `android.widgets[${widget.id}].initialStatePath`
350
+ ),
351
+ previewImage: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewImage),
352
+ previewLayout: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewLayout),
353
+ appIntent: normalizeAndroidAppIntent(widget.appIntent, `android.widgets[${widget.id}].appIntent`),
354
+ serverUpdate: widget.serverUpdate
355
+ ? normalizeServerUpdate(
356
+ widget.serverUpdate,
357
+ `android.widgets[${widget.id}].serverUpdate`,
358
+ CLI_DEFAULTS.android.serverUpdateIntervalMinutes,
359
+ CLI_DEFAULTS.android.serverUpdateRefresh,
360
+ 15
361
+ )
362
+ : undefined,
363
+ }
364
+ }
365
+
366
+ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): NormalizedIOSWidgetConfig {
367
+ assertObject(widget, 'ios.widgets[]')
368
+ assertNonEmptyString(widget.id, 'ios.widgets[].id')
369
+ assertValidWidgetId(widget.id, 'ios.widgets[].id')
370
+
371
+ if (widget.supportedFamilies !== undefined) {
372
+ if (!Array.isArray(widget.supportedFamilies)) {
373
+ throw new VoltraConfigNormalizationError(`ios.widgets[${widget.id}].supportedFamilies must be an array`)
374
+ }
375
+
376
+ for (const family of widget.supportedFamilies) {
377
+ if (!VALID_IOS_WIDGET_FAMILIES.has(family)) {
378
+ throw new VoltraConfigNormalizationError(
379
+ `ios.widgets[${widget.id}].supportedFamilies contains invalid family '${family}'`
380
+ )
381
+ }
382
+ }
383
+ }
384
+
385
+ return {
386
+ ...widget,
387
+ displayName: normalizeLabel(widget.displayName, `ios.widgets[${widget.id}].displayName`),
388
+ description: normalizeLabel(widget.description, `ios.widgets[${widget.id}].description`),
389
+ supportedFamilies: widget.supportedFamilies ?? [...CLI_DEFAULTS.ios.widgetFamilies],
390
+ entry: normalizeOptionalWidgetEntry(widget.entry, `ios.widgets[${widget.id}].entry`),
391
+ initialStatePath: normalizeInitialStatePath(
392
+ projectRoot,
393
+ widget.initialStatePath,
394
+ `ios.widgets[${widget.id}].initialStatePath`
395
+ ),
396
+ appIntent: normalizeIOSAppIntent(widget.appIntent, `ios.widgets[${widget.id}].appIntent`),
397
+ serverUpdate: widget.serverUpdate
398
+ ? normalizeServerUpdate(
399
+ widget.serverUpdate,
400
+ `ios.widgets[${widget.id}].serverUpdate`,
401
+ CLI_DEFAULTS.ios.serverUpdateIntervalMinutes,
402
+ CLI_DEFAULTS.ios.serverUpdateRefresh,
403
+ 1
404
+ )
405
+ : undefined,
406
+ }
407
+ }
408
+
409
+ function assertUniqueWidgetIds(widgetIds: string[], context: string): void {
410
+ const seen = new Set<string>()
411
+
412
+ for (const widgetId of widgetIds) {
413
+ if (seen.has(widgetId)) {
414
+ throw new VoltraConfigNormalizationError(`Duplicate ${context} widget ID '${widgetId}'`)
415
+ }
416
+
417
+ seen.add(widgetId)
418
+ }
419
+ }
420
+
421
+ function assertValidWidgetId(widgetId: string, context: string): void {
422
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widgetId)) {
423
+ throw new VoltraConfigNormalizationError(
424
+ `${context} must start with a letter or underscore and contain only alphanumeric characters and underscores`
425
+ )
426
+ }
427
+ }
428
+
429
+ function assertValidIOSTargetName(targetName: string, context: string): void {
430
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(targetName)) {
431
+ throw new VoltraConfigNormalizationError(
432
+ `${context} must start with a letter and contain only letters, numbers, and underscores`
433
+ )
434
+ }
435
+ }
436
+
437
+ function normalizeAndroidConfig(
438
+ projectRoot: string,
439
+ config: LoadedVoltraConfig['config']['android']
440
+ ): NormalizedVoltraAndroidConfig | undefined {
441
+ if (config === undefined) {
442
+ return undefined
443
+ }
444
+
445
+ assertObject(config, 'android')
446
+ assertOptionalBoolean(config.enableNotifications, 'android.enableNotifications')
447
+ assertOptionalStringArray(config.fonts, 'android.fonts')
448
+ assertOptionalString(config.userImagesPath, 'android.userImagesPath')
449
+
450
+ if (config.project !== undefined) {
451
+ assertObject(config.project, 'android.project')
452
+ assertOptionalString(config.project.rootDir, 'android.project.rootDir')
453
+ assertOptionalString(config.project.appModuleName, 'android.project.appModuleName')
454
+ assertOptionalString(config.project.manifestPath, 'android.project.manifestPath')
455
+ assertOptionalString(config.project.packageName, 'android.project.packageName')
456
+ }
457
+
458
+ if (config.widgets !== undefined && !Array.isArray(config.widgets)) {
459
+ throw new VoltraConfigNormalizationError('android.widgets must be an array')
460
+ }
461
+
462
+ const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget))
463
+ assertUniqueWidgetIds(
464
+ widgets.map((widget) => widget.id),
465
+ 'android'
466
+ )
467
+
468
+ return {
469
+ enableNotifications: config.enableNotifications ?? CLI_DEFAULTS.android.enableNotifications,
470
+ widgets,
471
+ fonts: (config.fonts ?? []).map((fontPath) => resolvePathFromProjectRoot(projectRoot, fontPath)),
472
+ userImagesPath: resolvePathFromProjectRoot(
473
+ projectRoot,
474
+ config.userImagesPath ?? CLI_DEFAULTS.android.userImagesPath
475
+ ),
476
+ project: {
477
+ rootDir: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.rootDir),
478
+ appModuleName: config.project?.appModuleName,
479
+ manifestPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.manifestPath),
480
+ packageName: config.project?.packageName,
481
+ },
482
+ }
483
+ }
484
+
485
+ function normalizeIOSConfig(
486
+ projectRoot: string,
487
+ config: LoadedVoltraConfig['config']['ios']
488
+ ): NormalizedVoltraIOSConfig | undefined {
489
+ if (config === undefined) {
490
+ return undefined
491
+ }
492
+
493
+ assertObject(config, 'ios')
494
+ assertOptionalBoolean(config.enablePushNotifications, 'ios.enablePushNotifications')
495
+ assertOptionalString(config.groupIdentifier, 'ios.groupIdentifier')
496
+ assertOptionalString(config.deploymentTarget, 'ios.deploymentTarget')
497
+ assertOptionalString(config.targetName, 'ios.targetName')
498
+ assertOptionalStringArray(config.fonts, 'ios.fonts')
499
+ assertOptionalString(config.userImagesPath, 'ios.userImagesPath')
500
+ assertOptionalString(config.keychainGroup, 'ios.keychainGroup')
501
+
502
+ if (config.project !== undefined) {
503
+ assertObject(config.project, 'ios.project')
504
+ assertOptionalString(config.project.rootDir, 'ios.project.rootDir')
505
+ assertOptionalString(config.project.xcodeprojPath, 'ios.project.xcodeprojPath')
506
+ assertOptionalString(config.project.mainTargetName, 'ios.project.mainTargetName')
507
+ assertOptionalString(config.project.infoPlistPath, 'ios.project.infoPlistPath')
508
+ assertOptionalString(config.project.entitlementsPath, 'ios.project.entitlementsPath')
509
+ assertOptionalString(config.project.podfilePath, 'ios.project.podfilePath')
510
+ }
511
+
512
+ if (config.widgets !== undefined && !Array.isArray(config.widgets)) {
513
+ throw new VoltraConfigNormalizationError('ios.widgets must be an array')
514
+ }
515
+
516
+ if (config.targetName !== undefined) {
517
+ assertValidIOSTargetName(config.targetName, 'ios.targetName')
518
+ }
519
+
520
+ const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget))
521
+ assertUniqueWidgetIds(
522
+ widgets.map((widget) => widget.id),
523
+ 'ios'
524
+ )
525
+
526
+ return {
527
+ enablePushNotifications: config.enablePushNotifications ?? CLI_DEFAULTS.ios.enablePushNotifications,
528
+ groupIdentifier: config.groupIdentifier,
529
+ widgets,
530
+ deploymentTarget: config.deploymentTarget ?? CLI_DEFAULTS.ios.deploymentTarget,
531
+ targetName: config.targetName,
532
+ fonts: (config.fonts ?? []).map((fontPath) => resolvePathFromProjectRoot(projectRoot, fontPath)),
533
+ userImagesPath: resolvePathFromProjectRoot(projectRoot, config.userImagesPath ?? CLI_DEFAULTS.ios.userImagesPath),
534
+ keychainGroup: config.keychainGroup,
535
+ project: {
536
+ rootDir: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.rootDir),
537
+ xcodeprojPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.xcodeprojPath),
538
+ mainTargetName: config.project?.mainTargetName,
539
+ infoPlistPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.infoPlistPath),
540
+ entitlementsPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.entitlementsPath),
541
+ podfilePath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.podfilePath),
542
+ },
543
+ }
544
+ }
545
+
546
+ export function normalizeVoltraConfig(loadedConfig: LoadedVoltraConfig): NormalizedVoltraConfig {
547
+ assertObject(loadedConfig.config, 'config')
548
+ assertOptionalString(loadedConfig.config.projectRoot, 'projectRoot')
549
+
550
+ const projectRoot = resolvePathFromProjectRoot(
551
+ loadedConfig.configDir,
552
+ loadedConfig.config.projectRoot ?? loadedConfig.configDir
553
+ )
554
+
555
+ return {
556
+ configPath: loadedConfig.configPath,
557
+ configDir: loadedConfig.configDir,
558
+ projectRoot,
559
+ android: normalizeAndroidConfig(projectRoot, loadedConfig.config.android),
560
+ ios: normalizeIOSConfig(projectRoot, loadedConfig.config.ios),
561
+ }
562
+ }