@tamagui/native-ci 1.139.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 (81) hide show
  1. package/README.md +320 -0
  2. package/action.yml +98 -0
  3. package/actions/fingerprint/action.yml +110 -0
  4. package/actions/test-detox-android/action.yml +70 -0
  5. package/actions/test-detox-ios/action.yml +66 -0
  6. package/dist/cache.js +71 -0
  7. package/dist/cache.js.map +6 -0
  8. package/dist/cache.mjs +73 -0
  9. package/dist/cache.mjs.map +1 -0
  10. package/dist/cli.js +275 -0
  11. package/dist/cli.js.map +6 -0
  12. package/dist/cli.mjs +306 -0
  13. package/dist/cli.mjs.map +1 -0
  14. package/dist/constants.js +12 -0
  15. package/dist/constants.js.map +6 -0
  16. package/dist/constants.mjs +10 -0
  17. package/dist/constants.mjs.map +1 -0
  18. package/dist/deps.js +44 -0
  19. package/dist/deps.js.map +6 -0
  20. package/dist/deps.mjs +53 -0
  21. package/dist/deps.mjs.map +1 -0
  22. package/dist/detox.js +49 -0
  23. package/dist/detox.js.map +6 -0
  24. package/dist/detox.mjs +55 -0
  25. package/dist/detox.mjs.map +1 -0
  26. package/dist/fingerprint.js +43 -0
  27. package/dist/fingerprint.js.map +6 -0
  28. package/dist/fingerprint.mjs +40 -0
  29. package/dist/fingerprint.mjs.map +1 -0
  30. package/dist/index.js +90 -0
  31. package/dist/index.js.map +6 -0
  32. package/dist/index.mjs +11 -0
  33. package/dist/index.mjs.map +1 -0
  34. package/dist/metro.js +79 -0
  35. package/dist/metro.js.map +6 -0
  36. package/dist/metro.mjs +75 -0
  37. package/dist/metro.mjs.map +1 -0
  38. package/dist/runner.js +73 -0
  39. package/dist/runner.js.map +6 -0
  40. package/dist/runner.mjs +73 -0
  41. package/dist/runner.mjs.map +1 -0
  42. package/package.json +50 -0
  43. package/src/android.ts +103 -0
  44. package/src/cache.ts +144 -0
  45. package/src/cli.ts +513 -0
  46. package/src/constants.ts +30 -0
  47. package/src/deps.ts +109 -0
  48. package/src/detox.ts +102 -0
  49. package/src/fingerprint.ts +77 -0
  50. package/src/index.ts +86 -0
  51. package/src/ios.ts +38 -0
  52. package/src/metro.ts +157 -0
  53. package/src/run-detox-android.ts +49 -0
  54. package/src/run-detox-ios.ts +40 -0
  55. package/src/runner.ts +123 -0
  56. package/types/android.d.ts +32 -0
  57. package/types/android.d.ts.map +1 -0
  58. package/types/cache.d.ts +41 -0
  59. package/types/cache.d.ts.map +1 -0
  60. package/types/cli.d.ts +11 -0
  61. package/types/cli.d.ts.map +1 -0
  62. package/types/constants.d.ts +18 -0
  63. package/types/constants.d.ts.map +1 -0
  64. package/types/deps.d.ts +32 -0
  65. package/types/deps.d.ts.map +1 -0
  66. package/types/detox.d.ts +39 -0
  67. package/types/detox.d.ts.map +1 -0
  68. package/types/fingerprint.d.ts +21 -0
  69. package/types/fingerprint.d.ts.map +1 -0
  70. package/types/index.d.ts +16 -0
  71. package/types/index.d.ts.map +1 -0
  72. package/types/ios.d.ts +18 -0
  73. package/types/ios.d.ts.map +1 -0
  74. package/types/metro.d.ts +51 -0
  75. package/types/metro.d.ts.map +1 -0
  76. package/types/run-detox-android.d.ts +15 -0
  77. package/types/run-detox-android.d.ts.map +1 -0
  78. package/types/run-detox-ios.d.ts +14 -0
  79. package/types/run-detox-ios.d.ts.map +1 -0
  80. package/types/runner.d.ts +35 -0
  81. package/types/runner.d.ts.map +1 -0
package/src/cache.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { DEFAULT_KV_TTL_SECONDS, type Platform } from './constants'
4
+
5
+ export interface CacheOptions {
6
+ platform: Platform
7
+ fingerprint: string
8
+ prefix?: string
9
+ }
10
+
11
+ export interface RedisKVOptions {
12
+ url: string
13
+ token: string
14
+ }
15
+
16
+ /**
17
+ * Create a cache key for the native build.
18
+ */
19
+ export function createCacheKey(options: CacheOptions): string {
20
+ const { platform, fingerprint, prefix = 'native-build' } = options
21
+ return `${prefix}-${platform}-${fingerprint}`
22
+ }
23
+
24
+ /**
25
+ * Save fingerprint mapping to Redis KV store.
26
+ * Used to map pre-fingerprint hash to actual fingerprint for faster lookups.
27
+ */
28
+ export async function saveFingerprintToKV(
29
+ kv: RedisKVOptions,
30
+ key: string,
31
+ fingerprint: string,
32
+ ttlSeconds = DEFAULT_KV_TTL_SECONDS
33
+ ): Promise<void> {
34
+ try {
35
+ const response = await fetch(`${kv.url}/SETEX/${key}/${ttlSeconds}/${fingerprint}`, {
36
+ method: 'POST',
37
+ headers: {
38
+ Authorization: `Bearer ${kv.token}`,
39
+ },
40
+ })
41
+
42
+ if (!response.ok) {
43
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
44
+ }
45
+ } catch (error) {
46
+ if (error instanceof TypeError) {
47
+ throw new Error(`Network error connecting to KV store: ${(error as Error).message}`)
48
+ }
49
+ throw new Error(`Failed to save fingerprint to KV: ${(error as Error).message}`)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Get fingerprint from Redis KV store.
55
+ */
56
+ export async function getFingerprintFromKV(
57
+ kv: RedisKVOptions,
58
+ key: string
59
+ ): Promise<string | null> {
60
+ try {
61
+ const response = await fetch(`${kv.url}/get/${key}`, {
62
+ headers: {
63
+ Authorization: `Bearer ${kv.token}`,
64
+ },
65
+ })
66
+
67
+ if (!response.ok) {
68
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
69
+ }
70
+
71
+ const data = (await response.json()) as { result: string | null }
72
+ return data.result === 'null' ? null : data.result
73
+ } catch (error) {
74
+ if (error instanceof TypeError) {
75
+ throw new Error(`Network error connecting to KV store: ${(error as Error).message}`)
76
+ }
77
+ throw new Error(`Failed to get fingerprint from KV: ${(error as Error).message}`)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Extend TTL on a KV key.
83
+ */
84
+ export async function extendKVTTL(
85
+ kv: RedisKVOptions,
86
+ key: string,
87
+ ttlSeconds = DEFAULT_KV_TTL_SECONDS
88
+ ): Promise<void> {
89
+ try {
90
+ await fetch(`${kv.url}/EXPIRE/${key}/${ttlSeconds}`, {
91
+ method: 'POST',
92
+ headers: {
93
+ Authorization: `Bearer ${kv.token}`,
94
+ },
95
+ })
96
+ } catch (error) {
97
+ // Non-fatal - log but don't throw
98
+ console.warn(`Failed to extend KV TTL: ${(error as Error).message}`)
99
+ }
100
+ }
101
+
102
+ // Local file-based cache for testing
103
+
104
+ export interface LocalCacheOptions {
105
+ cacheDir?: string
106
+ }
107
+
108
+ /**
109
+ * Save cache data locally (for testing).
110
+ * If filePath is a simple filename, it's saved directly in the current directory.
111
+ */
112
+ export function saveCache(
113
+ filePath: string,
114
+ data: Record<string, unknown>,
115
+ options: LocalCacheOptions = {}
116
+ ): void {
117
+ const { cacheDir } = options
118
+ const cachePath = cacheDir ? join(process.cwd(), cacheDir, filePath) : join(process.cwd(), filePath)
119
+
120
+ mkdirSync(dirname(cachePath), { recursive: true })
121
+ writeFileSync(cachePath, JSON.stringify(data, null, 2))
122
+ }
123
+
124
+ /**
125
+ * Load cache data locally (for testing).
126
+ * If filePath is a simple filename, it's loaded from the current directory.
127
+ */
128
+ export function loadCache<T extends Record<string, unknown>>(
129
+ filePath: string,
130
+ options: LocalCacheOptions = {}
131
+ ): T | null {
132
+ const { cacheDir } = options
133
+ const cachePath = cacheDir ? join(process.cwd(), cacheDir, filePath) : join(process.cwd(), filePath)
134
+
135
+ if (!existsSync(cachePath)) {
136
+ return null
137
+ }
138
+
139
+ try {
140
+ return JSON.parse(readFileSync(cachePath, 'utf-8'))
141
+ } catch {
142
+ return null
143
+ }
144
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI for @tamagui/native-ci
4
+ *
5
+ * Provides commands for:
6
+ * - Running native tests (Detox, Maestro)
7
+ * - Fingerprint generation and caching
8
+ * - Dependency management
9
+ */
10
+
11
+ import { generateFingerprint, generatePreFingerprintHash } from './fingerprint'
12
+ import { createCacheKey, saveFingerprintToKV, getFingerprintFromKV, saveCache, loadCache } from './cache'
13
+ import { setGitHubOutput, isGitHubActions, isCI } from './runner'
14
+ import { checkDeps, ensureIosDeps, ensureAndroidDeps, ensureMaestro, printDepsStatus } from './deps'
15
+ import { withMetro } from './metro'
16
+ import { parseDetoxArgs, runDetoxTests } from './detox'
17
+ import { ensureIOSFolder } from './ios'
18
+ import { setupAndroidDevice, ensureAndroidFolder } from './android'
19
+ import type { Platform } from './constants'
20
+
21
+ const HELP = `
22
+ native-ci - Native CI/CD helpers for Expo apps
23
+
24
+ COMMANDS:
25
+
26
+ Test Commands:
27
+ test ios [options] Run iOS Detox tests
28
+ test android [options] Run Android Detox tests
29
+ test maestro [flow] Run Maestro tests
30
+ test all Run all tests (iOS + Android)
31
+
32
+ Dependency Commands:
33
+ deps Show dependency status
34
+ deps install Install missing dependencies
35
+ deps install-ios Install iOS dependencies (macOS only)
36
+ deps install-android Install Android dependencies
37
+ deps install-maestro Install Maestro
38
+
39
+ Fingerprint Commands:
40
+ fingerprint <platform> Generate native build fingerprint
41
+ fingerprint-test Test fingerprint caching locally
42
+ pre-hash <files...> Generate quick pre-fingerprint hash
43
+ cache-key <platform> <fp> Generate cache key from fingerprint
44
+
45
+ KV Store Commands:
46
+ kv-get <key> Get value from KV store
47
+ kv-set <key> <value> Set value in KV store
48
+
49
+ OPTIONS:
50
+ --project-root <path> Project root directory (default: cwd)
51
+ --config <name> Detox configuration name
52
+ --record-logs <mode> Record logs: none, failing, all (default: all)
53
+ --retries <n> Number of retries for flaky tests (default: 0)
54
+ --headless Run in headless mode (Android only)
55
+ --prefix <prefix> Cache key prefix (default: native-build)
56
+ --github-output Output results for GitHub Actions
57
+ --json Output as JSON
58
+ --help Show this help message
59
+
60
+ ENVIRONMENT:
61
+ KV_STORE_REDIS_REST_URL Redis REST API URL for fingerprint caching
62
+ KV_STORE_REDIS_REST_TOKEN Redis REST API token
63
+
64
+ EXAMPLES:
65
+ native-ci test ios
66
+ native-ci test android --headless
67
+ native-ci test maestro
68
+ native-ci deps
69
+ native-ci deps install
70
+ native-ci fingerprint ios
71
+ native-ci fingerprint-test
72
+ `
73
+
74
+ interface ParsedArgs {
75
+ command: string
76
+ subcommand: string
77
+ args: string[]
78
+ options: {
79
+ projectRoot: string
80
+ config: string
81
+ recordLogs: string
82
+ retries: number
83
+ headless: boolean
84
+ prefix: string
85
+ githubOutput: boolean
86
+ json: boolean
87
+ help: boolean
88
+ }
89
+ }
90
+
91
+ function parseArgs(argv: string[]): ParsedArgs {
92
+ const args: string[] = []
93
+ const options = {
94
+ projectRoot: process.cwd(),
95
+ config: '',
96
+ recordLogs: 'all',
97
+ retries: 0,
98
+ headless: false,
99
+ prefix: 'native-build',
100
+ githubOutput: false,
101
+ json: false,
102
+ help: false,
103
+ }
104
+
105
+ let i = 0
106
+ while (i < argv.length) {
107
+ const arg = argv[i]
108
+
109
+ if (arg === '--project-root' && argv[i + 1]) {
110
+ options.projectRoot = argv[++i]
111
+ } else if (arg === '--config' && argv[i + 1]) {
112
+ options.config = argv[++i]
113
+ } else if (arg === '--record-logs' && argv[i + 1]) {
114
+ options.recordLogs = argv[++i]
115
+ } else if (arg === '--retries' && argv[i + 1]) {
116
+ const val = Number.parseInt(argv[++i], 10)
117
+ if (!Number.isNaN(val) && val >= 0) {
118
+ options.retries = val
119
+ }
120
+ } else if (arg === '--prefix' && argv[i + 1]) {
121
+ options.prefix = argv[++i]
122
+ } else if (arg === '--github-output') {
123
+ options.githubOutput = true
124
+ } else if (arg === '--json') {
125
+ options.json = true
126
+ } else if (arg === '--help' || arg === '-h') {
127
+ options.help = true
128
+ } else if (!arg.startsWith('-')) {
129
+ args.push(arg)
130
+ }
131
+
132
+ i++
133
+ }
134
+
135
+ return {
136
+ command: args[0] || '',
137
+ subcommand: args[1] || '',
138
+ args: args.slice(2),
139
+ options,
140
+ }
141
+ }
142
+
143
+ function validatePlatform(value: string): Platform {
144
+ if (value !== 'ios' && value !== 'android') {
145
+ console.error('Error: platform must be "ios" or "android"')
146
+ process.exit(1)
147
+ }
148
+ return value
149
+ }
150
+
151
+ function getKVCredentials(): { url: string; token: string } {
152
+ const url = process.env.KV_STORE_REDIS_REST_URL
153
+ const token = process.env.KV_STORE_REDIS_REST_TOKEN
154
+
155
+ if (!url || !token) {
156
+ console.error('Error: KV_STORE_REDIS_REST_URL and KV_STORE_REDIS_REST_TOKEN required')
157
+ process.exit(1)
158
+ }
159
+
160
+ return { url, token }
161
+ }
162
+
163
+ // Parse arguments
164
+ const { command, subcommand, args, options } = parseArgs(process.argv.slice(2))
165
+
166
+ if (options.help || !command) {
167
+ console.info(HELP)
168
+ process.exit(options.help ? 0 : 1)
169
+ }
170
+
171
+ try {
172
+ switch (command) {
173
+ // ========================================
174
+ // Test Commands
175
+ // ========================================
176
+ case 'test': {
177
+ // Skip in CI - native tests are run by separate workflows
178
+ if (isCI() && !process.env.NATIVE_CI_FORCE_RUN) {
179
+ console.info('Skipping native tests in CI (handled by separate workflow)')
180
+ console.info('Set NATIVE_CI_FORCE_RUN=1 to force run')
181
+ process.exit(0)
182
+ }
183
+
184
+ const platform = subcommand || 'ios'
185
+
186
+ if (platform === 'ios') {
187
+ // Ensure iOS dependencies
188
+ await ensureIosDeps()
189
+
190
+ const config = options.config || 'ios.sim.debug'
191
+ console.info('=== iOS Detox Test Runner ===')
192
+ console.info(`Config: ${config}`)
193
+ console.info(`Project root: ${options.projectRoot}`)
194
+
195
+ process.chdir(options.projectRoot)
196
+ await ensureIOSFolder()
197
+
198
+ const exitCode = await withMetro('ios', async () => {
199
+ return runDetoxTests({
200
+ config,
201
+ projectRoot: options.projectRoot,
202
+ recordLogs: options.recordLogs,
203
+ retries: options.retries,
204
+ })
205
+ })
206
+ process.exit(exitCode)
207
+ } else if (platform === 'android') {
208
+ // Ensure Android dependencies
209
+ await ensureAndroidDeps()
210
+
211
+ const config = options.config || 'android.emu.debug'
212
+ console.info('=== Android Detox Test Runner ===')
213
+ console.info(`Config: ${config}`)
214
+ console.info(`Project root: ${options.projectRoot}`)
215
+ console.info(`Headless: ${options.headless}`)
216
+
217
+ process.chdir(options.projectRoot)
218
+ await ensureAndroidFolder()
219
+
220
+ // Setup Android device (wait for emulator, ADB reverse)
221
+ await setupAndroidDevice()
222
+
223
+ const exitCode = await withMetro('android', async () => {
224
+ return runDetoxTests({
225
+ config,
226
+ projectRoot: options.projectRoot,
227
+ recordLogs: options.recordLogs,
228
+ retries: options.retries,
229
+ headless: options.headless,
230
+ })
231
+ })
232
+ process.exit(exitCode)
233
+ } else if (platform === 'maestro') {
234
+ // Ensure Maestro is installed
235
+ await ensureMaestro()
236
+
237
+ const flow = args[0] || ''
238
+ console.info('=== Maestro Test Runner ===')
239
+ console.info(`Flow: ${flow || 'all'}`)
240
+ console.info(`Project root: ${options.projectRoot}`)
241
+
242
+ process.chdir(options.projectRoot)
243
+
244
+ // Run Maestro with Metro for development builds
245
+ const exitCode = await withMetro('ios', async () => {
246
+ const { $ } = await import('bun')
247
+ // Flows are at ./flows/ in kitchen-sink, not .maestro/flows/
248
+ const flowArg = flow ? `./flows/${flow}` : './flows'
249
+ const result = await $`maestro test ${flowArg} --exclude-tags=util --no-ansi`.nothrow()
250
+ return result.exitCode
251
+ })
252
+ process.exit(exitCode)
253
+ } else if (platform === 'all') {
254
+ console.info('=== Running All Native Tests ===\n')
255
+
256
+ // Run iOS tests
257
+ await ensureIosDeps()
258
+ console.info('\n--- iOS Tests ---\n')
259
+ process.chdir(options.projectRoot)
260
+ await ensureIOSFolder()
261
+
262
+ let iosExit = 0
263
+ try {
264
+ iosExit = await withMetro('ios', async () => {
265
+ return runDetoxTests({
266
+ config: options.config || 'ios.sim.debug',
267
+ projectRoot: options.projectRoot,
268
+ recordLogs: options.recordLogs,
269
+ retries: options.retries,
270
+ })
271
+ })
272
+ } catch (err) {
273
+ console.error('iOS tests failed:', err)
274
+ iosExit = 1
275
+ }
276
+
277
+ // Run Android tests
278
+ await ensureAndroidDeps()
279
+ console.info('\n--- Android Tests ---\n')
280
+ await ensureAndroidFolder()
281
+ await setupAndroidDevice()
282
+
283
+ let androidExit = 0
284
+ try {
285
+ androidExit = await withMetro('android', async () => {
286
+ return runDetoxTests({
287
+ config: options.config || 'android.emu.debug',
288
+ projectRoot: options.projectRoot,
289
+ recordLogs: options.recordLogs,
290
+ retries: options.retries,
291
+ headless: options.headless,
292
+ })
293
+ })
294
+ } catch (err) {
295
+ console.error('Android tests failed:', err)
296
+ androidExit = 1
297
+ }
298
+
299
+ const success = iosExit === 0 && androidExit === 0
300
+ console.info(`\n=== Test Results ===`)
301
+ console.info(`iOS: ${iosExit === 0 ? 'PASSED' : 'FAILED'}`)
302
+ console.info(`Android: ${androidExit === 0 ? 'PASSED' : 'FAILED'}`)
303
+ process.exit(success ? 0 : 1)
304
+ } else {
305
+ console.error(`Unknown test platform: ${platform}`)
306
+ console.info('Usage: native-ci test [ios|android|maestro|all]')
307
+ process.exit(1)
308
+ }
309
+ break
310
+ }
311
+
312
+ // ========================================
313
+ // Dependency Commands
314
+ // ========================================
315
+ case 'deps': {
316
+ if (!subcommand || subcommand === 'status') {
317
+ printDepsStatus()
318
+ } else if (subcommand === 'install') {
319
+ console.info('Installing all dependencies...\n')
320
+ await ensureIosDeps()
321
+ await ensureMaestro()
322
+ console.info('\nAll dependencies installed!')
323
+ } else if (subcommand === 'install-ios') {
324
+ await ensureIosDeps()
325
+ } else if (subcommand === 'install-android') {
326
+ await ensureAndroidDeps()
327
+ } else if (subcommand === 'install-maestro') {
328
+ await ensureMaestro()
329
+ } else {
330
+ console.error(`Unknown deps subcommand: ${subcommand}`)
331
+ process.exit(1)
332
+ }
333
+ break
334
+ }
335
+
336
+ // ========================================
337
+ // Fingerprint Commands
338
+ // ========================================
339
+ case 'fingerprint': {
340
+ const platform = validatePlatform(subcommand)
341
+
342
+ const result = await generateFingerprint({
343
+ platform,
344
+ projectRoot: options.projectRoot,
345
+ })
346
+
347
+ if (options.githubOutput || isGitHubActions()) {
348
+ setGitHubOutput('fingerprint', result.hash)
349
+ setGitHubOutput(
350
+ 'cache-key',
351
+ createCacheKey({
352
+ platform,
353
+ fingerprint: result.hash,
354
+ prefix: options.prefix,
355
+ })
356
+ )
357
+ }
358
+
359
+ if (options.json) {
360
+ console.info(JSON.stringify(result, null, 2))
361
+ } else {
362
+ console.info(result.hash)
363
+ }
364
+ break
365
+ }
366
+
367
+ case 'fingerprint-test': {
368
+ const CACHE_FILE = '.fingerprint-cache.json'
369
+
370
+ console.info('Generating fingerprints...\n')
371
+
372
+ const iosResult = await generateFingerprint({ platform: 'ios', projectRoot: options.projectRoot })
373
+ const androidResult = await generateFingerprint({ platform: 'android', projectRoot: options.projectRoot })
374
+
375
+ const iosFingerprint = iosResult.hash
376
+ const androidFingerprint = androidResult.hash
377
+
378
+ console.info('Current fingerprints:')
379
+ console.info(` iOS: ${iosFingerprint}`)
380
+ console.info(` Android: ${androidFingerprint}`)
381
+ console.info('')
382
+
383
+ const cache = loadCache(CACHE_FILE)
384
+
385
+ if (cache?.ios && cache?.android) {
386
+ console.info('Previous fingerprints (from cache):')
387
+ console.info(` iOS: ${cache.ios}`)
388
+ console.info(` Android: ${cache.android}`)
389
+ console.info('')
390
+
391
+ const iosChanged = cache.ios !== iosFingerprint
392
+ const androidChanged = cache.android !== androidFingerprint
393
+
394
+ if (iosChanged || androidChanged) {
395
+ console.info('Fingerprints changed!')
396
+ if (iosChanged) console.info(' - iOS fingerprint changed (would trigger iOS rebuild)')
397
+ if (androidChanged) console.info(' - Android fingerprint changed (would trigger Android rebuild)')
398
+ } else {
399
+ console.info('Fingerprints match - no rebuild needed')
400
+ }
401
+ } else {
402
+ console.info('No previous fingerprints cached.')
403
+ }
404
+
405
+ // Save current fingerprints
406
+ saveCache(CACHE_FILE, { ios: iosFingerprint, android: androidFingerprint, timestamp: new Date().toISOString() })
407
+ console.info(`\nSaved fingerprints to ${CACHE_FILE}`)
408
+
409
+ console.info(`
410
+ To test cache invalidation:
411
+ 1. Add a native dependency: yarn add react-native-mmkv
412
+ 2. Run this script again: native-ci fingerprint-test
413
+ 3. Fingerprints should change!
414
+ 4. Remove the dependency: yarn remove react-native-mmkv
415
+ 5. Run this script again - fingerprints should match original
416
+ `)
417
+ break
418
+ }
419
+
420
+ case 'pre-hash': {
421
+ const files = [subcommand, ...args].filter(Boolean)
422
+ if (files.length === 0) {
423
+ console.error('Error: at least one file required')
424
+ process.exit(1)
425
+ }
426
+
427
+ const hash = generatePreFingerprintHash(files, options.projectRoot)
428
+
429
+ if (options.githubOutput || isGitHubActions()) {
430
+ setGitHubOutput('pre-fingerprint-hash', hash)
431
+ }
432
+
433
+ if (options.json) {
434
+ console.info(JSON.stringify({ hash, files }, null, 2))
435
+ } else {
436
+ console.info(hash)
437
+ }
438
+ break
439
+ }
440
+
441
+ case 'cache-key': {
442
+ const platform = validatePlatform(subcommand)
443
+ const fingerprint = args[0]
444
+
445
+ if (!fingerprint) {
446
+ console.error('Error: fingerprint is required')
447
+ process.exit(1)
448
+ }
449
+
450
+ const cacheKey = createCacheKey({
451
+ platform,
452
+ fingerprint,
453
+ prefix: options.prefix,
454
+ })
455
+
456
+ if (options.githubOutput || isGitHubActions()) {
457
+ setGitHubOutput('cache-key', cacheKey)
458
+ }
459
+
460
+ console.info(cacheKey)
461
+ break
462
+ }
463
+
464
+ // ========================================
465
+ // KV Store Commands
466
+ // ========================================
467
+ case 'kv-get': {
468
+ const key = subcommand
469
+ if (!key) {
470
+ console.error('Error: key is required')
471
+ process.exit(1)
472
+ }
473
+
474
+ const kv = getKVCredentials()
475
+ const value = await getFingerprintFromKV(kv, key)
476
+
477
+ if (options.githubOutput || isGitHubActions()) {
478
+ setGitHubOutput('value', value || '')
479
+ setGitHubOutput('found', value ? 'true' : 'false')
480
+ }
481
+
482
+ if (value) {
483
+ console.info(value)
484
+ } else {
485
+ process.exit(1)
486
+ }
487
+ break
488
+ }
489
+
490
+ case 'kv-set': {
491
+ const key = subcommand
492
+ const value = args[0]
493
+
494
+ if (!key || !value) {
495
+ console.error('Error: key and value are required')
496
+ process.exit(1)
497
+ }
498
+
499
+ const kv = getKVCredentials()
500
+ await saveFingerprintToKV(kv, key, value)
501
+ console.info('OK')
502
+ break
503
+ }
504
+
505
+ default:
506
+ console.error(`Unknown command: ${command}`)
507
+ console.info(HELP)
508
+ process.exit(1)
509
+ }
510
+ } catch (error) {
511
+ console.error('Error:', (error as Error).message)
512
+ process.exit(1)
513
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Constants for @tamagui/native-ci
3
+ */
4
+
5
+ // Metro bundler configuration
6
+ export const METRO_HOST = '127.0.0.1'
7
+ export const METRO_PORT = 8081
8
+ export const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`
9
+
10
+ // Detox server port (used for test communication)
11
+ export const DETOX_SERVER_PORT = 8099
12
+
13
+ // Default timeouts and intervals
14
+ export const DEFAULT_METRO_WAIT_ATTEMPTS = 60
15
+ export const DEFAULT_METRO_WAIT_INTERVAL_MS = 2000
16
+ export const DEFAULT_METRO_TIMEOUT_MS =
17
+ DEFAULT_METRO_WAIT_ATTEMPTS * DEFAULT_METRO_WAIT_INTERVAL_MS // 120s
18
+
19
+ // KV cache configuration
20
+ export const DEFAULT_KV_TTL_SECONDS = 2592000 // 30 days
21
+
22
+ // Supported platforms
23
+ export type Platform = 'ios' | 'android'
24
+
25
+ // Expo manifest response type
26
+ export interface ExpoManifest {
27
+ launchAsset?: {
28
+ url?: string
29
+ }
30
+ }