|
1 | 1 | #!/usr/bin/env node |
2 | | -const { spawnSync } = require('child_process'); |
3 | | -const fs = require('fs'); |
4 | | -const path = require('path'); |
5 | | -const process = require('process'); |
6 | | - |
7 | | -const SENTRY_URL = 'SENTRY_URL'; |
8 | | -const SENTRY_ORG = 'SENTRY_ORG'; |
9 | | -const SENTRY_PROJECT = 'SENTRY_PROJECT'; |
10 | | -const SENTRY_AUTH_TOKEN = 'SENTRY_AUTH_TOKEN'; |
11 | | -const SENTRY_CLI_EXECUTABLE = 'SENTRY_CLI_EXECUTABLE'; |
12 | | - |
13 | | -function getEnvVar(varname) { |
14 | | - return process.env[varname]; |
15 | | -} |
16 | | - |
17 | | -function getSentryPluginPropertiesFromExpoConfig() { |
18 | | - try { |
19 | | - const result = spawnSync('npx', ['expo', 'config', '--json'], { encoding: 'utf8' }); |
20 | | - if (result.error || result.status !== 0) { |
21 | | - throw result.error || new Error(`expo config exited with status ${result.status}`); |
22 | | - } |
23 | | - const config = JSON.parse(result.stdout); |
24 | | - const plugins = config.plugins || []; |
25 | | - const sentryPlugin = plugins.find(plugin => { |
26 | | - if (!Array.isArray(plugin) || plugin.length < 2) { |
27 | | - return false; |
28 | | - } |
29 | | - const [pluginName] = plugin; |
30 | | - return pluginName === '@sentry/react-native/expo'; |
31 | | - }); |
32 | | - |
33 | | - if (sentryPlugin) { |
34 | | - const [, pluginConfig] = sentryPlugin; |
35 | | - return pluginConfig; |
36 | | - } |
37 | | - |
38 | | - // When withSentry is used programmatically in app.config.ts, the plugin |
39 | | - // doesn't appear in the plugins array. Check config._internal where the |
40 | | - // plugin stashes build-time properties as a fallback. |
41 | | - if (config._internal?.sentryBuildProperties) { |
42 | | - return config._internal.sentryBuildProperties; |
43 | | - } |
44 | | - |
45 | | - return null; |
46 | | - } catch (error) { |
47 | | - console.error('Error fetching expo config:', error); |
48 | | - return null; |
49 | | - } |
50 | | -} |
51 | | - |
52 | | -function getSentryPropertiesFromFile() { |
53 | | - const candidates = [ |
54 | | - path.join(projectRoot, 'android', 'sentry.properties'), |
55 | | - path.join(projectRoot, 'ios', 'sentry.properties'), |
56 | | - ]; |
57 | | - for (const candidate of candidates) { |
58 | | - if (!fs.existsSync(candidate)) { |
59 | | - continue; |
60 | | - } |
61 | | - try { |
62 | | - const content = fs.readFileSync(candidate, 'utf8'); |
63 | | - const props = {}; |
64 | | - for (const line of content.split('\n')) { |
65 | | - const trimmed = line.trim(); |
66 | | - if (!trimmed || trimmed.startsWith('#')) { |
67 | | - continue; |
68 | | - } |
69 | | - const eqIndex = trimmed.indexOf('='); |
70 | | - if (eqIndex === -1) { |
71 | | - continue; |
72 | | - } |
73 | | - const key = trimmed.substring(0, eqIndex).trim(); |
74 | | - const value = trimmed.substring(eqIndex + 1).trim(); |
75 | | - if (key === 'defaults.org') { |
76 | | - props.organization = value; |
77 | | - } else if (key === 'defaults.project') { |
78 | | - props.project = value; |
79 | | - } else if (key === 'defaults.url') { |
80 | | - props.url = value; |
81 | | - } |
82 | | - } |
83 | | - if (props.organization || props.project) { |
84 | | - console.log(`Found sentry properties in ${candidate}`); |
85 | | - return props; |
86 | | - } |
87 | | - } catch (_e) { |
88 | | - // continue to next candidate |
89 | | - } |
90 | | - } |
91 | | - return null; |
92 | | -} |
93 | | - |
94 | | -function readAndPrintJSONFile(filePath) { |
95 | | - if (!fs.existsSync(filePath)) { |
96 | | - throw new Error(`The file "${filePath}" does not exist.`); |
97 | | - } |
98 | | - try { |
99 | | - const data = fs.readFileSync(filePath, 'utf8'); |
100 | | - return JSON.parse(data); |
101 | | - } catch (err) { |
102 | | - console.error('Error reading or parsing JSON file:', err); |
103 | | - throw err; |
104 | | - } |
105 | | -} |
106 | | - |
107 | | -function writeJSONFile(filePath, object) { |
108 | | - // Convert the updated JavaScript object back to a JSON string |
109 | | - const updatedJsonString = JSON.stringify(object, null, 2); |
110 | | - fs.writeFileSync(filePath, updatedJsonString, 'utf8', writeErr => { |
111 | | - if (writeErr) { |
112 | | - console.error('Error writing to the file:', writeErr); |
113 | | - } else { |
114 | | - console.log('File updated successfully.'); |
115 | | - } |
116 | | - }); |
117 | | -} |
118 | | - |
119 | | -function isAsset(filename) { |
120 | | - return filename.endsWith('.map') || filename.endsWith('.js') || filename.endsWith('.hbc'); |
121 | | -} |
122 | | - |
123 | | -function getAssetPathsSync(directory) { |
124 | | - const files = []; |
125 | | - const items = fs.readdirSync(directory, { withFileTypes: true }); |
126 | | - |
127 | | - for (const item of items) { |
128 | | - const fullPath = path.join(directory, item.name); |
129 | | - if (item.isDirectory()) { |
130 | | - files.push(...getAssetPathsSync(fullPath)); |
131 | | - } else if (item.isFile() && isAsset(item.name)) { |
132 | | - files.push(fullPath); |
133 | | - } |
134 | | - } |
135 | | - return files; |
136 | | -} |
137 | | - |
138 | | -function groupAssets(assetPaths) { |
139 | | - const groups = {}; |
140 | | - for (const assetPath of assetPaths) { |
141 | | - const parsedPath = path.parse(assetPath); |
142 | | - const extname = parsedPath.ext; |
143 | | - const assetGroupName = extname === '.map' ? path.join(parsedPath.dir, parsedPath.name) : path.format(parsedPath); |
144 | | - if (!groups[assetGroupName]) { |
145 | | - groups[assetGroupName] = [assetPath]; |
146 | | - } else { |
147 | | - groups[assetGroupName].push(assetPath); |
148 | | - } |
149 | | - } |
150 | | - return groups; |
151 | | -} |
152 | | - |
153 | | -function loadDotenv(dotenvPath) { |
154 | | - try { |
155 | | - const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); |
156 | | - // NOTE: Do not use the dotenv.config API directly to read the dotenv file! For some ungodly reason, it falls back to reading `${process.cwd()}/.env` which is absolutely not what we want. |
157 | | - // dotenv is dependency of @expo/env, so we can just require it here |
158 | | - const dotenvResult = require('dotenv').parse(dotenvFile); |
159 | | - |
160 | | - Object.assign(process.env, dotenvResult); |
161 | | - } catch (error) { |
162 | | - if (error.code === 'ENOENT') { |
163 | | - // noop if file does not exist |
164 | | - } else { |
165 | | - console.warn('⚠️ Failed to load environment variables using dotenv.'); |
166 | | - console.warn(error); |
167 | | - } |
168 | | - } |
169 | | -} |
170 | | - |
171 | | -process.env.NODE_ENV = process.env.NODE_ENV || 'development'; // Ensures precedence .env.development > .env (the same as @expo/cli) |
172 | | -const projectRoot = '.'; // Assume script is run from the project root |
173 | 2 | try { |
174 | | - require('@expo/env').load(projectRoot); |
175 | | -} catch (error) { |
176 | | - console.warn('⚠️ Failed to load environment variables using @expo/env.'); |
177 | | - console.warn(error); |
178 | | -} |
179 | | - |
180 | | -const sentryBuildPluginPath = path.join(projectRoot, '.env.sentry-build-plugin'); |
181 | | -if (fs.existsSync(sentryBuildPluginPath)) { |
182 | | - loadDotenv(sentryBuildPluginPath); |
183 | | -} |
184 | | - |
185 | | -let sentryOrg = getEnvVar(SENTRY_ORG); |
186 | | -let sentryUrl = getEnvVar(SENTRY_URL); |
187 | | -let sentryProject = getEnvVar(SENTRY_PROJECT); |
188 | | -let authToken = getEnvVar(SENTRY_AUTH_TOKEN); |
189 | | -const sentryCliBin = getEnvVar(SENTRY_CLI_EXECUTABLE) || require.resolve('@sentry/cli/bin/sentry-cli'); |
190 | | - |
191 | | -if (!sentryOrg || !sentryProject || !sentryUrl) { |
192 | | - console.log('🐕 Fetching from expo config...'); |
193 | | - let pluginConfig = getSentryPluginPropertiesFromExpoConfig(); |
194 | | - if (!pluginConfig) { |
195 | | - console.log('Could not fetch from expo config, trying sentry.properties files...'); |
196 | | - pluginConfig = getSentryPropertiesFromFile(); |
197 | | - } |
198 | | - if (!pluginConfig) { |
| 3 | + require.resolve('@sentry/expo-upload-sourcemaps/cli.js'); |
| 4 | +} catch (e) { |
| 5 | + if (e && e.code === 'MODULE_NOT_FOUND') { |
199 | 6 | console.error( |
200 | | - "Could not resolve Sentry configuration. Set SENTRY_ORG, SENTRY_PROJECT, and SENTRY_URL environment variables, " + |
201 | | - "or ensure '@sentry/react-native/expo' is in your plugins array in app.json/app.config.ts." |
| 7 | + "The '@sentry/expo-upload-sourcemaps' package is missing. Reinstall @sentry/react-native, or invoke `npx @sentry/expo-upload-sourcemaps dist` directly." |
202 | 8 | ); |
203 | 9 | process.exit(1); |
204 | 10 | } |
205 | | - |
206 | | - if (!sentryOrg) { |
207 | | - if (!pluginConfig.organization) { |
208 | | - console.error( |
209 | | - `Could not resolve sentry org, set it in the environment variable ${SENTRY_ORG} or in the '@sentry/react-native' plugin properties in your expo config.`, |
210 | | - ); |
211 | | - process.exit(1); |
212 | | - } |
213 | | - |
214 | | - sentryOrg = pluginConfig.organization; |
215 | | - console.log(`${SENTRY_ORG} resolved to ${sentryOrg} from expo config.`); |
216 | | - } |
217 | | - |
218 | | - if (!sentryProject) { |
219 | | - if (!pluginConfig.project) { |
220 | | - console.error( |
221 | | - `Could not resolve sentry project, set it in the environment variable ${SENTRY_PROJECT} or in the '@sentry/react-native' plugin properties in your expo config.`, |
222 | | - ); |
223 | | - process.exit(1); |
224 | | - } |
225 | | - |
226 | | - sentryProject = pluginConfig.project; |
227 | | - console.log(`${SENTRY_PROJECT} resolved to ${sentryProject} from expo config.`); |
228 | | - } |
229 | | - if (!sentryUrl) { |
230 | | - if (pluginConfig.url) { |
231 | | - sentryUrl = pluginConfig.url; |
232 | | - console.log(`${SENTRY_URL} resolved to ${sentryUrl} from expo config.`); |
233 | | - } |
234 | | - else { |
235 | | - sentryUrl = 'https://sentry.io/'; |
236 | | - console.log( |
237 | | - `Since it wasn't specified in the Expo config or environment variable, ${SENTRY_URL} now points to ${sentryUrl}.` |
238 | | - ); |
239 | | - } |
240 | | - } |
241 | | -} |
242 | | - |
243 | | -if (!authToken) { |
244 | | - console.error(`${SENTRY_AUTH_TOKEN} environment variable must be set.`); |
245 | | - process.exit(1); |
246 | | -} |
247 | | - |
248 | | -const outputDir = process.argv[2]; |
249 | | -if (!outputDir) { |
250 | | - console.error('Provide the directory with your bundles and sourcemaps as the first argument.'); |
251 | | - console.error('Example: node node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps dist'); |
252 | | - process.exit(1); |
253 | | -} |
254 | | - |
255 | | -const files = getAssetPathsSync(outputDir); |
256 | | -const groupedAssets = groupAssets(files); |
257 | | - |
258 | | -const totalAssets = Object.keys(groupedAssets).length; |
259 | | -let numAssetsUploaded = 0; |
260 | | -for (const [assetGroupName, assets] of Object.entries(groupedAssets)) { |
261 | | - const sourceMapPath = assets.find(asset => asset.endsWith('.map')); |
262 | | - if (sourceMapPath) { |
263 | | - const sourceMap = readAndPrintJSONFile(sourceMapPath); |
264 | | - if (sourceMap.debugId) { |
265 | | - sourceMap.debug_id = sourceMap.debugId; |
266 | | - } |
267 | | - writeJSONFile(sourceMapPath, sourceMap); |
268 | | - console.log(`⬆️ Uploading ${assetGroupName} bundle and sourcemap...`); |
269 | | - } else { |
270 | | - console.log(`❓ Sourcemap for ${assetGroupName} not found, skipping...`); |
271 | | - continue; |
272 | | - } |
273 | | - |
274 | | - const isHermes = assets.find(asset => asset.endsWith('.hbc')); |
275 | | - |
276 | | - // Build arguments array for spawnSync (no shell interpretation needed) |
277 | | - const args = ['sourcemaps', 'upload']; |
278 | | - if (isHermes) { |
279 | | - args.push('--debug-id-reference'); |
280 | | - } |
281 | | - args.push(...assets); |
282 | | - |
283 | | - const result = spawnSync(sentryCliBin, args, { |
284 | | - env: { |
285 | | - ...process.env, |
286 | | - [SENTRY_PROJECT]: sentryProject, |
287 | | - [SENTRY_ORG]: sentryOrg, |
288 | | - [SENTRY_URL]: sentryUrl |
289 | | - }, |
290 | | - stdio: 'inherit', |
291 | | - }); |
292 | | - |
293 | | - if (result.error) { |
294 | | - console.error('Failed to upload sourcemaps:', result.error); |
295 | | - process.exit(1); |
296 | | - } |
297 | | - if (result.status !== 0) { |
298 | | - console.error(`sentry-cli exited with status ${result.status}`); |
299 | | - process.exit(result.status); |
300 | | - } |
301 | | - numAssetsUploaded++; |
302 | | -} |
303 | | - |
304 | | -if (numAssetsUploaded === totalAssets) { |
305 | | - console.log('✅ Uploaded bundles and sourcemaps to Sentry successfully.'); |
306 | | -} else { |
307 | | - console.warn( |
308 | | - `⚠️ Uploaded ${numAssetsUploaded} of ${totalAssets} bundles and sourcemaps. ${numAssetsUploaded === 0 ? 'Ensure you are running `expo export` with the `--source-maps` flag.' : '' |
309 | | - }`, |
310 | | - ); |
| 11 | + throw e; |
311 | 12 | } |
| 13 | +require('@sentry/expo-upload-sourcemaps/cli.js'); |
0 commit comments