peachsim 0.1.629 → 0.1.631

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 (128) hide show
  1. package/README.md +8 -4
  2. package/cli/app-fonts.ts +23 -4
  3. package/cli/bridge-flow-runner.ts +45 -31
  4. package/cli/cli-update.ts +2 -7
  5. package/cli/cloud-test-tunnel.ts +16 -3
  6. package/cli/command-registry.ts +1 -0
  7. package/cli/commands/compat.ts +11 -60
  8. package/cli/commands/config.ts +1 -1
  9. package/cli/commands/control.ts +40 -8
  10. package/cli/commands/daemon-mac-app.ts +4 -2
  11. package/cli/commands/daemon.ts +4 -4
  12. package/cli/commands/detect.ts +289 -26
  13. package/cli/commands/device.ts +2 -3
  14. package/cli/commands/inspect/actions.ts +125 -90
  15. package/cli/commands/inspect/core.ts +1 -1
  16. package/cli/commands/inspect.ts +10 -1
  17. package/cli/commands/maestro.ts +3 -1
  18. package/cli/commands/platform.ts +1 -1
  19. package/cli/commands/project-report.ts +386 -0
  20. package/cli/commands/projectReportContract.ts +42 -0
  21. package/cli/commands/report-issue.ts +38 -54
  22. package/cli/commands/screenshot-layers.ts +1 -1
  23. package/cli/commands/screenshot.ts +1 -1
  24. package/cli/commands/setup.ts +6 -4
  25. package/cli/commands/skills.ts +0 -31
  26. package/cli/commands/telemetry.ts +8 -3
  27. package/cli/commands/test.ts +347 -134
  28. package/cli/drivers/playwright-provisioning.ts +3 -2
  29. package/cli/drivers/playwright-sim-host.ts +25 -9
  30. package/cli/main.ts +19 -3
  31. package/cli/outbound-endpoints.ts +2 -5
  32. package/cli/privacy.ts +1 -2
  33. package/cli/runtime-diagnostics.ts +51 -0
  34. package/cli/telemetry.ts +78 -7
  35. package/detox/index.ts +77 -51
  36. package/dist-lib/agent-daemon-client.cjs +1 -1
  37. package/dist-lib/agent-events.cjs +1 -1
  38. package/dist-lib/agent-identity.cjs +1 -1
  39. package/dist-lib/agent-sessions.cjs +1 -1
  40. package/dist-lib/attached-projects.cjs +1 -1
  41. package/dist-lib/auth/shared-session.cjs +1 -1
  42. package/dist-lib/backend-origin.cjs +1 -1
  43. package/dist-lib/beta.cjs +1 -1
  44. package/dist-lib/beta.mjs +1 -1
  45. package/dist-lib/bridge-constants.cjs +1 -1
  46. package/dist-lib/bridge-contract-input.cjs +1 -1
  47. package/dist-lib/bridge-contract-input.mjs +1 -1
  48. package/dist-lib/bridge-contract.cjs +1 -1
  49. package/dist-lib/bridge-contract.mjs +1 -1
  50. package/dist-lib/capture-contract.cjs +1 -1
  51. package/dist-lib/capture-contract.mjs +1 -1
  52. package/dist-lib/cli-constants.cjs +1 -1
  53. package/dist-lib/cloud-contract.cjs +1 -1
  54. package/dist-lib/cloud-contract.mjs +1 -1
  55. package/dist-lib/cloud-test-args.cjs +1 -1
  56. package/dist-lib/cloud-test-args.mjs +1 -1
  57. package/dist-lib/cloud.cjs +1 -1
  58. package/dist-lib/cloud.mjs +1 -1
  59. package/dist-lib/config.cjs +1 -1
  60. package/dist-lib/detox/index.cjs +130 -33
  61. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  62. package/dist-lib/home-paths.cjs +8 -5
  63. package/dist-lib/host/bridge-host.cjs +353 -290
  64. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  65. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  66. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  67. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  68. package/dist-lib/host/websocket-proxy.cjs +1 -1
  69. package/dist-lib/index.cjs +257 -121
  70. package/dist-lib/jump-to-source-babel.cjs +1 -1
  71. package/dist-lib/jump-to-source-native.cjs +1 -1
  72. package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
  73. package/dist-lib/menu.cjs +11 -1
  74. package/dist-lib/menu.mjs +11 -1
  75. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  76. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  77. package/dist-lib/metro-production-bundle.cjs +1 -1
  78. package/dist-lib/metro-production-bundle.mjs +1 -1
  79. package/dist-lib/metro.cjs +132 -9
  80. package/dist-lib/native-app-identity.cjs +278 -0
  81. package/dist-lib/native-app-identity.mjs +245 -0
  82. package/dist-lib/profiles.cjs +1 -1
  83. package/dist-lib/public-brand.cjs +1 -1
  84. package/dist-lib/react-native-host-modules.cjs +1 -1
  85. package/dist-lib/react-native-host-modules.mjs +1 -1
  86. package/dist-lib/render-mode.cjs +1 -1
  87. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  88. package/dist-lib/sdk.cjs +112 -87
  89. package/dist-lib/sdk.mjs +111 -87
  90. package/dist-lib/skills.cjs +1270 -709
  91. package/dist-lib/swift-compile.cjs +1 -1
  92. package/dist-lib/swift-compile.mjs +1 -1
  93. package/dist-lib/swift-package.cjs +1 -1
  94. package/dist-lib/swift-package.mjs +1 -1
  95. package/dist-lib/swift-submit.cjs +1 -1
  96. package/dist-lib/swift-submit.mjs +1 -1
  97. package/dist-lib/swift.cjs +672 -275
  98. package/dist-lib/vite-swift.cjs +1 -1
  99. package/dist-lib/vite.cjs +11 -28
  100. package/kotlin/README.md +32 -4
  101. package/kotlin/fixtures/counter/Counter.kt +26 -0
  102. package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
  103. package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
  104. package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
  105. package/kotlin/fork/LICENSE +178 -0
  106. package/kotlin/fork/NOTICE +25 -0
  107. package/kotlin/fork/README.md +59 -0
  108. package/kotlin/fork/build.sh +128 -0
  109. package/kotlin/fork/compose-worker-globals.patch +96 -0
  110. package/kotlin/fork/fork.json +33 -0
  111. package/kotlin/fork/publish.ts +77 -0
  112. package/kotlin/fork/skiko-worker-clock.patch +33 -0
  113. package/kotlin/toolchain.json +288 -0
  114. package/package.json +13 -5
  115. package/skills/contrast/SKILL.md +1 -0
  116. package/src/automatic-update-policy.ts +17 -0
  117. package/src/home-paths.ts +11 -9
  118. package/src/host/bridge-host.ts +3 -1
  119. package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
  120. package/src/menu.ts +11 -0
  121. package/src/metro-plugin.ts +18 -0
  122. package/src/native-app-identity.ts +376 -0
  123. package/src/native-firebase-config.ts +90 -0
  124. package/src/screenshots/frame-compose.ts +1 -1
  125. package/src/screenshots/registry.ts +1 -1
  126. package/src/screenshots/schema.ts +2 -2
  127. package/src/vite-plugin-one.ts +11 -28
  128. package/src/vite-plugin.ts +6 -1
@@ -20,6 +20,8 @@ import { shouldPrintInspectNotice } from '../inspect-notice-state'
20
20
  import { levenshtein } from '../levenshtein'
21
21
  import { TOP_LEVEL_RUNTIME_COMMANDS } from '../parse-args'
22
22
  import { rethrowIfExit, rnxExit } from '../run-rnx'
23
+ import { collectRuntimeDiagnostics } from '../runtime-diagnostics'
24
+ import { trackCliError } from '../telemetry'
23
25
  import {
24
26
  type BridgeTransportOption,
25
27
  callInBridge,
@@ -1054,7 +1056,12 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1054
1056
  args[0] === 'get' || args[0] === 'do' || args[0] === 'debug' || args[0] === 'wait'
1055
1057
  ? args[0]
1056
1058
  : null
1057
- const effectiveArgs = verbPrefix ? args.slice(1) : args
1059
+ const groupedArgs = verbPrefix ? args.slice(1) : args
1060
+ const tapTarget = groupedArgs[0] === 'tap' ? readTargetFlag(groupedArgs) : null
1061
+ const effectiveArgs =
1062
+ tapTarget?.mode === 'text'
1063
+ ? ['tap-text', tapTarget.value, ...groupedArgs.slice(1)]
1064
+ : groupedArgs
1058
1065
 
1059
1066
  const parsed = parseBridgeCliArgs(effectiveArgs, {
1060
1067
  port: opts.port,
@@ -4634,6 +4641,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4634
4641
  }
4635
4642
  } catch (err: any) {
4636
4643
  rethrowIfExit(err)
4644
+ trackCliError(err)
4637
4645
  const message = err instanceof Error ? err.message : String(err)
4638
4646
  console.error(` ${subcommand ?? 'peach'} failed: ${message}`)
4639
4647
  const unknownSim = /^no sim connected with id ([^;]+)(?:; connected sims: .+)?$/.exec(
@@ -4699,6 +4707,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4699
4707
  }
4700
4708
  rnxExit(1)
4701
4709
  } finally {
4710
+ if (subcommand !== 'list') await collectRuntimeDiagnostics(bridge)
4702
4711
  bridge.close()
4703
4712
  }
4704
4713
  }
@@ -244,7 +244,9 @@ const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
244
244
  '--screen-size',
245
245
  ])
246
246
 
247
- function splitTagList(value: string): string[] {
247
+ // shared with the cloud submitter, which resolves tag selection before any
248
+ // hosted job exists the same way a local workspace run does.
249
+ export function splitTagList(value: string): string[] {
248
250
  return value
249
251
  .split(',')
250
252
  .map((tag) => tag.trim())
@@ -12,7 +12,7 @@ import {
12
12
  listSelectableDeviceModels,
13
13
  type DeviceModel,
14
14
  type RuntimePlatform,
15
- } from 'peach-engine/settings'
15
+ } from 'peach-engine/settings/devices'
16
16
  import { settingsStore } from 'peach-engine/settings/store'
17
17
  import { rnxPublicBrand } from '../../src/public-brand'
18
18
  import { UnsupportedRemoteHostError } from '../cloud-client'
@@ -0,0 +1,386 @@
1
+ // local, read-only project metadata. never evaluate config or invoke a build.
2
+
3
+ import { execFileSync } from 'node:child_process'
4
+ import { homedir, release, tmpdir } from 'node:os'
5
+ import { basename, dirname, join, relative, resolve } from 'node:path'
6
+ import { scanDeps } from '@peach/compat/web'
7
+ import { getCliVersion } from '../../src/cli-version'
8
+ import { readActiveRuntime } from '../../src/home-paths'
9
+ import {
10
+ detectFramework,
11
+ detectMonorepoType,
12
+ detectPackageManager,
13
+ findProjectRoot,
14
+ metadataValue,
15
+ readProjectJson,
16
+ readProjectManifest,
17
+ readProjectMetadata,
18
+ resolveManifestVersions,
19
+ scanRepository,
20
+ } from './detect'
21
+ import type {
22
+ DependencyVersion,
23
+ ProjectReport,
24
+ ProjectReportEntry,
25
+ } from './projectReportContract'
26
+
27
+ function toolVersion(command: string, args = ['--version']): string | null {
28
+ try {
29
+ const text = execFileSync(command, args, {
30
+ encoding: 'utf8',
31
+ timeout: 3_000,
32
+ maxBuffer: 4_096,
33
+ cwd: tmpdir(),
34
+ stdio: ['ignore', 'pipe', 'ignore'],
35
+ }).trim()
36
+ const match =
37
+ command === 'xcodebuild'
38
+ ? /Xcode ([\d.]+)\s+Build version ([\w.]+)/.exec(text)
39
+ : /^v?([\d][\w.+-]*)$/.exec(text)
40
+ return match ? match.slice(1).join(' (build ') + (match.length > 2 ? ')' : '') : null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ export function sanitizeProjectReportName(value: string): string {
47
+ return value
48
+ .replace(/(?:https?|file):\/\/[^\s]+/g, '<redacted>')
49
+ .split(homedir())
50
+ .join('<home>')
51
+ .replace(/(?:[A-Za-z]:[\\/]|\/(?:Users|home)\/)[^\s]+/g, '<home>')
52
+ .replace(/[^\w@./ +()<>-]/g, '_')
53
+ .slice(0, 240)
54
+ }
55
+
56
+ export function sanitizeProjectReportVersion(value: string): string {
57
+ // URL, git and local dependency specs can carry credentials or home paths.
58
+ return value.length <= 80 &&
59
+ /^(?:[v^~<>=*| ]*\d[\w.+*| <>=~-]*|[a-f\d]{7,64}|\*)$/.test(value)
60
+ ? value
61
+ : 'non-version spec (redacted)'
62
+ }
63
+
64
+ function values(text: string, pattern: RegExp): string {
65
+ return [...new Set([...text.matchAll(pattern)].map((match) => match[1]))].join(', ')
66
+ }
67
+
68
+ function swiftDependencies(dir: string): Record<string, DependencyVersion> {
69
+ const dependencies: Record<string, DependencyVersion> = {}
70
+ // SwiftPM v1 and v2/v3 put the pin array in different containers.
71
+ const parsed =
72
+ readProjectJson(join(dir, 'Package.resolved')) ??
73
+ readProjectJson(join(dir, '.swiftpm/configuration/Package.resolved'))
74
+ const pins =
75
+ metadataValue(parsed, 'pins') ??
76
+ metadataValue(metadataValue(parsed, 'object'), 'pins')
77
+ if (Array.isArray(pins))
78
+ for (const pin of pins) {
79
+ const name = metadataValue(pin, 'identity') ?? metadataValue(pin, 'package')
80
+ const state = metadataValue(pin, 'state')
81
+ const version = metadataValue(state, 'version') ?? metadataValue(state, 'revision')
82
+ if (typeof name === 'string' && typeof version === 'string') {
83
+ dependencies[sanitizeProjectReportName(name).toLowerCase()] = {
84
+ version: sanitizeProjectReportVersion(version),
85
+ source: 'lockfile',
86
+ }
87
+ }
88
+ }
89
+ return dependencies
90
+ }
91
+
92
+ function podDependencies(dir: string): Record<string, DependencyVersion> {
93
+ const text = readProjectMetadata(join(dir, 'Podfile.lock')) ?? ''
94
+ const pods = text.split(/^DEPENDENCIES:/m)[0]
95
+ return Object.fromEntries(
96
+ [...pods.matchAll(/^ - ([\w./+-]+) \(([\d][\w.+-]*)\)/gm)].map((match) => [
97
+ match[1],
98
+ {
99
+ version: sanitizeProjectReportVersion(match[2]),
100
+ source: 'lockfile',
101
+ } satisfies DependencyVersion,
102
+ ]),
103
+ )
104
+ }
105
+
106
+ export function createProjectReport(
107
+ cwd = process.cwd(),
108
+ exactDirectory = false,
109
+ ): ProjectReport {
110
+ const root = exactDirectory ? resolve(cwd) : findProjectRoot(resolve(cwd), true)
111
+ const scan = scanRepository(root)
112
+ const projects: ProjectReportEntry[] = []
113
+ const lockCache = new Map<string, Map<string, string>>()
114
+ const notes = [
115
+ 'Only project paths, dependency versions and toolchain settings are included. No source, environment values, URLs or credentials.',
116
+ 'Config is inspected without execution. Unspecified or dynamic engine/architecture settings are reported as unknown.',
117
+ 'Directories named node_modules, Pods, Carthage, vendor, build, dist, dist-lib, DerivedData, coverage, target, tmp, cache, artifacts, test-results, playwright-report, and hidden directories are excluded. Directory symlinks are not followed.',
118
+ 'Limits: 12 directory levels, 10,000 directories, 200,000 entries, 4 MiB per metadata file. Paths are relative to the scanned root.',
119
+ ]
120
+ for (const dir of scan.directories) {
121
+ const path = sanitizeProjectReportName(relative(root, dir) || '.')
122
+ const manifest = readProjectManifest(dir)
123
+ if (manifest) {
124
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies }
125
+ const framework = detectFramework(declared)
126
+ if (framework !== 'unknown') {
127
+ const resolved = resolveManifestVersions(dir, manifest, lockCache)
128
+ const resolvedManifest = {
129
+ dependencies: Object.fromEntries(
130
+ Object.entries(resolved).map(([name, item]) => [name, item.version]),
131
+ ),
132
+ }
133
+ const nativeNames = scanDeps(resolvedManifest).packages.map((item) => item.name)
134
+ const dependencies: Record<string, DependencyVersion> = {}
135
+ for (const name of new Set([
136
+ 'react',
137
+ 'react-native',
138
+ 'expo',
139
+ 'one',
140
+ ...nativeNames,
141
+ ])) {
142
+ const item = resolved[name]
143
+ if (item)
144
+ dependencies[sanitizeProjectReportName(name)] = {
145
+ ...item,
146
+ version: sanitizeProjectReportVersion(item.version),
147
+ }
148
+ }
149
+ const expo = metadataValue(readProjectJson(join(dir, 'app.json')), 'expo')
150
+ const podProperties = readProjectJson(join(dir, 'ios/Podfile.properties.json'))
151
+ const gradle = readProjectMetadata(join(dir, 'android/gradle.properties')) ?? ''
152
+ const engine =
153
+ metadataValue(expo, 'jsEngine') ?? metadataValue(podProperties, 'expo.jsEngine')
154
+ const architecture =
155
+ metadataValue(expo, 'newArchEnabled') ??
156
+ metadataValue(podProperties, 'newArchEnabled')
157
+ const settings: Record<string, string> = {
158
+ jsEngine:
159
+ engine === 'hermes' || engine === 'jsc'
160
+ ? `${engine} (configured)`
161
+ : 'unknown (not configured in static metadata)',
162
+ newArchitecture:
163
+ typeof architecture === 'boolean' ||
164
+ architecture === 'true' ||
165
+ architecture === 'false'
166
+ ? `${architecture} (configured)`
167
+ : 'unknown (not configured in static metadata)',
168
+ }
169
+ const hermes = /^hermesEnabled\s*=\s*(true|false)\s*$/m.exec(gradle)
170
+ const newArch = /^newArchEnabled\s*=\s*(true|false)\s*$/m.exec(gradle)
171
+ if (hermes)
172
+ settings.androidJsEngine = `${hermes[1] === 'true' ? 'hermes' : 'jsc'} (gradle.properties)`
173
+ if (newArch) settings.androidNewArchitecture = `${newArch[1]} (gradle.properties)`
174
+ if (resolved.expo)
175
+ settings.expoSdk = `${sanitizeProjectReportVersion(resolved.expo.version)} (${resolved.expo.source}; expo package version)`
176
+ projects.push({
177
+ path,
178
+ name: sanitizeProjectReportName(manifest.name ?? basename(dir)),
179
+ kind: framework === 'bare' ? 'react-native' : framework,
180
+ settings,
181
+ dependencies,
182
+ })
183
+ }
184
+ }
185
+
186
+ const folder = basename(dir)
187
+ if (folder.endsWith('.xcodeproj') || folder.endsWith('.xcworkspace')) {
188
+ // xcodeproj's internal workspace is not a second app.
189
+ if (basename(dirname(dir)).endsWith('.xcodeproj')) continue
190
+ const pbx = readProjectMetadata(join(dir, 'project.pbxproj')) ?? ''
191
+ const settings: Record<string, string> = {}
192
+ const deployment = values(pbx, /IPHONEOS_DEPLOYMENT_TARGET\s*=\s*"?([\d.]+)/g)
193
+ const swift = values(pbx, /SWIFT_VERSION\s*=\s*"?([\d.]+)/g)
194
+ settings.iOSDeploymentTarget =
195
+ deployment || 'unknown (workspace or external build settings)'
196
+ settings.swiftVersion = swift || 'unknown (workspace or external build settings)'
197
+ const pins = swiftDependencies(
198
+ join(dir, 'project.xcworkspace/xcshareddata/swiftpm'),
199
+ )
200
+ const workspacePins = swiftDependencies(join(dir, 'xcshareddata/swiftpm'))
201
+ projects.push({
202
+ path,
203
+ name: sanitizeProjectReportName(folder),
204
+ kind: 'native-ios',
205
+ settings,
206
+ dependencies: { ...podDependencies(dirname(dir)), ...pins, ...workspacePins },
207
+ })
208
+ }
209
+
210
+ const swiftPackage = readProjectMetadata(join(dir, 'Package.swift'))
211
+ if (swiftPackage) {
212
+ const settings: Record<string, string> = {
213
+ swiftToolsVersion:
214
+ /swift-tools-version:\s*([\d.]+)/.exec(swiftPackage)?.[1] ?? 'unknown',
215
+ iOSDeploymentTarget:
216
+ values(swiftPackage, /\.iOS\(\s*\.v([\d_]+)/g).replace(/_/g, '.') ||
217
+ 'unspecified',
218
+ }
219
+ const language = values(swiftPackage, /\.swiftLanguageMode\(\.v(\d+)/g)
220
+ if (language) settings.swiftLanguageVersion = language
221
+ const dependencies = swiftDependencies(dir)
222
+ for (const match of swiftPackage.matchAll(
223
+ /\.package\(\s*url:\s*"[^"]*\/([\w.-]+?)(?:\.git)?"\s*,\s*(?:from:|exact:)\s*"([\d.]+)"/g,
224
+ )) {
225
+ const name = sanitizeProjectReportName(match[1]).toLowerCase()
226
+ if (!dependencies[name])
227
+ dependencies[name] = { version: match[2], source: 'declared' }
228
+ }
229
+ const local = [...swiftPackage.matchAll(/\.package\(\s*path:/g)].length
230
+ if (local) settings.localDependencies = String(local)
231
+ projects.push({
232
+ path,
233
+ name: sanitizeProjectReportName(
234
+ /\bname:\s*"([\w .-]+)"/.exec(swiftPackage)?.[1] ?? basename(dir),
235
+ ),
236
+ kind: 'swift-package',
237
+ settings,
238
+ dependencies,
239
+ })
240
+ }
241
+
242
+ const gradleSettings =
243
+ readProjectMetadata(join(dir, 'settings.gradle')) ??
244
+ readProjectMetadata(join(dir, 'settings.gradle.kts'))
245
+ if (gradleSettings !== null) {
246
+ const buildFiles = [
247
+ 'settings.gradle',
248
+ 'settings.gradle.kts',
249
+ 'build.gradle',
250
+ 'build.gradle.kts',
251
+ 'app/build.gradle',
252
+ 'app/build.gradle.kts',
253
+ 'gradle.properties',
254
+ 'gradle/libs.versions.toml',
255
+ ]
256
+ .map((file) => readProjectMetadata(join(dir, file)) ?? '')
257
+ .join('\n')
258
+ const wrapper =
259
+ readProjectMetadata(join(dir, 'gradle/wrapper/gradle-wrapper.properties')) ?? ''
260
+ const agp =
261
+ values(buildFiles, /com\.android\.tools\.build:gradle:([\d.]+)/g) ||
262
+ values(
263
+ buildFiles,
264
+ /id\s*(?:\(\s*)?["']com\.android\.(?:application|library)["']\s*\)?\s*version\s*["']([\d.]+)/g,
265
+ ) ||
266
+ values(buildFiles, /\bagp\s*=\s*["']([\d.]+)/g)
267
+ const kotlin =
268
+ values(
269
+ buildFiles,
270
+ /(?:kotlinVersion|kotlin_version|kotlin)\s*=\s*["']([\d.]+)/g,
271
+ ) ||
272
+ values(
273
+ buildFiles,
274
+ /id\s*(?:\(\s*)?["'](?:org\.jetbrains\.kotlin\.[\w.]+|kotlin-android)["']\s*\)?\s*version\s*["']([\d.]+)/g,
275
+ )
276
+ projects.push({
277
+ path,
278
+ name: sanitizeProjectReportName(basename(dir)),
279
+ kind: 'android',
280
+ settings: {
281
+ gradleVersion:
282
+ /gradle-([\d.]+)-(?:bin|all)\.zip/.exec(wrapper)?.[1] ?? 'unknown',
283
+ androidGradlePluginVersion: agp || 'unknown (not a static version)',
284
+ kotlinVersion: kotlin || 'unknown (not a static version)',
285
+ },
286
+ dependencies: {},
287
+ })
288
+ }
289
+ }
290
+ if (scan.truncated) notes.push('Discovery reached a limit; this report is incomplete.')
291
+ if (scan.unreadable)
292
+ notes.push('Some directories could not be read; this report may be incomplete.')
293
+ const manager = detectPackageManager(root)
294
+ const rootManifest = readProjectJson(join(root, 'package.json'))
295
+ const packageManager = metadataValue(rootManifest, 'packageManager')
296
+ const managerVersion =
297
+ typeof packageManager === 'string'
298
+ ? (/^(?:bun|pnpm|npm|yarn)@([\d.]+)/.exec(packageManager)?.[1] ?? null)
299
+ : null
300
+ const runtimeVersion = readActiveRuntime()
301
+ return {
302
+ schemaVersion: 1,
303
+ machine: {
304
+ os: `${process.platform} ${release()}`,
305
+ arch: process.arch,
306
+ xcode:
307
+ process.platform === 'darwin' ? toolVersion('xcodebuild', ['-version']) : null,
308
+ node: toolVersion('node'),
309
+ bun: toolVersion('bun'),
310
+ peachCli: sanitizeProjectReportVersion(getCliVersion()),
311
+ peachRuntime: runtimeVersion ? sanitizeProjectReportVersion(runtimeVersion) : null,
312
+ },
313
+ repository: {
314
+ root: '.',
315
+ monorepo: detectMonorepoType(root),
316
+ packageManager: rootManifest ? manager : 'none detected',
317
+ packageManagerVersion: managerVersion,
318
+ directoriesScanned: scan.directories.length,
319
+ truncated: scan.truncated,
320
+ unreadableDirectories: scan.unreadable,
321
+ },
322
+ projects,
323
+ notes,
324
+ }
325
+ }
326
+
327
+ export function formatProjectReport(report: ProjectReport): string {
328
+ const machine = report.machine
329
+ const lines = [
330
+ 'peach project report',
331
+ '',
332
+ `machine: ${machine.os} / ${machine.arch}`,
333
+ `tools: Xcode ${machine.xcode ?? 'not available'}, Node ${machine.node ?? 'not available'}, Bun ${machine.bun ?? 'not available'}`,
334
+ `peach: CLI ${machine.peachCli}, active runtime ${machine.peachRuntime ?? 'not installed'}`,
335
+ `repository: ${report.repository.monorepo}, ${report.repository.packageManager}${report.repository.packageManagerVersion ? ` ${report.repository.packageManagerVersion} (declared)` : ''}`,
336
+ `discovery: ${report.projects.length} app/package entries, ${report.repository.directoriesScanned} directories${report.repository.truncated ? ' (LIMIT REACHED)' : ''}`,
337
+ '',
338
+ ]
339
+ for (const entry of report.projects) {
340
+ lines.push(`${entry.path} [${entry.kind}] ${entry.name}`)
341
+ for (const [key, value] of Object.entries(entry.settings))
342
+ lines.push(` ${key}: ${value}`)
343
+ for (const [name, item] of Object.entries(entry.dependencies))
344
+ lines.push(` ${name}: ${item.version} (${item.source})`)
345
+ lines.push('')
346
+ }
347
+ if (!report.projects.length)
348
+ lines.push(
349
+ 'No recognized React Native, Expo, native iOS, Android or Swift projects found.',
350
+ '',
351
+ )
352
+ lines.push(
353
+ ...report.notes,
354
+ '',
355
+ 'To send this report: peach report-issue "<description>"',
356
+ 'Review the preview and explicitly confirm before sending.',
357
+ '',
358
+ )
359
+ return lines.join('\n')
360
+ }
361
+
362
+ export function runProjectReport(args: string[]): number {
363
+ let app: string | undefined
364
+ for (let index = 0; index < args.length; index++) {
365
+ const arg = args[index]
366
+ if (arg === '--json') continue
367
+ if (arg === '--app' && args[index + 1] && !args[index + 1].startsWith('-')) {
368
+ app = args[++index]
369
+ continue
370
+ }
371
+ process.stderr.write(
372
+ `unknown flag or argument: ${arg}\nusage: peach project-report [--app <dir>] [--json]\n`,
373
+ )
374
+ return 1
375
+ }
376
+ const report = createProjectReport(
377
+ app ? resolve(app) : process.cwd(),
378
+ app !== undefined,
379
+ )
380
+ process.stdout.write(
381
+ args.includes('--json')
382
+ ? `${JSON.stringify(report, null, 2)}\n`
383
+ : formatProjectReport(report),
384
+ )
385
+ return 0
386
+ }
@@ -0,0 +1,42 @@
1
+ export type ProjectManifest = {
2
+ name?: string
3
+ dependencies?: Record<string, string>
4
+ devDependencies?: Record<string, string>
5
+ }
6
+
7
+ export type DependencyVersion = {
8
+ version: string
9
+ source: 'installed' | 'lockfile' | 'declared'
10
+ }
11
+
12
+ export type ProjectReportEntry = {
13
+ path: string
14
+ name: string
15
+ kind: string
16
+ settings: Record<string, string>
17
+ dependencies: Record<string, DependencyVersion>
18
+ }
19
+
20
+ export type ProjectReport = {
21
+ schemaVersion: 1
22
+ machine: {
23
+ os: string
24
+ arch: string
25
+ xcode: string | null
26
+ node: string | null
27
+ bun: string | null
28
+ peachCli: string
29
+ peachRuntime: string | null
30
+ }
31
+ repository: {
32
+ root: '.'
33
+ monorepo: string
34
+ packageManager: string
35
+ packageManagerVersion: string | null
36
+ directoriesScanned: number
37
+ truncated: boolean
38
+ unreadableDirectories: number
39
+ }
40
+ projects: ProjectReportEntry[]
41
+ notes: string[]
42
+ }
@@ -1,27 +1,30 @@
1
1
  // peach report-issue — submit an explicitly approved compatibility report.
2
2
 
3
- import { existsSync, readFileSync } from 'node:fs'
3
+ import { existsSync } from 'node:fs'
4
4
  import { resolve } from 'node:path'
5
5
  import { getCliVersion } from '../../src/cli-version'
6
6
  import { readActiveRuntime } from '../../src/home-paths'
7
7
  import { authHeaderValue, resolveCliAuth } from '../auth'
8
8
  import { confirm } from '../prompt'
9
+ import { installedManifest, readProjectManifest } from './detect'
10
+ import {
11
+ createProjectReport,
12
+ formatProjectReport,
13
+ sanitizeProjectReportName,
14
+ sanitizeProjectReportVersion,
15
+ } from './project-report'
16
+ import type { ProjectReport } from './projectReportContract'
9
17
  import type { ScanResult } from '@peach/compat/web'
10
18
 
11
19
  const DEFAULT_REPORT_ORIGIN = 'https://contrast.dev'
12
20
  const MAX_DESCRIPTION_LENGTH = 8_000
13
21
 
14
- type PackageManifest = {
15
- name?: string
16
- dependencies?: Record<string, string>
17
- devDependencies?: Record<string, string>
18
- }
19
-
20
22
  type ReportIssueOptions = {
21
23
  appDir: string
22
24
  description: string
23
25
  dryRun: boolean
24
26
  yes: boolean
27
+ scopeToApp: boolean
25
28
  }
26
29
 
27
30
  export type ReportIssuePayload = {
@@ -37,6 +40,7 @@ export type ReportIssuePayload = {
37
40
  platform: NodeJS.Platform
38
41
  arch: string
39
42
  compatibility: ScanResult | null
43
+ projectReport: ProjectReport
40
44
  }
41
45
  }
42
46
 
@@ -104,60 +108,38 @@ export function parseReportIssueOptions(
104
108
  return {
105
109
  appDir: resolve(cwd, app ?? '.'),
106
110
  description,
111
+ scopeToApp: app !== null,
107
112
  dryRun: args.includes('--dry-run'),
108
113
  yes: args.includes('--yes') || args.includes('-y'),
109
114
  }
110
115
  }
111
116
 
112
- function readStringRecord(value: unknown): Record<string, string> | undefined {
113
- if (!value || typeof value !== 'object') return undefined
114
- const output: Record<string, string> = {}
115
- for (const key of Object.keys(value)) {
116
- const next = Object.getOwnPropertyDescriptor(value, key)?.value
117
- if (typeof next === 'string') output[key] = next
118
- }
119
- return output
120
- }
121
-
122
- function readManifest(appDir: string): PackageManifest | null {
123
- const path = resolve(appDir, 'package.json')
124
- if (!existsSync(path)) return null
125
-
126
- let parsed: unknown
127
- try {
128
- parsed = JSON.parse(readFileSync(path, 'utf8'))
129
- } catch {
130
- throw new Error(`could not parse ${path}`)
131
- }
132
- if (!parsed || typeof parsed !== 'object') {
133
- throw new Error(`${path} must contain a JSON object`)
134
- }
135
-
136
- const name = Object.getOwnPropertyDescriptor(parsed, 'name')?.value
137
- const dependencies = Object.getOwnPropertyDescriptor(parsed, 'dependencies')?.value
138
- const devDependencies = Object.getOwnPropertyDescriptor(
139
- parsed,
140
- 'devDependencies',
141
- )?.value
142
- const dependencyRecord = readStringRecord(dependencies)
143
- const devDependencyRecord = readStringRecord(devDependencies)
144
- return {
145
- ...(typeof name === 'string' ? { name } : {}),
146
- ...(dependencyRecord ? { dependencies: dependencyRecord } : {}),
147
- ...(devDependencyRecord ? { devDependencies: devDependencyRecord } : {}),
148
- }
149
- }
150
-
151
117
  export async function createReportIssuePayload(
152
- options: Pick<ReportIssueOptions, 'appDir' | 'description'>,
118
+ options: Pick<ReportIssueOptions, 'appDir' | 'description'> & { scopeToApp?: boolean },
153
119
  now = new Date(),
154
120
  ): Promise<ReportIssuePayload> {
155
- const manifest = readManifest(options.appDir)
156
- const compatibility = manifest
157
- ? (await import('@peach/compat/web')).scanDeps(manifest)
121
+ const manifest = readProjectManifest(options.appDir)
122
+ const installed = manifest ? installedManifest(options.appDir, manifest) : null
123
+ const safeVersions = (deps: Record<string, string> | undefined) =>
124
+ deps &&
125
+ Object.fromEntries(
126
+ Object.entries(deps).map(([name, version]) => [
127
+ name,
128
+ sanitizeProjectReportVersion(version),
129
+ ]),
130
+ )
131
+ const compatibility = installed
132
+ ? (await import('@peach/compat/web')).scanDeps({
133
+ name: installed.name ? sanitizeProjectReportName(installed.name) : undefined,
134
+ dependencies: safeVersions(installed.dependencies),
135
+ devDependencies: safeVersions(installed.devDependencies),
136
+ })
137
+ : null
138
+ const cliVersion = sanitizeProjectReportVersion(getCliVersion())
139
+ const activeRuntime = readActiveRuntime()
140
+ const runtimeVersion = activeRuntime
141
+ ? sanitizeProjectReportVersion(activeRuntime)
158
142
  : null
159
- const cliVersion = getCliVersion()
160
- const runtimeVersion = readActiveRuntime()
161
143
 
162
144
  return {
163
145
  note: options.description,
@@ -175,6 +157,7 @@ export async function createReportIssuePayload(
175
157
  platform: process.platform,
176
158
  arch: process.arch,
177
159
  compatibility,
160
+ projectReport: createProjectReport(options.appDir, options.scopeToApp),
178
161
  },
179
162
  }
180
163
  }
@@ -243,7 +226,8 @@ export async function runReportIssue(
243
226
  ` environment: CLI ${payload.bundle.cliVersion}, runtime ${
244
227
  payload.bundle.runtimeVersion ?? 'not installed'
245
228
  }, ${payload.bundle.platform}/${payload.bundle.arch}\n\n` +
246
- ` Includes only the description, compatibility scan, and the version/platform details above.\n` +
229
+ `${formatProjectReport(payload.bundle.projectReport)}\n` +
230
+ ` Includes only the description, compatibility scan, project/dependency metadata, and toolchain details above.\n` +
247
231
  ` It does not include source files, environment variables, terminal output, logs, screenshots, or app data.\n`,
248
232
  )
249
233
 
@@ -260,7 +244,7 @@ export async function runReportIssue(
260
244
  return 2
261
245
  }
262
246
  const approved = await dependencies.confirmSubmission(
263
- 'send this compatibility report to the peach team?',
247
+ 'send this issue and project report to the peach team?',
264
248
  false,
265
249
  )
266
250
  if (!approved) {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { mkdirSync, writeFileSync } from 'fs'
11
11
  import { resolve } from 'path'
12
- import { devices } from 'peach-engine/settings'
12
+ import { devices } from 'peach-engine/settings/devices'
13
13
  import { buildResolveRectEval, type SampleRect } from '../browser-evals'
14
14
  import { rnxExit } from '../run-rnx'
15
15
  import { callInBridge, createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { mkdirSync, writeFileSync } from 'fs'
10
10
  import { dirname, resolve } from 'path'
11
- import { devices, type DeviceModel } from 'peach-engine/settings'
11
+ import { devices, type DeviceModel } from 'peach-engine/settings/devices'
12
12
  import { launchReapedChrome } from '../../../../scripts/lib/reap-browser'
13
13
  import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
14
14
  import { composeFramedScreenshot } from '../../src/screenshots/frame-compose'