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,11 @@
1
+ import path from 'node:path'
2
+
3
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
4
+
5
+ export function resolveMainAppEntitlementsPath(discovery: IOSProjectDiscovery): string {
6
+ if (discovery.entitlementsPath) {
7
+ return discovery.entitlementsPath
8
+ }
9
+
10
+ return path.join(path.dirname(discovery.infoPlistPath), `${discovery.mainTargetName}.entitlements`)
11
+ }
@@ -0,0 +1,321 @@
1
+ import { parseStringPromise } from 'xml2js'
2
+
3
+ import { readTextFile, writeTextFile } from '../../fs/readWrite'
4
+ import { toRelativePath } from '../../fs/path'
5
+ import { VoltraCliError } from '../../reporting/summary'
6
+
7
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
8
+ import type { NormalizedVoltraIOSConfig } from '../../config/types'
9
+ import type { ReportedChange } from '../../reporting/summary'
10
+
11
+ interface OrderedPlistNode {
12
+ '#name'?: string
13
+ _?: string
14
+ $$?: OrderedPlistNode[]
15
+ }
16
+
17
+ interface TaggedPlistScalar {
18
+ __voltraPlistScalarType: 'data' | 'date'
19
+ value: string
20
+ }
21
+
22
+ type PlistErrorFactory = (message: string) => Error
23
+
24
+ export interface EnsureInfoPlistOptions {
25
+ projectRoot: string
26
+ ios: NormalizedVoltraIOSConfig
27
+ discovery: IOSProjectDiscovery
28
+ }
29
+
30
+ export interface EnsureInfoPlistResult {
31
+ change?: ReportedChange
32
+ }
33
+
34
+ export class IOSInfoPlistMutationError extends VoltraCliError {
35
+ constructor(message: string) {
36
+ super(message, 'VOLTRA_IOS_INFO_PLIST_FAILED')
37
+ this.name = 'IOSInfoPlistMutationError'
38
+ }
39
+ }
40
+
41
+ export async function ensureInfoPlist(options: EnsureInfoPlistOptions): Promise<EnsureInfoPlistResult> {
42
+ const { projectRoot, ios, discovery } = options
43
+ const infoPlist = await parsePlistFile(discovery.infoPlistPath, 'main app Info.plist', createInfoPlistError)
44
+
45
+ infoPlist.NSSupportsLiveActivities = true
46
+ infoPlist.NSSupportsLiveActivitiesFrequentUpdates = false
47
+
48
+ setOrDeleteVoltraKey(infoPlist, 'Voltra_AppGroupIdentifier', ios.groupIdentifier)
49
+ setOrDeleteVoltraKey(infoPlist, 'Voltra_KeychainGroup', ios.keychainGroup)
50
+ setOrDeleteVoltraKey(infoPlist, 'Voltra_EnablePushNotifications', ios.enablePushNotifications ? true : undefined)
51
+
52
+ const widgetIds = ios.widgets.map((widget) => widget.id)
53
+ setOrDeleteVoltraKey(infoPlist, 'Voltra_WidgetIds', widgetIds.length > 0 ? widgetIds : undefined)
54
+
55
+ const serverWidgets = ios.widgets.filter((widget) => widget.serverUpdate)
56
+ const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url]))
57
+ const serverIntervals = Object.fromEntries(
58
+ serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])
59
+ )
60
+
61
+ setOrDeleteVoltraKey(
62
+ infoPlist,
63
+ 'Voltra_WidgetServerUrls',
64
+ Object.keys(serverUrls).length > 0 ? serverUrls : undefined
65
+ )
66
+ setOrDeleteVoltraKey(
67
+ infoPlist,
68
+ 'Voltra_WidgetServerIntervals',
69
+ Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined
70
+ )
71
+
72
+ const nextContent = buildPlistXml(infoPlist, createInfoPlistError)
73
+ const change = await writePlistIfChanged(projectRoot, discovery.infoPlistPath, nextContent)
74
+
75
+ return { change }
76
+ }
77
+
78
+ export async function parsePlistFile(
79
+ filePath: string,
80
+ errorContext: string,
81
+ createError: PlistErrorFactory
82
+ ): Promise<Record<string, unknown>> {
83
+ let content: string
84
+
85
+ try {
86
+ content = await readTextFile(filePath)
87
+ } catch (error: unknown) {
88
+ throw createError(`Failed to read ${errorContext} at ${filePath}: ${getErrorMessage(error)}`)
89
+ }
90
+
91
+ let parsed: unknown
92
+
93
+ try {
94
+ parsed = await parseStringPromise(content, {
95
+ explicitArray: false,
96
+ explicitChildren: true,
97
+ preserveChildrenOrder: true,
98
+ })
99
+ } catch (error: unknown) {
100
+ throw createError(`Failed to parse ${errorContext} at ${filePath}: ${getErrorMessage(error)}`)
101
+ }
102
+
103
+ const plistRoot = getPlistRoot(parsed, errorContext, filePath, createError)
104
+ return parsePlistDict(plistRoot, errorContext, filePath, createError)
105
+ }
106
+
107
+ export function buildPlistXml(value: unknown, createError: PlistErrorFactory): string {
108
+ try {
109
+ return [
110
+ '<?xml version="1.0" encoding="UTF-8"?>',
111
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
112
+ '<plist version="1.0">',
113
+ renderPlistValue(value, 0, createError),
114
+ '</plist>',
115
+ '',
116
+ ].join('\n')
117
+ } catch (error: unknown) {
118
+ if (error instanceof Error) {
119
+ throw error
120
+ }
121
+
122
+ throw createError(`Failed to build plist XML: ${String(error)}`)
123
+ }
124
+ }
125
+
126
+ function getPlistRoot(
127
+ value: unknown,
128
+ errorContext: string,
129
+ filePath: string,
130
+ createError: PlistErrorFactory
131
+ ): OrderedPlistNode {
132
+ if (!value || typeof value !== 'object' || !('plist' in value)) {
133
+ throw createError(`Parsed ${errorContext} at ${filePath} is missing the plist root.`)
134
+ }
135
+
136
+ const plistValue = (value as { plist?: { dict?: unknown } }).plist
137
+
138
+ if (!plistValue || typeof plistValue !== 'object' || !('dict' in plistValue)) {
139
+ throw createError(`Parsed ${errorContext} at ${filePath} is missing the root dict.`)
140
+ }
141
+
142
+ if (!plistValue.dict || typeof plistValue.dict !== 'object') {
143
+ throw createError(`Parsed ${errorContext} at ${filePath} contains an invalid root dict.`)
144
+ }
145
+
146
+ return plistValue.dict as OrderedPlistNode
147
+ }
148
+
149
+ function parsePlistDict(
150
+ node: OrderedPlistNode,
151
+ errorContext: string,
152
+ filePath: string,
153
+ createError: PlistErrorFactory
154
+ ): Record<string, unknown> {
155
+ const children = getOrderedChildren(node)
156
+ const result: Record<string, unknown> = {}
157
+
158
+ for (let index = 0; index < children.length; index += 2) {
159
+ const keyNode = children[index]
160
+ const valueNode = children[index + 1]
161
+
162
+ if (keyNode?.['#name'] !== 'key' || typeof keyNode._ !== 'string' || keyNode._.trim().length === 0) {
163
+ throw createError(`Parsed ${errorContext} at ${filePath} contains an invalid dict key.`)
164
+ }
165
+
166
+ if (!valueNode) {
167
+ throw createError(`Parsed ${errorContext} at ${filePath} is missing a value for key '${keyNode._}'.`)
168
+ }
169
+
170
+ result[keyNode._] = parsePlistValue(valueNode, errorContext, filePath, createError)
171
+ }
172
+
173
+ return result
174
+ }
175
+
176
+ function parsePlistArray(
177
+ node: OrderedPlistNode,
178
+ errorContext: string,
179
+ filePath: string,
180
+ createError: PlistErrorFactory
181
+ ): unknown[] {
182
+ return getOrderedChildren(node).map((child) => parsePlistValue(child, errorContext, filePath, createError))
183
+ }
184
+
185
+ function parsePlistValue(
186
+ node: OrderedPlistNode,
187
+ errorContext: string,
188
+ filePath: string,
189
+ createError: PlistErrorFactory
190
+ ): unknown {
191
+ switch (node['#name']) {
192
+ case 'string':
193
+ return node._ ?? ''
194
+ case 'integer':
195
+ case 'real': {
196
+ const rawValue = node._ ?? ''
197
+ const numericValue = Number(rawValue)
198
+
199
+ if (!Number.isFinite(numericValue)) {
200
+ throw createError(
201
+ `Parsed ${errorContext} at ${filePath} contains an invalid ${node['#name']} value '${rawValue}'.`
202
+ )
203
+ }
204
+
205
+ return numericValue
206
+ }
207
+ case 'true':
208
+ return true
209
+ case 'false':
210
+ return false
211
+ case 'dict':
212
+ return parsePlistDict(node, errorContext, filePath, createError)
213
+ case 'array':
214
+ return parsePlistArray(node, errorContext, filePath, createError)
215
+ case 'data':
216
+ return { __voltraPlistScalarType: 'data', value: node._ ?? '' } satisfies TaggedPlistScalar
217
+ case 'date':
218
+ return { __voltraPlistScalarType: 'date', value: node._ ?? '' } satisfies TaggedPlistScalar
219
+ default:
220
+ throw createError(
221
+ `Parsed ${errorContext} at ${filePath} contains unsupported plist node '${node['#name'] ?? 'unknown'}'.`
222
+ )
223
+ }
224
+ }
225
+
226
+ function getOrderedChildren(node: OrderedPlistNode): OrderedPlistNode[] {
227
+ return Array.isArray(node.$$) ? node.$$ : []
228
+ }
229
+
230
+ function renderPlistValue(value: unknown, indentLevel: number, createError: PlistErrorFactory): string {
231
+ const indent = ' '.repeat(indentLevel)
232
+
233
+ if (Array.isArray(value)) {
234
+ const items = value.map((item) => renderPlistValue(item, indentLevel + 1, createError)).join('\n')
235
+ return `${indent}<array>\n${items}\n${indent}</array>`
236
+ }
237
+
238
+ if (isTaggedPlistScalar(value)) {
239
+ return `${indent}<${value.__voltraPlistScalarType}>${escapePlistText(value.value)}</${
240
+ value.__voltraPlistScalarType
241
+ }>`
242
+ }
243
+
244
+ if (value && typeof value === 'object') {
245
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)
246
+ const lines = entries.flatMap(([key, entryValue]) => [
247
+ `${indent} <key>${escapePlistText(key)}</key>`,
248
+ renderPlistValue(entryValue, indentLevel + 1, createError),
249
+ ])
250
+ return `${indent}<dict>\n${lines.join('\n')}\n${indent}</dict>`
251
+ }
252
+
253
+ if (typeof value === 'string') {
254
+ return `${indent}<string>${escapePlistText(value)}</string>`
255
+ }
256
+
257
+ if (typeof value === 'number') {
258
+ return Number.isInteger(value) ? `${indent}<integer>${value}</integer>` : `${indent}<real>${value}</real>`
259
+ }
260
+
261
+ if (typeof value === 'boolean') {
262
+ return `${indent}<${value ? 'true' : 'false'}/>`
263
+ }
264
+
265
+ if (value === null || value === undefined) {
266
+ throw createError('Cannot encode null or undefined in plist output.')
267
+ }
268
+
269
+ throw createError(`Unsupported plist value type: ${typeof value}`)
270
+ }
271
+
272
+ function isTaggedPlistScalar(value: unknown): value is TaggedPlistScalar {
273
+ return (
274
+ !!value &&
275
+ typeof value === 'object' &&
276
+ '__voltraPlistScalarType' in value &&
277
+ ((value as TaggedPlistScalar).__voltraPlistScalarType === 'data' ||
278
+ (value as TaggedPlistScalar).__voltraPlistScalarType === 'date') &&
279
+ typeof (value as TaggedPlistScalar).value === 'string'
280
+ )
281
+ }
282
+
283
+ function setOrDeleteVoltraKey(target: Record<string, unknown>, key: string, value: unknown): void {
284
+ if (value === undefined) {
285
+ delete target[key]
286
+ return
287
+ }
288
+
289
+ target[key] = value
290
+ }
291
+
292
+ async function writePlistIfChanged(
293
+ projectRoot: string,
294
+ plistPath: string,
295
+ nextContent: string
296
+ ): Promise<ReportedChange | undefined> {
297
+ const previousContent = await readTextFile(plistPath)
298
+
299
+ if (previousContent === nextContent) {
300
+ return undefined
301
+ }
302
+
303
+ await writeTextFile(plistPath, nextContent)
304
+
305
+ return {
306
+ kind: 'updated',
307
+ path: toRelativePath(projectRoot, plistPath),
308
+ }
309
+ }
310
+
311
+ function escapePlistText(value: string): string {
312
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
313
+ }
314
+
315
+ function getErrorMessage(error: unknown): string {
316
+ return error instanceof Error ? error.message : String(error)
317
+ }
318
+
319
+ function createInfoPlistError(message: string): IOSInfoPlistMutationError {
320
+ return new IOSInfoPlistMutationError(message)
321
+ }
@@ -0,0 +1,255 @@
1
+ import path from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+
4
+ import { readTextFile, writeTextFile } from '../../fs/readWrite'
5
+ import { toRelativePath } from '../../fs/path'
6
+ import { VoltraCliError } from '../../reporting/summary'
7
+ import { resolveIOSWidgetTargetName } from './targetName'
8
+ import { VOLTRA_MIN_IOS_DEPLOYMENT_TARGET, isIOSDeploymentTargetAtLeast } from './deploymentTarget'
9
+
10
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
11
+ import type { NormalizedVoltraIOSConfig } from '../../config/types'
12
+ import type { ReportedChange } from '../../reporting/summary'
13
+
14
+ const VOLTRA_MANAGED_BLOCK_START = '# >>> VOLTRA MANAGED BLOCK: widget extension target'
15
+ const VOLTRA_MANAGED_BLOCK_END = '# <<< VOLTRA MANAGED BLOCK: widget extension target'
16
+ const LEGACY_BLOCK_HEADER = '# Voltra Widget Extension Target'
17
+ const LEGACY_BLOCK_NOTICE = '# DO NOT MODIFY THIS FILE - IT IS AUTO-GENERATED BY THE VOLTRA PLUGIN'
18
+
19
+ export interface EnsurePodfileBlockOptions {
20
+ projectRoot: string
21
+ ios: NormalizedVoltraIOSConfig
22
+ discovery: IOSProjectDiscovery
23
+ }
24
+
25
+ export interface EnsurePodfileBlockResult {
26
+ change?: ReportedChange
27
+ targetName: string
28
+ warnings?: string[]
29
+ }
30
+
31
+ export class IOSPodfileMutationError extends VoltraCliError {
32
+ constructor(message: string) {
33
+ super(message, 'VOLTRA_IOS_PODFILE_FAILED')
34
+ this.name = 'IOSPodfileMutationError'
35
+ }
36
+ }
37
+
38
+ export async function ensurePodfileBlock(options: EnsurePodfileBlockOptions): Promise<EnsurePodfileBlockResult> {
39
+ const { projectRoot, ios, discovery } = options
40
+ const targetName = resolveIOSWidgetTargetName(ios, discovery)
41
+ const currentContent = await readPodfile(discovery.podfilePath)
42
+ const nextBlock = buildManagedPodfileBlock(targetName)
43
+ const platformResult = await ensurePodfileDeploymentTarget(currentContent, projectRoot)
44
+ const nextContent = reconcilePodfile(platformResult.content, nextBlock, targetName)
45
+
46
+ if (currentContent === nextContent) {
47
+ return { targetName, warnings: platformResult.warnings }
48
+ }
49
+
50
+ await writeTextFile(discovery.podfilePath, nextContent)
51
+
52
+ return {
53
+ change: {
54
+ kind: 'updated',
55
+ path: toRelativePath(projectRoot, discovery.podfilePath),
56
+ },
57
+ targetName,
58
+ warnings: platformResult.warnings,
59
+ }
60
+ }
61
+
62
+ interface EnsurePodfileDeploymentTargetResult {
63
+ content: string
64
+ warnings?: string[]
65
+ }
66
+
67
+ async function ensurePodfileDeploymentTarget(
68
+ content: string,
69
+ projectRoot: string
70
+ ): Promise<EnsurePodfileDeploymentTargetResult> {
71
+ const lines = content.split('\n')
72
+ const platformLineIndex = lines.findIndex((line) => /^\s*platform\s+:ios\s*,/.test(line))
73
+
74
+ if (platformLineIndex === -1) {
75
+ return {
76
+ content,
77
+ warnings: [
78
+ `Could not find an iOS platform declaration in the Podfile. Ensure it resolves to iOS ${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET} or newer.`,
79
+ ],
80
+ }
81
+ }
82
+
83
+ const line = lines[platformLineIndex]
84
+ const match = line.match(/^(\s*platform\s+:ios\s*,\s*)(.*?)(\s*(?:#.*)?)$/)
85
+
86
+ if (!match) {
87
+ return {
88
+ content,
89
+ warnings: [getUnknownPodfilePlatformWarning()],
90
+ }
91
+ }
92
+
93
+ const [, prefix, rawValue, suffix] = match
94
+ const platformValue = rawValue.trim()
95
+ const nextValue = await resolveNextPodfilePlatformValue(platformValue, projectRoot)
96
+
97
+ if (nextValue === undefined) {
98
+ return {
99
+ content,
100
+ warnings: [getUnknownPodfilePlatformWarning()],
101
+ }
102
+ }
103
+
104
+ if (nextValue === platformValue) {
105
+ return { content }
106
+ }
107
+
108
+ lines[platformLineIndex] = `${prefix}${nextValue}${suffix}`
109
+ return { content: lines.join('\n') }
110
+ }
111
+
112
+ async function resolveNextPodfilePlatformValue(
113
+ platformValue: string,
114
+ projectRoot: string
115
+ ): Promise<string | undefined> {
116
+ const literalVersion = parseRubyStringLiteral(platformValue)
117
+
118
+ if (literalVersion) {
119
+ return isIOSDeploymentTargetAtLeast(literalVersion, VOLTRA_MIN_IOS_DEPLOYMENT_TARGET)
120
+ ? platformValue
121
+ : `'${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET}'`
122
+ }
123
+
124
+ if (platformValue === 'min_ios_version_supported') {
125
+ const reactNativeMinVersion = await resolveReactNativeMinIOSVersion(projectRoot)
126
+
127
+ if (
128
+ reactNativeMinVersion &&
129
+ isIOSDeploymentTargetAtLeast(reactNativeMinVersion, VOLTRA_MIN_IOS_DEPLOYMENT_TARGET)
130
+ ) {
131
+ return platformValue
132
+ }
133
+
134
+ return `'${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET}'`
135
+ }
136
+
137
+ return undefined
138
+ }
139
+
140
+ function parseRubyStringLiteral(value: string): string | undefined {
141
+ const match = value.match(/^(['"])(\d+(?:\.\d+){0,2})\1$/)
142
+ return match?.[2]
143
+ }
144
+
145
+ async function resolveReactNativeMinIOSVersion(projectRoot: string): Promise<string | undefined> {
146
+ try {
147
+ const requireFromProject = createRequire(path.join(projectRoot, 'package.json'))
148
+ const reactNativePackagePath = requireFromProject.resolve('react-native/package.json')
149
+ const helpersPath = path.join(path.dirname(reactNativePackagePath), 'scripts', 'cocoapods', 'helpers.rb')
150
+ const helpersContent = await readTextFile(helpersPath)
151
+ return helpersContent.match(/def self\.min_ios_version_supported[\s\S]*?return ['"]([^'"]+)['"]/)?.[1]
152
+ } catch {
153
+ return undefined
154
+ }
155
+ }
156
+
157
+ function getUnknownPodfilePlatformWarning(): string {
158
+ return `Could not verify the Podfile iOS platform declaration. Ensure it resolves to iOS ${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET} or newer.`
159
+ }
160
+
161
+ function buildManagedPodfileBlock(targetName: string): string {
162
+ const escapedTargetName = escapeRubySingleQuotedString(targetName)
163
+
164
+ return [
165
+ VOLTRA_MANAGED_BLOCK_START,
166
+ '# Voltra widget extension target managed by voltra apply.',
167
+ `target '${escapedTargetName}' do`,
168
+ " use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if defined?(podfile_properties) && podfile_properties['ios.useFrameworks']",
169
+ " use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']",
170
+ '',
171
+ " require 'pathname'",
172
+ ' project_root = "#{Pod::Config.instance.installation_root}/.."',
173
+ " abort('[voltra] Error: @use-voltra/ios-client must be installed in the app project before running pod install.') unless system(\"node --print \\\"require.resolve('@use-voltra/ios-client/package.json', {paths: ['#{project_root}']})\\\" >/dev/null 2>&1\")",
174
+ " voltra_client_root = `node --print \"require.resolve('@use-voltra/ios-client/package.json', {paths: ['#{project_root}']})\" 2>/dev/null`.strip.gsub('/package.json', '')",
175
+ " voltra_widget_podspec_root = File.join(voltra_client_root, 'ios')",
176
+ ' podspec_dir_path = Pathname.new(voltra_widget_podspec_root).relative_path_from(Pathname.new(__dir__)).to_path',
177
+ '',
178
+ " pod 'VoltraWidget', :path => podspec_dir_path",
179
+ 'end',
180
+ VOLTRA_MANAGED_BLOCK_END,
181
+ ].join('\n')
182
+ }
183
+
184
+ function reconcilePodfile(currentContent: string, nextBlock: string, targetName: string): string {
185
+ const normalizedContent = stripTrailingWhitespace(currentContent)
186
+ const managedBlockPattern = createManagedBlockPattern()
187
+ const legacyBlockPattern = createLegacyBlockPattern()
188
+
189
+ if (managedBlockPattern.test(normalizedContent)) {
190
+ return ensureTrailingNewline(normalizedContent.replace(managedBlockPattern, nextBlock))
191
+ }
192
+
193
+ if (legacyBlockPattern.test(normalizedContent)) {
194
+ return ensureTrailingNewline(normalizedContent.replace(legacyBlockPattern, nextBlock))
195
+ }
196
+
197
+ if (hasUnmanagedTargetBlock(normalizedContent, targetName)) {
198
+ throw new IOSPodfileMutationError(
199
+ `Podfile already contains an unmanaged target '${targetName}'. Remove it or choose a different ios.targetName before running voltra apply.`
200
+ )
201
+ }
202
+
203
+ const separator = normalizedContent.length === 0 ? '' : '\n\n'
204
+ return ensureTrailingNewline(`${normalizedContent}${separator}${nextBlock}`)
205
+ }
206
+
207
+ function createManagedBlockPattern(): RegExp {
208
+ return new RegExp(
209
+ `${escapeRegExp(VOLTRA_MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(VOLTRA_MANAGED_BLOCK_END)}`,
210
+ 'm'
211
+ )
212
+ }
213
+
214
+ function createLegacyBlockPattern(): RegExp {
215
+ return new RegExp(
216
+ `${escapeRegExp(LEGACY_BLOCK_HEADER)}\\n${escapeRegExp(LEGACY_BLOCK_NOTICE)}\\ntarget '[^']+' do[\\s\\S]*?\\nend`,
217
+ 'm'
218
+ )
219
+ }
220
+
221
+ async function readPodfile(podfilePath: string): Promise<string> {
222
+ try {
223
+ return await readTextFile(podfilePath)
224
+ } catch (error: unknown) {
225
+ throw new IOSPodfileMutationError(`Failed to read Podfile at ${podfilePath}: ${getErrorMessage(error)}`)
226
+ }
227
+ }
228
+
229
+ function stripTrailingWhitespace(value: string): string {
230
+ return value.replace(/\s+$/, '')
231
+ }
232
+
233
+ function ensureTrailingNewline(value: string): string {
234
+ return `${value}\n`
235
+ }
236
+
237
+ function escapeRubySingleQuotedString(value: string): string {
238
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
239
+ }
240
+
241
+ function escapeRegExp(value: string): string {
242
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
243
+ }
244
+
245
+ function hasUnmanagedTargetBlock(content: string, targetName: string): boolean {
246
+ const targetPattern = new RegExp(
247
+ `(^|\\n)target '${escapeRegExp(escapeRubySingleQuotedString(targetName))}' do(\\n|$)`,
248
+ 'm'
249
+ )
250
+ return targetPattern.test(content)
251
+ }
252
+
253
+ function getErrorMessage(error: unknown): string {
254
+ return error instanceof Error ? error.message : String(error)
255
+ }
@@ -0,0 +1,11 @@
1
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
2
+ import type { NormalizedVoltraIOSConfig } from '../../config/types'
3
+
4
+ export function resolveIOSWidgetTargetName(ios: NormalizedVoltraIOSConfig, discovery: IOSProjectDiscovery): string {
5
+ if (ios.targetName) {
6
+ return ios.targetName
7
+ }
8
+
9
+ const sanitizedTargetName = discovery.mainTargetName.replace(/[^A-Za-z0-9_]/g, '')
10
+ return `${sanitizedTargetName}LiveActivity`
11
+ }