peachsim 0.1.648 → 0.1.650

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 (132) hide show
  1. package/README.md +10 -9
  2. package/cli/command-registry.ts +2 -1
  3. package/cli/commands/guest-host.ts +67 -0
  4. package/cli/commands/kotlin.ts +231 -0
  5. package/cli/commands/project-report.ts +1 -1
  6. package/cli/commands/report-issue.ts +55 -32
  7. package/cli/commands/setup.ts +10 -14
  8. package/cli/commands/support.ts +399 -0
  9. package/cli/commands/swift.ts +6 -52
  10. package/cli/commands/telemetry.ts +10 -13
  11. package/cli/main.ts +22 -10
  12. package/cli/outbound-endpoints.ts +23 -2
  13. package/cli/privacy.ts +23 -25
  14. package/cli/runtime-notes.ts +3 -2
  15. package/cli/telemetry.ts +109 -14
  16. package/dist-lib/agent-daemon-client.cjs +1 -1
  17. package/dist-lib/agent-events.cjs +1 -1
  18. package/dist-lib/agent-identity.cjs +1 -1
  19. package/dist-lib/agent-sessions.cjs +1 -1
  20. package/dist-lib/attached-projects.cjs +1 -1
  21. package/dist-lib/auth/shared-session.cjs +1 -1
  22. package/dist-lib/backend-origin.cjs +1 -1
  23. package/dist-lib/beta.cjs +1 -1
  24. package/dist-lib/beta.mjs +1 -1
  25. package/dist-lib/bridge-constants.cjs +1 -1
  26. package/dist-lib/bridge-contract-input.cjs +1 -1
  27. package/dist-lib/bridge-contract-input.mjs +1 -1
  28. package/dist-lib/bridge-contract.cjs +1 -1
  29. package/dist-lib/bridge-contract.mjs +1 -1
  30. package/dist-lib/capture-contract.cjs +1 -1
  31. package/dist-lib/capture-contract.mjs +1 -1
  32. package/dist-lib/cli-constants.cjs +1 -1
  33. package/dist-lib/cloud-contract.cjs +1 -1
  34. package/dist-lib/cloud-contract.mjs +1 -1
  35. package/dist-lib/cloud-test-args.cjs +1 -1
  36. package/dist-lib/cloud-test-args.mjs +1 -1
  37. package/dist-lib/cloud.cjs +1 -1
  38. package/dist-lib/cloud.mjs +1 -1
  39. package/dist-lib/config.cjs +1 -1
  40. package/dist-lib/detox/index.cjs +1 -1
  41. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  42. package/dist-lib/home-paths.cjs +3 -3
  43. package/dist-lib/host/bridge-host.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  45. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  46. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  47. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  48. package/dist-lib/host/websocket-proxy.cjs +1 -1
  49. package/dist-lib/index.cjs +1 -1
  50. package/dist-lib/jump-to-source-babel.cjs +1 -1
  51. package/dist-lib/jump-to-source-native.cjs +1 -1
  52. package/dist-lib/kotlin-host.cjs +102 -21
  53. package/dist-lib/menu.cjs +1 -1
  54. package/dist-lib/menu.mjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  56. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  57. package/dist-lib/metro-production-bundle.cjs +1 -1
  58. package/dist-lib/metro-production-bundle.mjs +1 -1
  59. package/dist-lib/metro.cjs +1 -1
  60. package/dist-lib/native-app-identity.cjs +1 -1
  61. package/dist-lib/native-app-identity.mjs +1 -1
  62. package/dist-lib/profiles.cjs +1 -1
  63. package/dist-lib/public-brand.cjs +1 -1
  64. package/dist-lib/react-native-host-modules.cjs +1 -1
  65. package/dist-lib/react-native-host-modules.mjs +1 -1
  66. package/dist-lib/render-mode.cjs +1 -1
  67. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  68. package/dist-lib/sdk.cjs +1 -1
  69. package/dist-lib/sdk.mjs +1 -1
  70. package/dist-lib/skills.cjs +28 -39
  71. package/dist-lib/swift-compile.cjs +6 -3
  72. package/dist-lib/swift-compile.mjs +6 -3
  73. package/dist-lib/swift-package.cjs +1 -1
  74. package/dist-lib/swift-package.mjs +1 -1
  75. package/dist-lib/swift-submit.cjs +1 -1
  76. package/dist-lib/swift-submit.mjs +1 -1
  77. package/dist-lib/swift.cjs +1 -1
  78. package/dist-lib/vite-swift.cjs +6 -3
  79. package/dist-lib/vite.cjs +1 -1
  80. package/kotlin/AppSources/android/app/Activity.kt +85 -0
  81. package/kotlin/AppSources/android/app/Application.kt +7 -0
  82. package/kotlin/AppSources/android/content/Context.kt +5 -0
  83. package/kotlin/AppSources/android/content/Intent.kt +16 -0
  84. package/kotlin/AppSources/android/os/Bundle.kt +5 -0
  85. package/kotlin/AppSources/android/util/SuperNotCalledException.kt +3 -0
  86. package/kotlin/AppSources/android/view/View.kt +72 -0
  87. package/kotlin/AppSources/android/view/Window.kt +44 -0
  88. package/kotlin/AppSources/android/widget/FrameLayout.kt +6 -0
  89. package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +57 -0
  90. package/kotlin/AppSources/androidx/activity/compose/ComponentActivity.kt +29 -0
  91. package/kotlin/AppSources/androidx/compose/ui/platform/ComposeView.kt +67 -0
  92. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +136 -0
  93. package/kotlin/AppSources/dev/peach/app/PeachWindowHost.kt +13 -0
  94. package/kotlin/ComposeSources/PeachComposeHost.kt +316 -0
  95. package/kotlin/Sources/dev/onejs/one/OneNativeSourceViewDispatch.kt +13 -0
  96. package/kotlin/Sources/org/json/JSON.kt +6 -0
  97. package/kotlin/fixtures/counter/Counter.d.kt.ts +16 -0
  98. package/kotlin/fixtures/counter/Counter.kt +18 -0
  99. package/kotlin/fixtures/counter/CounterApp.native.tsx +34 -0
  100. package/kotlin/fixtures/counter/CounterApp.tsx +3 -0
  101. package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +7 -0
  102. package/kotlin/fixtures/counter/CounterScreen.kt +18 -3
  103. package/kotlin/fixtures/counter/app/_layout.tsx +4 -0
  104. package/kotlin/fixtures/counter/app/index+spa.tsx +1 -0
  105. package/kotlin/fixtures/counter/app/routes.d.ts +18 -0
  106. package/kotlin/fixtures/counter/package.json +7 -0
  107. package/kotlin/fixtures/counter/tsconfig.json +19 -0
  108. package/kotlin/fixtures/counter/vite.config.ts +10 -0
  109. package/kotlin/fixtures/headless-counter/Headless.kt +5 -0
  110. package/kotlin/fixtures/kotlin-app/app/build.gradle.kts +30 -0
  111. package/kotlin/fixtures/kotlin-app/app/src/main/AndroidManifest.xml +13 -0
  112. package/kotlin/fixtures/kotlin-app/app/src/main/java/dev/contrast/kotlinapp/MainActivity.kt +60 -0
  113. package/kotlin/fixtures/kotlin-app/app/src/main/java/dev/contrast/kotlinapp/ProbeApplication.kt +18 -0
  114. package/kotlin/fixtures/kotlin-app/build.gradle.kts +5 -0
  115. package/kotlin/fixtures/kotlin-app/gradle.properties +4 -0
  116. package/kotlin/fixtures/kotlin-app/settings.gradle.kts +4 -0
  117. package/kotlin/fork/NOTICE +7 -5
  118. package/kotlin/fork/README.md +41 -49
  119. package/kotlin/fork/build.sh +3 -2
  120. package/kotlin/fork/compose-worker-globals.patch +22 -11
  121. package/kotlin/fork/fork.json +2 -2
  122. package/kotlin/fork/publish.ts +30 -15
  123. package/kotlin/fork/skiko-worker-clock.patch +38 -0
  124. package/kotlin/toolchain.json +9 -9
  125. package/package.json +1 -1
  126. package/skills/contrast/SKILL.md +2 -1
  127. package/skills/peach-setup/SKILL.md +2 -2
  128. package/src/{swift-guest.ts → guest-bundle.ts} +22 -7
  129. package/src/home-paths.ts +5 -3
  130. package/src/kotlin-app.ts +411 -0
  131. package/src/kotlin-host.ts +148 -25
  132. package/src/swift-compile.ts +7 -2
package/README.md CHANGED
@@ -284,14 +284,15 @@ Peach sends no analytics, crash report, command event, app data, project data, o
284
284
  identity by default. An unsigned-in interactive run may request the runtime it
285
285
  is about to execute and the stable standalone CLI version document. The latter
286
286
  runs after the command in a detached process at most once every 20 hours and can
287
- be disabled with `PEACH_UPDATE_CHECK=off`. The first human run asks once about
288
- bounded diagnostics, defaults to no, and never prompts in a non-TTY shell, CI,
289
- or an agent. An automated first run is declined for that process without
290
- consuming the later human choice. Legacy browser and Electron telemetry senders
291
- are removed rather than being enabled by this consent.
292
-
293
- Opt-in diagnostics are limited to package name/version/unsupported API or error
294
- class. Peach never sends source, file paths, project names, app content, commands,
287
+ be disabled with `PEACH_UPDATE_CHECK=off`. Bounded diagnostics and automatic
288
+ crash reports are on by default; the first human run says once what they send,
289
+ and `peach config telemetry off` or `DO_NOT_TRACK=1` turns them off. Legacy
290
+ browser and Electron telemetry senders are removed rather than being enabled by
291
+ this default.
292
+
293
+ Diagnostics are limited to package name/version/unsupported API, error class
294
+ and first-line message, and the command name with its exit code; a crash report
295
+ files the same error fields in the issue queue. Peach never sends source, file paths, project names, app content, commands,
295
296
  identity, screenshots, or recordings as diagnostics. Cloud commands such as
296
297
  login, upload, billing, issue reporting, generated flows, and screenshot decks
297
298
  contact their documented service only when explicitly invoked. The complete
@@ -362,7 +363,7 @@ peach open /settings # route via React Native Linking
362
363
  peach describe # dump UI tree
363
364
  peach get errors 5 # recent runtime failures
364
365
  peach compat # scan native package compatibility
365
- peach report-issue "…" # preview an opt-in compatibility report
366
+ peach support report "…" # preview an opt-in compatibility report
366
367
  peach find --testid cta # inspect one node deeply
367
368
  peach debug enable animated,layout
368
369
  peach debug trace shell on 240
@@ -43,6 +43,7 @@ const NODE_COMMAND_NAMES = [
43
43
  'remote',
44
44
  'preview',
45
45
  'swift',
46
+ 'kotlin',
46
47
  'box',
47
48
  'list',
48
49
  'use',
@@ -50,7 +51,7 @@ const NODE_COMMAND_NAMES = [
50
51
  'close',
51
52
  'compat',
52
53
  'project-report',
53
- 'report-issue',
54
+ 'support',
54
55
  'desktop',
55
56
  'login',
56
57
  'logout',
@@ -0,0 +1,67 @@
1
+ // what `peach swift` and `peach kotlin` share: a loopback-only guest host
2
+ // and the teardown of the simulator, driver and bridge their open started.
3
+ import { isLoopbackHost } from '../../src/backend-origin'
4
+ import { createBridge } from '../ws-bridge'
5
+ import { runCloseCommand, type OpenCommandResources } from './control'
6
+ import type http from 'node:http'
7
+
8
+ export function isLoopbackGuestRequest(
9
+ req: http.IncomingMessage,
10
+ origin: string,
11
+ ): boolean {
12
+ if (req.headers.host !== new URL(origin).host) return false
13
+ if (!req.headers.origin) return true
14
+ try {
15
+ const requestOrigin = new URL(req.headers.origin)
16
+ return (
17
+ (requestOrigin.protocol === 'http:' || requestOrigin.protocol === 'https:') &&
18
+ isLoopbackHost(requestOrigin.hostname)
19
+ )
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ // answers whether every owned resource closed
26
+ export async function closeGuestResources(
27
+ resources: OpenCommandResources,
28
+ label: string,
29
+ ): Promise<boolean> {
30
+ let closed = true
31
+ if (resources.simId && resources.port) {
32
+ try {
33
+ await runCloseCommand(['--sim', resources.simId], { port: resources.port })
34
+ } catch (error) {
35
+ console.error(` ${label} cleanup failed: ${String(error)}`)
36
+ closed = false
37
+ }
38
+ }
39
+ if (
40
+ resources.driver?.driverId === 'playwright' &&
41
+ resources.driver.launched &&
42
+ resources.driver.pid
43
+ ) {
44
+ try {
45
+ process.kill(resources.driver.pid, 'SIGTERM')
46
+ } catch (error) {
47
+ if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) {
48
+ console.error(String(error))
49
+ closed = false
50
+ }
51
+ }
52
+ }
53
+ if (resources.bridge && !resources.bridgeReady) resources.bridge.kill('SIGTERM')
54
+ if (resources.bridge && resources.bridgeReady && resources.port) {
55
+ const bridge = createBridge(resources.port)
56
+ try {
57
+ if ((await bridge.listSims()).length === 0) resources.bridge.kill('SIGTERM')
58
+ else console.log(' preserving the bridge adopted by another simulator')
59
+ } catch (error) {
60
+ console.error(` cannot verify owned bridge cleanup: ${String(error)}`)
61
+ closed = false
62
+ } finally {
63
+ bridge.close()
64
+ }
65
+ }
66
+ return closed
67
+ }
@@ -0,0 +1,231 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+ import { DEFAULT_DEVICE_BY_PLATFORM, devices } from 'peach-engine/settings/devices'
5
+ import { WebSocket, WebSocketServer } from 'ws'
6
+ import { kotlinGuestBundle } from '../../src/guest-bundle'
7
+ import {
8
+ readKotlinApp,
9
+ type ComposeAndroidPins,
10
+ type KotlinAppProject,
11
+ } from '../../src/kotlin-app'
12
+ import { PeachExit } from '../run-rnx'
13
+ import { runOpenCommand, type OpenCommandResources } from './control'
14
+ import { closeGuestResources, isLoopbackGuestRequest } from './guest-host'
15
+
16
+ const GUEST_PATH = '/__peach/kotlin-guest.js'
17
+ const HMR_PATH = '/__peach/kotlin-hmr'
18
+ // peachsim's Kotlin module: two levels above this file in source (cli/commands)
19
+ // and in the built CLI (dist-cli/chunks)
20
+ const MODULE_ROOT = path.resolve(import.meta.dirname, '../../kotlin')
21
+
22
+ function composePins(): ComposeAndroidPins {
23
+ const toolchain: unknown = JSON.parse(
24
+ fs.readFileSync(path.join(MODULE_ROOT, 'toolchain.json'), 'utf8'),
25
+ )
26
+ const android =
27
+ typeof toolchain === 'object' && toolchain !== null
28
+ ? Reflect.get(Reflect.get(toolchain, 'compose') ?? {}, 'android')
29
+ : undefined
30
+ const pin = (name: string) => {
31
+ const value: unknown = android ? Reflect.get(android, name) : undefined
32
+ if (typeof value !== 'string')
33
+ throw new Error(`Kotlin toolchain has no Compose ${name} pin`)
34
+ return value
35
+ }
36
+ return {
37
+ ui: pin('ui'),
38
+ foundation: pin('foundation'),
39
+ runtime: pin('runtime'),
40
+ material3: pin('material3'),
41
+ }
42
+ }
43
+
44
+ // opens an Android app module the way the device launches it: the manifest's
45
+ // launcher activity, cold-started in Peach's Kotlin process. sources compile
46
+ // locally; an edit recompiles and starts a new process, as a rebuilt app does.
47
+ export async function runKotlin(
48
+ args: string[],
49
+ opts: { port?: number; device?: string; driver?: string; headless?: boolean } = {},
50
+ ): Promise<number> {
51
+ const input = [...args]
52
+ const openArgs: string[] = []
53
+ let hostPort = 0
54
+ let activity: string | undefined
55
+ const valueFlags = new Set(['--driver', '--profile', '--cdp-port', '--base-url'])
56
+ const booleanFlags = new Set([
57
+ '--headless',
58
+ '--headed',
59
+ '--ephemeral',
60
+ '--quiet',
61
+ '--proof',
62
+ ])
63
+ let modulePath = ''
64
+ for (let index = 0; index < input.length; index++) {
65
+ const arg = input[index]
66
+ if (
67
+ arg === '--host-port' ||
68
+ arg === '--activity' ||
69
+ arg === '--device' ||
70
+ valueFlags.has(arg)
71
+ ) {
72
+ const value = input[++index]
73
+ if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`)
74
+ if (arg === '--host-port') {
75
+ hostPort = Number(value)
76
+ if (!Number.isInteger(hostPort) || hostPort < 1 || hostPort > 65535)
77
+ throw new Error('--host-port must be a port from 1 to 65535')
78
+ } else if (arg === '--activity') activity = value
79
+ else if (arg === '--device') opts.device = value
80
+ else openArgs.push(arg, value)
81
+ } else if (booleanFlags.has(arg)) openArgs.push(arg)
82
+ else if (arg.startsWith('-')) throw new Error(`unknown peach kotlin flag: ${arg}`)
83
+ else if (modulePath)
84
+ throw new Error('peach kotlin expects one Android app module directory')
85
+ else modulePath = path.resolve(arg)
86
+ }
87
+ if (!modulePath) throw new Error('usage: peach kotlin <android-app-module>')
88
+ const device = opts.device ?? DEFAULT_DEVICE_BY_PLATFORM.android
89
+ const spec: unknown = Reflect.get(devices, device)
90
+ if (!spec || Reflect.get(spec, 'platform') !== 'android')
91
+ throw new Error(
92
+ `peach kotlin runs an Android app; ${device} is not an Android device`,
93
+ )
94
+ const pins = composePins()
95
+ let project: KotlinAppProject = readKotlinApp(modulePath, { activity, pins })
96
+ const host = await import('../../src/kotlin-host')
97
+ const controller = new AbortController()
98
+ const resources: OpenCommandResources = {}
99
+ const sockets = new WebSocketServer({ noServer: true })
100
+ let origin = ''
101
+ const routes = () => ({
102
+ root: project.projectDir,
103
+ moduleRoot: MODULE_ROOT,
104
+ origin,
105
+ hmrPath: HMR_PATH,
106
+ project,
107
+ })
108
+ const server = http.createServer((req, res) => {
109
+ if (!isLoopbackGuestRequest(req, origin)) {
110
+ res.statusCode = 403
111
+ res.end('forbidden Kotlin host origin')
112
+ return
113
+ }
114
+ res.setHeader('access-control-allow-origin', req.headers.origin ?? '*')
115
+ res.setHeader('vary', 'origin')
116
+ res.setHeader('cache-control', 'no-store')
117
+ const pathname = new URL(req.url ?? '/', origin).pathname
118
+ if (pathname === GUEST_PATH) {
119
+ res.setHeader('content-type', 'application/javascript')
120
+ res.end(kotlinGuestBundle())
121
+ return
122
+ }
123
+ void host.serveKotlinRequest(pathname, res, routes()).then((served) => {
124
+ if (served) return
125
+ res.statusCode = 404
126
+ res.end('unknown Kotlin host asset')
127
+ })
128
+ })
129
+ server.on('upgrade', (req, socket, head) => {
130
+ if (
131
+ !isLoopbackGuestRequest(req, origin) ||
132
+ new URL(req.url ?? '/', origin).pathname !== HMR_PATH
133
+ ) {
134
+ socket.destroy()
135
+ return
136
+ }
137
+ sockets.handleUpgrade(req, socket, head, (client) =>
138
+ sockets.emit('connection', client, req),
139
+ )
140
+ })
141
+ // an edit rereads the module, rebuilds it, then tells the guest to start a
142
+ // new process; a failed build leaves the running process and prints why
143
+ let building: Promise<void> | undefined
144
+ let pending = false
145
+ const rebuild = () => {
146
+ pending = true
147
+ building ??= (async () => {
148
+ while (pending && !controller.signal.aborted) {
149
+ pending = false
150
+ try {
151
+ project = readKotlinApp(modulePath, { activity, pins })
152
+ const artifact = await host.kotlinAppArtifact(project, MODULE_ROOT, origin)
153
+ console.log(` Kotlin artifact: ${artifact.hash}`)
154
+ const message = JSON.stringify({ type: 'kotlin', packageName: 'kotlin' })
155
+ for (const client of sockets.clients)
156
+ if (client.readyState === WebSocket.OPEN) client.send(message)
157
+ } catch (error) {
158
+ console.error(error instanceof Error ? error.message : String(error))
159
+ }
160
+ }
161
+ })().finally(() => {
162
+ building = undefined
163
+ })
164
+ }
165
+ const stop = () => controller.abort()
166
+ process.once('SIGINT', stop)
167
+ process.once('SIGTERM', stop)
168
+ let watcher: fs.FSWatcher | undefined
169
+ let code = 0
170
+ try {
171
+ await new Promise<void>((resolve, reject) => {
172
+ server.once('error', reject)
173
+ server.listen(hostPort, '127.0.0.1', () => {
174
+ server.removeListener('error', reject)
175
+ const address = server.address()
176
+ if (!address || typeof address === 'string') {
177
+ reject(new Error('Kotlin host has no loopback port'))
178
+ return
179
+ }
180
+ origin = `http://127.0.0.1:${address.port}`
181
+ resolve()
182
+ })
183
+ })
184
+ console.log(` Kotlin app: ${project.launcher.name} in ${project.moduleDir}`)
185
+ const artifact = await host.kotlinAppArtifact(project, MODULE_ROOT, origin)
186
+ console.log(` Kotlin artifact: ${artifact.hash}`)
187
+ watcher = fs.watch(project.moduleDir, { recursive: true }, (_event, file) => {
188
+ if (
189
+ file &&
190
+ file
191
+ .split(/[\\/]/)
192
+ .some((segment) => segment === 'build' || segment.startsWith('.'))
193
+ )
194
+ return
195
+ rebuild()
196
+ })
197
+ controller.signal.throwIfAborted()
198
+ openArgs.push('--device', device)
199
+ if (opts.driver) openArgs.push('--driver', opts.driver)
200
+ if (opts.headless) openArgs.push('--headless')
201
+ await runOpenCommand([`${origin}${GUEST_PATH}`, '--new', '--no-hmr', ...openArgs], {
202
+ port: opts.port,
203
+ resources,
204
+ signal: controller.signal,
205
+ ownerPid: process.pid,
206
+ })
207
+ await new Promise<void>((resolve) => {
208
+ if (controller.signal.aborted) resolve()
209
+ else controller.signal.addEventListener('abort', () => resolve(), { once: true })
210
+ })
211
+ } catch (error) {
212
+ if (!controller.signal.aborted) {
213
+ console.error(
214
+ ` peach kotlin failed: ${error instanceof Error ? error.message : String(error)}`,
215
+ )
216
+ code = error instanceof PeachExit ? error.code : 1
217
+ }
218
+ } finally {
219
+ controller.abort()
220
+ process.removeListener('SIGINT', stop)
221
+ process.removeListener('SIGTERM', stop)
222
+ watcher?.close()
223
+ await building
224
+ for (const client of sockets.clients) client.terminate()
225
+ sockets.close()
226
+ server.closeAllConnections()
227
+ await new Promise<void>((resolve) => server.close(() => resolve()))
228
+ if (!(await closeGuestResources(resources, 'Kotlin app'))) code = 1
229
+ }
230
+ return code
231
+ }
@@ -352,7 +352,7 @@ export function formatProjectReport(report: ProjectReport): string {
352
352
  lines.push(
353
353
  ...report.notes,
354
354
  '',
355
- 'To send this report: peach report-issue "<description>"',
355
+ 'To send this report: peach support report "<description>"',
356
356
  'Review the preview and explicitly confirm before sending.',
357
357
  '',
358
358
  )
@@ -1,4 +1,4 @@
1
- // peach report-issue — submit an explicitly approved compatibility report.
1
+ // peach support report — submit an explicitly approved compatibility report.
2
2
 
3
3
  import { existsSync } from 'node:fs'
4
4
  import { resolve } from 'node:path'
@@ -28,6 +28,7 @@ type ReportIssueOptions = {
28
28
  }
29
29
 
30
30
  export type ReportIssuePayload = {
31
+ kind: 'report'
31
32
  note: string
32
33
  timestamp: string
33
34
  sootsim_version: string | null
@@ -114,12 +115,11 @@ export function parseReportIssueOptions(
114
115
  }
115
116
  }
116
117
 
117
- export async function createReportIssuePayload(
118
- options: Pick<ReportIssueOptions, 'appDir' | 'description'> & { scopeToApp?: boolean },
119
- now = new Date(),
120
- ): Promise<ReportIssuePayload> {
121
- const manifest = readProjectManifest(options.appDir)
122
- const installed = manifest ? installedManifest(options.appDir, manifest) : null
118
+ // the context every support request carries: CLI and runtime versions, the
119
+ // platform, and the dependency compatibility scan of the app directory
120
+ export async function toolchainContext(appDir: string) {
121
+ const manifest = readProjectManifest(appDir)
122
+ const installed = manifest ? installedManifest(appDir, manifest) : null
123
123
  const safeVersions = (deps: Record<string, string> | undefined) =>
124
124
  deps &&
125
125
  Object.fromEntries(
@@ -140,37 +140,57 @@ export async function createReportIssuePayload(
140
140
  const runtimeVersion = activeRuntime
141
141
  ? sanitizeProjectReportVersion(activeRuntime)
142
142
  : null
143
+ return {
144
+ activeApp:
145
+ compatibility && compatibility.projectName !== 'unknown'
146
+ ? { appName: compatibility.projectName }
147
+ : null,
148
+ reportSource: 'cli' as const,
149
+ runtimeVersion,
150
+ cliVersion,
151
+ platform: process.platform,
152
+ arch: process.arch,
153
+ compatibility,
154
+ }
155
+ }
143
156
 
157
+ export async function createReportIssuePayload(
158
+ options: Pick<ReportIssueOptions, 'appDir' | 'description'> & { scopeToApp?: boolean },
159
+ now = new Date(),
160
+ ): Promise<ReportIssuePayload> {
161
+ const context = await toolchainContext(options.appDir)
144
162
  return {
163
+ kind: 'report',
145
164
  note: options.description,
146
165
  timestamp: now.toISOString(),
147
- sootsim_version: runtimeVersion,
148
- cli_version: cliVersion,
166
+ sootsim_version: context.runtimeVersion,
167
+ cli_version: context.cliVersion,
149
168
  bundle: {
150
- activeApp:
151
- compatibility && compatibility.projectName !== 'unknown'
152
- ? { appName: compatibility.projectName }
153
- : null,
154
- reportSource: 'cli',
155
- runtimeVersion,
156
- cliVersion,
157
- platform: process.platform,
158
- arch: process.arch,
159
- compatibility,
169
+ ...context,
160
170
  projectReport: createProjectReport(options.appDir, options.scopeToApp),
161
171
  },
162
172
  }
163
173
  }
164
174
 
165
- function reportEndpoint(): { url: string; authorization: string | null } {
175
+ // where support requests go and who they come from: the login's origin and
176
+ // bearer, and the account a `peach login --account` sign-in chose. a report
177
+ // may go anonymously; an ask or a status read needs the sign-in.
178
+ export function supportEndpoint(): {
179
+ origin: string
180
+ headers: Record<string, string>
181
+ signedIn: boolean
182
+ } {
166
183
  const auth = resolveCliAuth()
167
- const origin =
168
- auth?.kind === 'session' ? auth.origin.replace(/\/+$/, '') : DEFAULT_REPORT_ORIGIN
169
- const authorization = auth && auth.kind !== 'github' ? authHeaderValue(auth) : null
170
- return {
171
- url: `${origin}/api/sootsim/report-issue`,
172
- authorization,
184
+ if (!auth || auth.kind === 'github') {
185
+ return { origin: DEFAULT_REPORT_ORIGIN, headers: {}, signedIn: false }
173
186
  }
187
+ const headers: Record<string, string> = { authorization: authHeaderValue(auth) }
188
+ if (auth.kind === 'session' && auth.accountId) {
189
+ headers['x-rnx-account-id'] = auth.accountId
190
+ }
191
+ const origin =
192
+ auth.kind === 'session' ? auth.origin.replace(/\/+$/, '') : DEFAULT_REPORT_ORIGIN
193
+ return { origin, headers, signedIn: true }
174
194
  }
175
195
 
176
196
  function compatibilitySummary(scan: ScanResult | null): string {
@@ -197,7 +217,7 @@ export async function runReportIssue(
197
217
  } catch (error) {
198
218
  dependencies.stderr(
199
219
  ` ${error instanceof Error ? error.message : String(error)}\n` +
200
- ` usage: peach report-issue [--app <dir>] [--dry-run] [--yes] "<description>"\n`,
220
+ ` usage: peach support report [--app <dir>] [--dry-run] [--yes] "<description>"\n`,
201
221
  )
202
222
  return 1
203
223
  }
@@ -253,13 +273,12 @@ export async function runReportIssue(
253
273
  }
254
274
  }
255
275
 
256
- const endpoint = reportEndpoint()
257
- const headers: Record<string, string> = { 'content-type': 'application/json' }
258
- if (endpoint.authorization) headers.authorization = endpoint.authorization
276
+ const endpoint = supportEndpoint()
277
+ const headers = { ...endpoint.headers, 'content-type': 'application/json' }
259
278
 
260
279
  let response: Response
261
280
  try {
262
- response = await dependencies.request(endpoint.url, {
281
+ response = await dependencies.request(`${endpoint.origin}/api/sootsim/report-issue`, {
263
282
  method: 'POST',
264
283
  headers,
265
284
  body: JSON.stringify(payload),
@@ -284,6 +303,10 @@ export async function runReportIssue(
284
303
 
285
304
  const parsed: unknown = await response.json().catch(() => null)
286
305
  const id = readResponseId(parsed)
287
- dependencies.stdout(id ? `\n sent. Report id: ${id}\n` : `\n sent. Thank you.\n`)
306
+ dependencies.stdout(
307
+ id
308
+ ? `\n sent. Report id: ${id}\n our replies and the fix show in \`peach support status\`.\n`
309
+ : `\n sent. Thank you.\n`,
310
+ )
288
311
  return 0
289
312
  }
@@ -17,7 +17,7 @@ import {
17
17
  } from '../../src/home-paths'
18
18
  import { rnxRuntime } from '../../src/runtime-delivery'
19
19
  import { findDesktopCompanion } from '../desktop-companion'
20
- import { canPromptForPrivacyChoice } from '../privacy'
20
+ import { PRIVACY_NOTICE } from '../privacy'
21
21
  import { confirm, select } from '../prompt'
22
22
  import {
23
23
  applyRepositorySetup,
@@ -159,8 +159,8 @@ export async function runSetup(
159
159
  )
160
160
  }
161
161
 
162
- const productAnalytics = await decidePrivacy(options, privacy)
163
- const crashReports = false
162
+ const productAnalytics = decidePrivacy(options, privacy)
163
+ const crashReports = productAnalytics
164
164
 
165
165
  const repositoryPlan = packageRoot
166
166
  ? planRepositorySetup({ appDir: packageRoot, cliVersion })
@@ -337,20 +337,16 @@ async function decideSimulatorDriver(
337
337
  return choice === 0 ? 'playwright' : 'electron'
338
338
  }
339
339
 
340
- async function decidePrivacy(
340
+ // diagnostics and crash reports are on unless turned off; setup says so once
341
+ // and records the choice
342
+ function decidePrivacy(
341
343
  options: SetupOptions,
342
344
  current: ReturnType<typeof readPrivacyPreferences>,
343
- ): Promise<boolean> {
345
+ ): boolean {
344
346
  if (options.productAnalytics !== undefined) return options.productAnalytics
345
347
  if (current.configured) return current.productAnalytics
346
- if (options.yes || options.ci || !canPromptForPrivacyChoice()) return false
347
- printPromptBlock('Private by default', [
348
- 'Diagnostics stay off unless you opt in, including CI, pipes and agent runs. DO_NOT_TRACK overrides opt-in.',
349
- 'Reports include CLI/runtime versions, OS/arch, command name and a random install ID.',
350
- 'They include package incompatibilities, error classes and first-line error/warning messages (300 characters, paths redacted).',
351
- 'Reports exclude arguments, stacks, app source and structured app data.',
352
- ])
353
- return confirm('Share bounded diagnostics to improve peach?', false)
348
+ if (!options.json) printPromptBlock('Diagnostics', PRIVACY_NOTICE)
349
+ return true
354
350
  }
355
351
 
356
352
  async function decideRepository(
@@ -414,7 +410,7 @@ choices:
414
410
  --repo | --no-repo
415
411
 
416
412
  automation:
417
- --yes accept recommended machine defaults; diagnostics and repo changes stay off
413
+ --yes accept recommended machine defaults; repo changes stay off
418
414
  --ci never prompt or install a background service
419
415
  --json emit runtime and cache metadata as JSON
420
416
  --dry-run show choices and repository operations without changing anything