peachsim 0.1.2210 → 0.1.2213

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 (120) hide show
  1. package/cli/command-registry.ts +1 -0
  2. package/cli/commands/inspect/settling.ts +1 -1
  3. package/cli/commands/map.ts +576 -0
  4. package/cli/commands/runtime.ts +1 -1
  5. package/cli/main.ts +6 -0
  6. package/cli/outbound-endpoints.ts +11 -0
  7. package/dist-lib/agent-daemon-client.cjs +1 -1
  8. package/dist-lib/agent-events.cjs +1 -1
  9. package/dist-lib/attached-projects.cjs +1 -1
  10. package/dist-lib/auth/shared-session.cjs +1 -1
  11. package/dist-lib/backend-origin.cjs +1 -1
  12. package/dist-lib/beta.cjs +1 -1
  13. package/dist-lib/beta.mjs +1 -1
  14. package/dist-lib/bridge-constants.cjs +1 -1
  15. package/dist-lib/bridge-contract-input.cjs +1 -1
  16. package/dist-lib/bridge-contract-input.mjs +1 -1
  17. package/dist-lib/bridge-contract.cjs +1 -1
  18. package/dist-lib/bridge-contract.mjs +1 -1
  19. package/dist-lib/bundle-channel.cjs +16 -7
  20. package/dist-lib/capture-contract.cjs +1 -1
  21. package/dist-lib/capture-contract.mjs +1 -1
  22. package/dist-lib/cloud-contract.cjs +1 -1
  23. package/dist-lib/cloud-contract.mjs +1 -1
  24. package/dist-lib/cloud-test-args.cjs +1 -1
  25. package/dist-lib/cloud-test-args.mjs +1 -1
  26. package/dist-lib/cloud.cjs +1 -1
  27. package/dist-lib/cloud.mjs +1 -1
  28. package/dist-lib/config.cjs +1 -1
  29. package/dist-lib/config.mjs +1 -1
  30. package/dist-lib/detox/index.cjs +6 -3
  31. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  32. package/dist-lib/expo-swift-bundle.cjs +14 -5
  33. package/dist-lib/expo-swift-metadata.cjs +1 -1
  34. package/dist-lib/expo-swift-metadata.mjs +1 -1
  35. package/dist-lib/home-paths.cjs +1 -1
  36. package/dist-lib/host/bridge-host.cjs +999 -811
  37. package/dist-lib/host/fetch-proxy-handler.cjs +8 -5
  38. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  39. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  40. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  41. package/dist-lib/host/websocket-proxy.cjs +1 -1
  42. package/dist-lib/index.cjs +1953 -752
  43. package/dist-lib/jump-to-source-babel.cjs +1 -1
  44. package/dist-lib/jump-to-source-native.cjs +1 -1
  45. package/dist-lib/kotlin-host.cjs +1 -1
  46. package/dist-lib/menu.cjs +1 -1
  47. package/dist-lib/menu.mjs +1 -1
  48. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  49. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  50. package/dist-lib/metro-production-bundle.cjs +1 -1
  51. package/dist-lib/metro-production-bundle.mjs +1 -1
  52. package/dist-lib/metro.cjs +1793 -593
  53. package/dist-lib/native-app-identity-project.cjs +6 -3
  54. package/dist-lib/native-app-identity-project.mjs +6 -3
  55. package/dist-lib/native-app-identity.cjs +1 -1
  56. package/dist-lib/native-app-identity.mjs +1 -1
  57. package/dist-lib/native-firebase-metadata.cjs +1 -1
  58. package/dist-lib/native-firebase-metadata.mjs +1 -1
  59. package/dist-lib/pdfium-raster.cjs +1 -1
  60. package/dist-lib/pdfium-raster.mjs +1 -1
  61. package/dist-lib/plist.cjs +1 -1
  62. package/dist-lib/plist.mjs +1 -1
  63. package/dist-lib/profiles.cjs +1 -1
  64. package/dist-lib/public-brand.cjs +1 -1
  65. package/dist-lib/public-origins-lib-esm.json +25 -24
  66. package/dist-lib/public-origins-lib.json +73 -53
  67. package/dist-lib/react-native-host-modules.cjs +1 -1
  68. package/dist-lib/react-native-host-modules.mjs +1 -1
  69. package/dist-lib/reap-browser.cjs +1 -1
  70. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  71. package/dist-lib/sdk.cjs +2 -2
  72. package/dist-lib/sdk.mjs +2 -2
  73. package/dist-lib/skills.cjs +1535 -1289
  74. package/dist-lib/swift-compile.cjs +2 -2
  75. package/dist-lib/swift-compile.mjs +2 -2
  76. package/dist-lib/swift-package.cjs +1 -1
  77. package/dist-lib/swift-package.mjs +1 -1
  78. package/dist-lib/swift-submit.cjs +1 -1
  79. package/dist-lib/swift-submit.mjs +1 -1
  80. package/dist-lib/swift-toolchain.cjs +1 -1
  81. package/dist-lib/swift.cjs +1 -1
  82. package/dist-lib/vite-swift.cjs +7 -4
  83. package/dist-lib/vite.cjs +1517 -303
  84. package/dist-lib/worklet-executable.cjs +1 -1
  85. package/dist-lib/worklet-executable.mjs +1 -1
  86. package/dist-types/packages/peach-engine/src/engine/font-config.d.ts +1 -1
  87. package/dist-types/packages/peach-engine/src/engine/node.d.ts +1 -0
  88. package/kotlin/sdk.tar.gz +0 -0
  89. package/package.json +1 -1
  90. package/runtime-companion.json +1 -1
  91. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/CoreData.apinotes +35 -0
  92. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/CoreData.h +8 -0
  93. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSAttributeDescription.h +30 -0
  94. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSEntityDescription.h +14 -0
  95. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSExpressionDescription.h +10 -0
  96. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSFetchIndexDescription.h +13 -0
  97. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSFetchIndexElementDescription.h +18 -0
  98. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSManagedObject.h +11 -0
  99. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSManagedObjectContext.h +19 -0
  100. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSManagedObjectID.h +2 -0
  101. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSManagedObjectModel.h +3 -0
  102. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSMergePolicy.h +17 -0
  103. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSPersistentStore.h +8 -0
  104. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSPersistentStoreCoordinator.h +6 -0
  105. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSPropertyDescription.h +13 -0
  106. package/sdk/ios/System/Library/Frameworks/CoreData.framework/Headers/NSRelationshipDescription.h +21 -0
  107. package/sdk/ios/System/Library/Frameworks/UIKit.framework/Headers/UIImage.h +1 -10
  108. package/sdk/ios/System/Library/Frameworks/UIKit.framework/Headers/UINibLoading.h +1 -1
  109. package/sdk/ios/System/Library/Frameworks/UIKitTypes.framework/Headers/UIKitTypes.h +11 -0
  110. package/skills/contrast/SKILL.md +1 -0
  111. package/src/home-paths.ts +1 -1
  112. package/src/host/bridge-host.ts +101 -97
  113. package/src/metro-plugin.ts +4 -1
  114. package/src/react-native-coverage/native-source-ranges-file.ts +1 -1
  115. package/src/runtime-assets.ts +0 -1
  116. package/src/runtime-delivery.ts +1 -1
  117. package/src/runtime-packs.ts +212 -0
  118. package/src/swift-shipped-binaries.ts +1 -1
  119. package/src/swift-standin-config.ts +4 -2
  120. package/src/vite-plugin-one.ts +4 -1
@@ -23,6 +23,7 @@ const NODE_COMMAND_NAMES = [
23
23
  'storage',
24
24
  'permissions',
25
25
  'perf',
26
+ 'map',
26
27
  'screenshot',
27
28
  'state',
28
29
  'reset',
@@ -541,7 +541,7 @@ export async function waitForSootsimIdle({
541
541
  const bounds = element.getBoundingClientRect()
542
542
  return element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) &&
543
543
  bounds.width > 0 && bounds.height > 0 && bounds.right > 0 && bounds.bottom > 0 &&
544
- bounds.left < window.innerWidth && bounds.top < window.innerHeight
544
+ bounds.left < globalThis.innerWidth && bounds.top < globalThis.innerHeight
545
545
  })
546
546
  if (busyEl) {
547
547
  webViewStarting = true
@@ -0,0 +1,576 @@
1
+ // peach map: build a branch screen map screen by screen, or crawl routes
2
+ // by deep link, then publish it to peach cloud (/api/maps). the local
3
+ // working file is peach-map.json with pngs under peach-map-assets/.
4
+ // capture reuses `peach screenshot` so readiness and fidelity match.
5
+
6
+ import { execSync } from 'node:child_process'
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from 'node:fs'
15
+ import { dirname, join, resolve } from 'node:path'
16
+ import { isRecord } from '../../src/is-record'
17
+ import { sleep } from '../../src/sleep'
18
+ import { authHeaderValue, resolveCliAuth } from '../auth'
19
+ import { createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
20
+ import { inspectDescribe, type InspectBridge } from './inspect/core'
21
+ import { runScreenshot } from './screenshot'
22
+ import { resolveDefaultUploadOrigin } from './upload'
23
+
24
+ type LocalScreen = {
25
+ id: string
26
+ route: string | null
27
+ title: string
28
+ state: string | null
29
+ png: string
30
+ source: string
31
+ }
32
+
33
+ type LocalEdge = {
34
+ from: string
35
+ to: string
36
+ label: string | null
37
+ testID: string | null
38
+ source: string
39
+ }
40
+
41
+ type LocalMap = {
42
+ version: 1
43
+ screens: LocalScreen[]
44
+ edges: LocalEdge[]
45
+ gaps: Array<{ screen: string; reason: string }>
46
+ }
47
+
48
+ const HELP = `
49
+ peach map — capture screens tied to routes and publish the branch map
50
+
51
+ usage:
52
+ peach map add --route /settings --title Settings [--from / --label Settings]
53
+ peach map crawl --scheme myapp:// --routes /,/settings,/profile
54
+ peach map crawl --app ~/my-app [--scheme myapp://] [--routes /,/settings]
55
+ peach map list
56
+ peach map publish --owner acme --repo my-app [--branch main]
57
+
58
+ add options:
59
+ --route <path> route this screen answers to (the screen id)
60
+ --id <id> screen id when it is not the route
61
+ --title <text> display title (default: the route)
62
+ --state <name> a second state of the same screen (e.g. signed-in)
63
+ --from <screen> screen the tap started from (adds an edge)
64
+ --label <text> tapped control label for the edge
65
+ --testid <id> tapped control testID for the edge
66
+ --png <file> use an existing png instead of capturing the sim
67
+ --file <path> working file (default: peach-map.json)
68
+
69
+ crawl options:
70
+ --app <dir> discover routes from the app router dir, scheme from app.json
71
+ --scheme <url> deep link scheme, e.g. myapp:// (required unless --app finds one)
72
+ --routes <list> comma separated routes to open and capture (required unless --app)
73
+ --settle-ms <n> wait per route for the tree to change, 0 to skip (default: 15000)
74
+
75
+ publish options:
76
+ --owner <org> github owner the cloud repo is linked under (required)
77
+ --repo <name> repo name (required)
78
+ --branch <name> branch the map belongs to (default: current git branch)
79
+ --commit <sha> commit the map was captured at (default: git HEAD)
80
+ --device <name> device label (default: default)
81
+ --appearance <a> light | dark (default: light)
82
+ --origin <url> cloud origin (default: your session origin)
83
+
84
+ examples:
85
+ peach open 8081
86
+ peach map add --route / --title Home
87
+ peach do tap-text Settings
88
+ peach map add --route /settings --title Settings --from / --label Settings
89
+ peach map publish --owner acme --repo my-app
90
+ `
91
+
92
+ function flag(args: string[], name: string): string | undefined {
93
+ const index = args.indexOf(name)
94
+ if (index < 0) return undefined
95
+ const value = args[index + 1]
96
+ if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`)
97
+ return value
98
+ }
99
+
100
+ function screenKey(id: string, state: string | null): string {
101
+ return state ? `${id}#${state}` : id
102
+ }
103
+
104
+ function assetName(key: string): string {
105
+ return `${key.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'root'}.png`
106
+ }
107
+
108
+ function readLocalMap(file: string): LocalMap {
109
+ if (!existsSync(file)) return { version: 1, screens: [], edges: [], gaps: [] }
110
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
111
+ if (!isRecord(parsed) || !Array.isArray(parsed.screens)) {
112
+ throw new Error(`${file} is not a peach map file`)
113
+ }
114
+ const screens: LocalScreen[] = []
115
+ for (const raw of parsed.screens) {
116
+ if (!isRecord(raw)) continue
117
+ if (typeof raw.id !== 'string' || typeof raw.title !== 'string') continue
118
+ if (typeof raw.png !== 'string') continue
119
+ screens.push({
120
+ id: raw.id,
121
+ route: typeof raw.route === 'string' ? raw.route : null,
122
+ title: raw.title,
123
+ state: typeof raw.state === 'string' ? raw.state : null,
124
+ png: raw.png,
125
+ source: typeof raw.source === 'string' ? raw.source : 'manual',
126
+ })
127
+ }
128
+ const edges: LocalEdge[] = []
129
+ if (Array.isArray(parsed.edges)) {
130
+ for (const raw of parsed.edges) {
131
+ if (!isRecord(raw)) continue
132
+ if (typeof raw.from !== 'string' || typeof raw.to !== 'string') continue
133
+ edges.push({
134
+ from: raw.from,
135
+ to: raw.to,
136
+ label: typeof raw.label === 'string' ? raw.label : null,
137
+ testID: typeof raw.testID === 'string' ? raw.testID : null,
138
+ source: typeof raw.source === 'string' ? raw.source : 'manual',
139
+ })
140
+ }
141
+ }
142
+ const gaps: LocalMap['gaps'] = []
143
+ if (Array.isArray(parsed.gaps)) {
144
+ for (const raw of parsed.gaps) {
145
+ if (!isRecord(raw)) continue
146
+ if (typeof raw.screen !== 'string' || typeof raw.reason !== 'string') continue
147
+ gaps.push({ screen: raw.screen, reason: raw.reason })
148
+ }
149
+ }
150
+ return { version: 1, screens, edges, gaps }
151
+ }
152
+
153
+ function writeLocalMap(file: string, map: LocalMap): void {
154
+ writeFileSync(file, `${JSON.stringify(map, null, 2)}\n`)
155
+ }
156
+
157
+ function upsertScreen(map: LocalMap, screen: LocalScreen, edge: LocalEdge | null): void {
158
+ const key = screenKey(screen.id, screen.state)
159
+ const index = map.screens.findIndex((entry) => screenKey(entry.id, entry.state) === key)
160
+ if (index >= 0) map.screens[index] = screen
161
+ else map.screens.push(screen)
162
+ map.gaps = map.gaps.filter((gap) => gap.screen !== key)
163
+ if (edge && !map.edges.some((e) => e.from === edge.from && e.to === edge.to)) {
164
+ map.edges.push(edge)
165
+ }
166
+ }
167
+
168
+ // a crawl is rerun as the app grows: one gap per screen, the newest reason.
169
+ function recordGaps(file: string, gaps: LocalMap['gaps']): void {
170
+ const map = readLocalMap(file)
171
+ const named = new Set(gaps.map((gap) => gap.screen))
172
+ map.gaps = [...map.gaps.filter((gap) => !named.has(gap.screen)), ...gaps]
173
+ writeLocalMap(file, map)
174
+ }
175
+
176
+ async function captureTo(
177
+ pngPath: string,
178
+ opts: { port?: number; verbose?: boolean },
179
+ ): Promise<void> {
180
+ mkdirSync(dirname(pngPath), { recursive: true })
181
+ await runScreenshot(['--output', pngPath], opts)
182
+ if (!existsSync(pngPath)) throw new Error(`screenshot did not write ${pngPath}`)
183
+ }
184
+
185
+ export async function runMap(
186
+ args: string[],
187
+ opts: { port?: number; verbose?: boolean },
188
+ ): Promise<number> {
189
+ try {
190
+ return await dispatch(args, opts)
191
+ } catch (error) {
192
+ console.error(
193
+ ` map failed: ${error instanceof Error ? error.message : String(error)}`,
194
+ )
195
+ return 1
196
+ }
197
+ }
198
+
199
+ async function dispatch(
200
+ args: string[],
201
+ opts: { port?: number; verbose?: boolean },
202
+ ): Promise<number> {
203
+ const [verb, ...rest] = args
204
+ if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
205
+ console.log(HELP)
206
+ return 0
207
+ }
208
+ const file = resolve(flag(rest, '--file') ?? 'peach-map.json')
209
+ if (verb === 'add') return addScreen(file, rest, opts, 'manual')
210
+ if (verb === 'crawl') return crawl(file, rest, opts)
211
+ if (verb === 'list') {
212
+ const map = readLocalMap(file)
213
+ for (const screen of map.screens) {
214
+ console.log(
215
+ ` ${screenKey(screen.id, screen.state)} ${screen.title}${screen.route ? ` (${screen.route})` : ''}`,
216
+ )
217
+ }
218
+ for (const edge of map.edges) {
219
+ console.log(
220
+ ` ${edge.from} -> ${edge.to}${edge.label ? ` tap "${edge.label}"` : ''}${edge.testID ? ` #${edge.testID}` : ''}`,
221
+ )
222
+ }
223
+ for (const gap of map.gaps) console.log(` gap: ${gap.screen} — ${gap.reason}`)
224
+ console.log(` ${map.screens.length} screens, ${map.edges.length} edges`)
225
+ return 0
226
+ }
227
+ if (verb === 'publish') return publish(file, rest)
228
+ console.error(` unknown map verb: ${verb}\n${HELP}`)
229
+ return 1
230
+ }
231
+
232
+ async function addScreen(
233
+ file: string,
234
+ args: string[],
235
+ opts: { port?: number; verbose?: boolean },
236
+ source: string,
237
+ prebuilt?: { id: string; route: string; title: string },
238
+ ): Promise<number> {
239
+ const route = prebuilt?.route ?? flag(args, '--route')
240
+ const id = prebuilt?.id ?? flag(args, '--id') ?? route
241
+ if (!id) throw new Error('peach map add needs --route or --id')
242
+ const title = prebuilt?.title ?? flag(args, '--title') ?? route ?? id
243
+ const state = flag(args, '--state') ?? null
244
+ const key = screenKey(id, state)
245
+ const assetDir = join(dirname(file), 'peach-map-assets')
246
+ const assetPath = join(assetDir, assetName(key))
247
+ const givenPng = flag(args, '--png')
248
+ if (givenPng) {
249
+ mkdirSync(assetDir, { recursive: true })
250
+ writeFileSync(assetPath, readFileSync(resolve(givenPng)))
251
+ } else {
252
+ await captureTo(assetPath, opts)
253
+ }
254
+ const from = flag(args, '--from')
255
+ const map = readLocalMap(file)
256
+ upsertScreen(
257
+ map,
258
+ {
259
+ id,
260
+ route: route ?? null,
261
+ title,
262
+ state,
263
+ png: `peach-map-assets/${assetName(key)}`,
264
+ source,
265
+ },
266
+ from
267
+ ? {
268
+ from,
269
+ to: key,
270
+ label: flag(args, '--label') ?? null,
271
+ testID: flag(args, '--testid') ?? null,
272
+ source,
273
+ }
274
+ : null,
275
+ )
276
+ writeLocalMap(file, map)
277
+ console.log(` mapped ${key} (${title}) -> ${file}`)
278
+ return 0
279
+ }
280
+
281
+ const ROUTE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js']
282
+ const PLATFORM_SUFFIX_RE = /\.(native|ios|android|web)(?=\.[^.]+$)/
283
+ const RENDER_MODE_SUFFIX_RE = /\+(ssg|ssr|spa)$/
284
+
285
+ export type DiscoveredAppRoutes = {
286
+ routes: string[]
287
+ gaps: Array<{ screen: string; reason: string }>
288
+ scheme: string | null
289
+ routerRoot: string | null
290
+ }
291
+
292
+ function readAppJsonScheme(appRoot: string): string | null {
293
+ try {
294
+ const parsed: unknown = JSON.parse(readFileSync(join(appRoot, 'app.json'), 'utf8'))
295
+ if (!isRecord(parsed)) return null
296
+ const expo = parsed.expo
297
+ if (!isRecord(expo)) return null
298
+ const scheme = expo.scheme
299
+ if (typeof scheme === 'string' && scheme.trim()) return scheme.trim()
300
+ if (Array.isArray(scheme)) {
301
+ const first = scheme.find(
302
+ (entry): entry is string => typeof entry === 'string' && entry.trim().length > 0,
303
+ )
304
+ return first?.trim() ?? null
305
+ }
306
+ return null
307
+ } catch {
308
+ return null
309
+ }
310
+ }
311
+
312
+ function walkRouteFiles(root: string): string[] {
313
+ const found: string[] = []
314
+ const visit = (dir: string) => {
315
+ for (const entry of readdirSync(dir)) {
316
+ const full = join(dir, entry)
317
+ let stat: ReturnType<typeof statSync>
318
+ try {
319
+ stat = statSync(full)
320
+ } catch {
321
+ continue
322
+ }
323
+ if (stat.isDirectory()) visit(full)
324
+ else if (stat.isFile()) found.push(full)
325
+ }
326
+ }
327
+ visit(root)
328
+ return found.sort()
329
+ }
330
+
331
+ // file routes follow the one/expo-router conventions in
332
+ // src/bundler/routeDiscovery.ts: groups vanish from the url, index names the
333
+ // directory, layouts/api/middleware/not-found are not screens. dynamic
334
+ // segments become gaps: a deep link cannot invent their values.
335
+ export function discoverDeepLinkRoutes(appRoot: string): DiscoveredAppRoutes {
336
+ const root = resolve(appRoot)
337
+ const scheme = readAppJsonScheme(root)
338
+ const routerRoot = ['app', 'src/app']
339
+ .map((leaf) => join(root, leaf))
340
+ .find((candidate) => {
341
+ try {
342
+ return statSync(candidate).isDirectory()
343
+ } catch {
344
+ return false
345
+ }
346
+ })
347
+ if (!routerRoot) return { routes: [], gaps: [], scheme, routerRoot: null }
348
+ const byRoute = new Map<string, { priority: number; file: string }>()
349
+ const gaps: DiscoveredAppRoutes['gaps'] = []
350
+ for (const file of walkRouteFiles(routerRoot)) {
351
+ if (!ROUTE_EXTENSIONS.some((ext) => file.endsWith(ext))) continue
352
+ const relative = file.slice(routerRoot.length + 1)
353
+ const segments = relative.split('/')
354
+ const name = segments.pop() ?? ''
355
+ const platform = PLATFORM_SUFFIX_RE.exec(name)?.[1]
356
+ if (platform === 'web') continue
357
+ const base = name
358
+ .replace(PLATFORM_SUFFIX_RE, '')
359
+ .replace(/\.[^.]+$/, '')
360
+ .replace(RENDER_MODE_SUFFIX_RE, '')
361
+ if (base === '_layout' || base === '+middleware' || base === '+not-found') continue
362
+ if (base === '+html' || base === '+native-intent') continue
363
+ if (base.endsWith('+api')) continue
364
+ const publicSegments = segments.filter(
365
+ (segment) => !(segment.startsWith('(') && segment.endsWith(')')),
366
+ )
367
+ const parts = base === 'index' ? publicSegments : [...publicSegments, base]
368
+ const dynamic = parts.find((part) => part.startsWith('[') && part.endsWith(']'))
369
+ if (dynamic) {
370
+ gaps.push({
371
+ screen: `/${parts.join('/')}`,
372
+ reason: `dynamic segment ${dynamic} needs a value; pass --routes to capture one`,
373
+ })
374
+ continue
375
+ }
376
+ const routePath = `/${parts.join('/')}`.replace(/\/+$/, '') || '/'
377
+ const priority = !platform ? 0 : platform === 'native' ? 1 : 2
378
+ const existing = byRoute.get(routePath)
379
+ if (!existing || priority < existing.priority) {
380
+ byRoute.set(routePath, { priority, file })
381
+ }
382
+ }
383
+ const routes = [...byRoute.keys()].sort((a, b) => {
384
+ if (a === '/') return -1
385
+ if (b === '/') return 1
386
+ const depthA = a.split('/').length
387
+ const depthB = b.split('/').length
388
+ return depthA === depthB ? a.localeCompare(b) : depthA - depthB
389
+ })
390
+ return { routes, gaps, scheme, routerRoot }
391
+ }
392
+
393
+ const DEFAULT_CRAWL_SETTLE_MS = 15000
394
+ const SETTLE_POLL_MS = 500
395
+
396
+ function treeDigest(tree: string): string {
397
+ let hash = 5381
398
+ for (let index = 0; index < tree.length; index++) {
399
+ hash = ((hash << 5) + hash + tree.charCodeAt(index)) | 0
400
+ }
401
+ return `${tree.length}:${hash}`
402
+ }
403
+
404
+ // a deep link lands after openUrl returns: metro may still be
405
+ // transforming the route while the old screen looks settled, so the
406
+ // screenshot gate alone captures one navigation late. wait for the
407
+ // tree to actually change, bounded; identical screens never change
408
+ // it, so they fall through to the screenshot gate.
409
+ export async function settleRoute(
410
+ bridge: InspectBridge,
411
+ settleMs: number,
412
+ ): Promise<void> {
413
+ const dump = { describe: true, verbose: false, filter: '', compact: true, hideXy: true }
414
+ let before: string | null = null
415
+ try {
416
+ const snapshot = await inspectDescribe(bridge, dump)
417
+ if (!snapshot.tree) return
418
+ before = treeDigest(snapshot.tree)
419
+ } catch {
420
+ return
421
+ }
422
+ const deadline = Date.now() + Math.max(0, settleMs)
423
+ while (Date.now() < deadline) {
424
+ await sleep(SETTLE_POLL_MS)
425
+ try {
426
+ const snapshot = await inspectDescribe(bridge, dump)
427
+ if (snapshot.tree && treeDigest(snapshot.tree) !== before) return
428
+ } catch {
429
+ return
430
+ }
431
+ }
432
+ }
433
+
434
+ async function crawl(
435
+ file: string,
436
+ args: string[],
437
+ opts: { port?: number; verbose?: boolean },
438
+ ): Promise<number> {
439
+ const appRoot = flag(args, '--app')
440
+ let scheme = flag(args, '--scheme')
441
+ const routesRaw = flag(args, '--routes')
442
+ let routes: string[]
443
+ if (appRoot) {
444
+ const discovered = discoverDeepLinkRoutes(appRoot)
445
+ if (!scheme) scheme = discovered.scheme ?? undefined
446
+ if (!scheme) {
447
+ throw new Error(
448
+ `peach map crawl found no scheme in ${resolve(appRoot)}/app.json; pass --scheme myapp://`,
449
+ )
450
+ }
451
+ if (routesRaw) {
452
+ routes = routesRaw
453
+ .split(',')
454
+ .map((route) => route.trim())
455
+ .filter(Boolean)
456
+ } else {
457
+ if (!discovered.routerRoot) {
458
+ throw new Error(
459
+ `peach map crawl found no app/ router dir under ${resolve(appRoot)}; pass --routes /,...`,
460
+ )
461
+ }
462
+ if (discovered.routes.length === 0) {
463
+ throw new Error(
464
+ `peach map crawl found no static routes under ${discovered.routerRoot}; pass --routes /,...`,
465
+ )
466
+ }
467
+ routes = discovered.routes
468
+ if (discovered.gaps.length > 0) recordGaps(file, discovered.gaps)
469
+ console.log(` discovered ${routes.length} routes under ${discovered.routerRoot}`)
470
+ }
471
+ } else {
472
+ if (!scheme || !routesRaw) {
473
+ throw new Error('peach map crawl needs --scheme and --routes, or --app <dir>')
474
+ }
475
+ routes = routesRaw
476
+ .split(',')
477
+ .map((route) => route.trim())
478
+ .filter(Boolean)
479
+ }
480
+ const base = scheme.endsWith('://') ? scheme : `${scheme}://`
481
+ const bridge = createBridgeFromParsed(
482
+ parseBridgeCliArgs([], { port: opts.port, commandTimeoutMs: 30000 }),
483
+ )
484
+ const settleRaw = Number(flag(args, '--settle-ms'))
485
+ const settleMs =
486
+ Number.isFinite(settleRaw) && settleRaw >= 0 ? settleRaw : DEFAULT_CRAWL_SETTLE_MS
487
+ let captured = 0
488
+ let previous: string | null = null
489
+ try {
490
+ for (const route of routes) {
491
+ const url = `${base}${route.replace(/^\//, '')}`
492
+ try {
493
+ await bridge.send({ type: 'openUrl', url })
494
+ await settleRoute(bridge, settleMs)
495
+ const code = await addScreen(
496
+ file,
497
+ previous ? ['--from', previous] : [],
498
+ opts,
499
+ 'crawl',
500
+ { id: route, route, title: route },
501
+ )
502
+ if (code === 0) {
503
+ captured++
504
+ previous = route
505
+ }
506
+ } catch (error) {
507
+ const reason = error instanceof Error ? error.message : String(error)
508
+ recordGaps(file, [{ screen: route, reason: `crawl failed: ${reason}` }])
509
+ console.error(` gap ${route}: ${reason}`)
510
+ }
511
+ }
512
+ } finally {
513
+ bridge.close()
514
+ }
515
+ console.log(` crawled ${captured}/${routes.length} routes`)
516
+ return captured > 0 ? 0 : 1
517
+ }
518
+
519
+ function gitValue(command: string): string | undefined {
520
+ try {
521
+ return (
522
+ execSync(command, { stdio: ['ignore', 'pipe', 'ignore'] })
523
+ .toString()
524
+ .trim() || undefined
525
+ )
526
+ } catch {
527
+ return undefined
528
+ }
529
+ }
530
+
531
+ async function publish(file: string, args: string[]): Promise<number> {
532
+ const owner = flag(args, '--owner')
533
+ const repo = flag(args, '--repo')
534
+ if (!owner || !repo) throw new Error('peach map publish needs --owner and --repo')
535
+ const auth = resolveCliAuth()
536
+ if (!auth) throw new Error('peach map publish needs `peach login` or PEACH_API_KEY')
537
+ const origin = await resolveDefaultUploadOrigin(flag(args, '--origin'))
538
+ const map = readLocalMap(file)
539
+ if (map.screens.length === 0) throw new Error(`${file} has no screens to publish`)
540
+ const dir = dirname(file)
541
+ const body = {
542
+ version: 1,
543
+ owner,
544
+ repo,
545
+ branch:
546
+ flag(args, '--branch') ?? gitValue('git rev-parse --abbrev-ref HEAD') ?? 'main',
547
+ commitSha: flag(args, '--commit') ?? gitValue('git rev-parse HEAD') ?? null,
548
+ device: flag(args, '--device') ?? 'default',
549
+ appearance: flag(args, '--appearance') === 'dark' ? 'dark' : 'light',
550
+ mode: map.screens.some((screen) => screen.source === 'crawl') ? 'crawl' : 'manual',
551
+ screens: map.screens.map((screen) => ({
552
+ id: screen.id,
553
+ route: screen.route,
554
+ title: screen.title,
555
+ state: screen.state,
556
+ source: screen.source,
557
+ screenshotPngBase64: readFileSync(join(dir, screen.png)).toString('base64'),
558
+ })),
559
+ edges: map.edges,
560
+ gaps: map.gaps,
561
+ }
562
+ const res = await fetch(`${origin.replace(/\/$/, '')}/api/maps`, {
563
+ method: 'POST',
564
+ headers: {
565
+ authorization: authHeaderValue(auth),
566
+ 'content-type': 'application/json',
567
+ },
568
+ body: JSON.stringify(body),
569
+ })
570
+ if (!res.ok) {
571
+ throw new Error(`publish failed (${res.status}): ${(await res.text()).slice(0, 300)}`)
572
+ }
573
+ const url = `${origin.replace(/\/$/, '')}/org/${owner}/${repo}/map?branch=${encodeURIComponent(body.branch)}&appearance=${body.appearance}`
574
+ console.log(` published ${map.screens.length} screens: ${url}`)
575
+ return 0
576
+ }
@@ -4,7 +4,7 @@ import fs from 'fs'
4
4
  // runtimes are versioned tarballs hosted behind the configured CDN origin
5
5
  // (default https://contrast.dev/runtimes/). each install:
6
6
  // 1. fetches manifest.json to resolve a version (or confirm the literal)
7
- // 2. downloads sootsim-runtime-<version>.tar.gz to an operation temp directory
7
+ // 2. downloads sootsim-runtime-<version>.tar.zst to an operation temp directory
8
8
  // 3. verifies sha256
9
9
  // 4. extracts to ~/.peach/runtimes/<version>/
10
10
  // 5. (optionally) sets it active + tells the daemon to hot-swap
package/cli/main.ts CHANGED
@@ -512,6 +512,12 @@ try {
512
512
  await exitWithFlush(typeof code === 'number' ? code : 0)
513
513
  }
514
514
 
515
+ case 'map': {
516
+ const { runMap } = await import('./commands/map')
517
+ const code = await runMap(commandArgsWithSim, { port, verbose })
518
+ await exitWithFlush(code)
519
+ }
520
+
515
521
  case 'screenshot': {
516
522
  const { runScreenshotCommand } = await import('./commands/screenshot-command')
517
523
  const code = await runScreenshotCommand(commandArgsWithSim, { port, verbose })
@@ -421,6 +421,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
421
421
  },
422
422
  'packages/peachsim/cli/commands/preview.ts :: fetch :: `${endpoint}?id=${encodeURIComponent()}`':
423
423
  { category: 'contrast_user_action', count: 2 },
424
+ "packages/peachsim/cli/commands/map.ts :: fetch :: `${origin.replace(/\\/$/, '')}/api/maps`":
425
+ { category: 'contrast_user_action', count: 1 },
424
426
  // the live-dev-stack publisher fetches a cloudflared binary only when none
425
427
  // is installed; the token proxy below it forwards to the user's own
426
428
  // loopback dev server and sidecars, never anywhere remote.
@@ -750,6 +752,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
750
752
  category: 'guest_app_network',
751
753
  count: 1,
752
754
  },
755
+ 'packages/peach-engine/src/guest/host-fetch.ts :: fetch :: input': {
756
+ category: 'guest_app_network',
757
+ count: 1,
758
+ },
753
759
  'packages/peach-engine/src/render-worker/rive-animation-clock.ts :: fetch :: wasmUrl': {
754
760
  category: 'same_origin_runtime',
755
761
  count: 1,
@@ -1034,6 +1040,11 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
1034
1040
  category: 'kotlin_app_library_sources',
1035
1041
  count: 1,
1036
1042
  },
1043
+ // the idle warm of the android emoji face, fetched from the engine's own font directory.
1044
+ 'packages/peach-engine/src/engine/android-emoji-font.ts :: fetch-alias :: url': {
1045
+ category: 'same_origin_runtime',
1046
+ count: 1,
1047
+ },
1037
1048
  'packages/peach-engine/src/engine/app-font-loader.ts :: fetch-alias :: url': {
1038
1049
  category: 'local_development',
1039
1050
  count: 1,
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.2210 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.2213 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = '', __sootsim_import_meta_filename = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; __sootsim_import_meta_filename = __filename; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.2210 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.2213 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = '', __sootsim_import_meta_filename = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; __sootsim_import_meta_filename = __filename; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.2210 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.2213 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = '', __sootsim_import_meta_filename = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; __sootsim_import_meta_filename = __filename; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.2210 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.2213 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = '', __sootsim_import_meta_filename = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; __sootsim_import_meta_filename = __filename; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.2210 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.2213 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = '', __sootsim_import_meta_filename = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; __sootsim_import_meta_filename = __filename; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;