peachsim 0.1.649 → 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 (102) 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 +93 -28
  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 +15 -26
  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 +58 -37
  95. package/package.json +1 -1
  96. package/skills/contrast/SKILL.md +2 -1
  97. package/skills/peach-setup/SKILL.md +2 -2
  98. package/src/{swift-guest.ts → guest-bundle.ts} +22 -7
  99. package/src/home-paths.ts +5 -3
  100. package/src/kotlin-app.ts +411 -0
  101. package/src/kotlin-host.ts +139 -32
  102. package/src/swift-compile.ts +7 -2
@@ -0,0 +1,399 @@
1
+ // peach support — ask for help, report a problem, and read our replies
2
+ // (plans/peach-cloud/partner-support.md). every item belongs to the account the
3
+ // CLI login names, so `status` lists them without the caller keeping ids.
4
+ //
5
+ // peach support ask "<question>" [--no-wait] [--wait <minutes>] [--json]
6
+ // peach support report [--app <dir>] [--dry-run] [--yes] "<description>"
7
+ // peach support status [--new] [--json]
8
+ // peach support fund [--amount <usd>]
9
+
10
+ import { resolve } from 'node:path'
11
+ import { TOPUP_DEFAULT_USD } from 'peach-engine/billing/plans'
12
+ import {
13
+ readSharedDesktopAuthSession,
14
+ writeSharedDesktopAuthSession,
15
+ } from '../../src/auth/shared-session'
16
+ import { readSharedConfig, writeSharedConfig } from '../../src/home-paths'
17
+ import { openUrl } from '../open-url'
18
+ import { runReportIssue, supportEndpoint, toolchainContext } from './report-issue'
19
+
20
+ const POLL_MS = 10_000
21
+ const FUND_POLL_MS = 3_000
22
+ const FUND_WAIT_MS = 30 * 60_000
23
+ const DEFAULT_WAIT_MINUTES = 15
24
+ const SEEN_KEY = 'supportSeenAt'
25
+
26
+ type Reply = { body: string; createdAt: number }
27
+
28
+ type Item = {
29
+ id: string
30
+ kind: 'ask' | 'report'
31
+ note: string | null
32
+ status: string
33
+ createdAt: number
34
+ updatedAt: number
35
+ replies: Reply[]
36
+ fixCommit: string | null
37
+ release: string | null
38
+ }
39
+
40
+ const USAGE =
41
+ ' usage:\n' +
42
+ ' peach support ask "<question>" [--no-wait] [--wait <minutes>] [--json]\n' +
43
+ ' peach support report [--app <dir>] [--dry-run] [--yes] "<description>"\n' +
44
+ ' peach support status [--new] [--json]\n' +
45
+ ' peach support fund [--amount <usd>]\n'
46
+
47
+ function signInHint(): number {
48
+ process.stderr.write(' sign in first with `peach login`, or set PEACH_API_KEY.\n')
49
+ return 1
50
+ }
51
+
52
+ function field(value: unknown, key: string): unknown {
53
+ return value && typeof value === 'object' ? Reflect.get(value, key) : undefined
54
+ }
55
+
56
+ function toItem(value: unknown): Item | null {
57
+ const id = field(value, 'id')
58
+ const kind = field(value, 'kind')
59
+ const status = field(value, 'status')
60
+ const createdAt = field(value, 'createdAt')
61
+ const updatedAt = field(value, 'updatedAt')
62
+ const replies = field(value, 'replies')
63
+ if (
64
+ typeof id !== 'string' ||
65
+ (kind !== 'ask' && kind !== 'report') ||
66
+ typeof status !== 'string' ||
67
+ typeof createdAt !== 'number' ||
68
+ typeof updatedAt !== 'number' ||
69
+ !Array.isArray(replies)
70
+ ) {
71
+ return null
72
+ }
73
+ const note = field(value, 'note')
74
+ const fixCommit = field(value, 'fixCommit')
75
+ const release = field(value, 'release')
76
+ return {
77
+ id,
78
+ kind,
79
+ note: typeof note === 'string' ? note : null,
80
+ status,
81
+ createdAt,
82
+ updatedAt,
83
+ fixCommit: typeof fixCommit === 'string' ? fixCommit : null,
84
+ release: typeof release === 'string' ? release : null,
85
+ replies: replies.flatMap((reply) => {
86
+ const body = field(reply, 'body')
87
+ const at = field(reply, 'createdAt')
88
+ return typeof body === 'string' && typeof at === 'number'
89
+ ? [{ body, createdAt: at }]
90
+ : []
91
+ }),
92
+ }
93
+ }
94
+
95
+ // the server's error message, never an html error page
96
+ async function failure(response: Response): Promise<string> {
97
+ // support routes answer { error }, billing routes { message }
98
+ const body: unknown = await response.json().catch(() => null)
99
+ const message = field(body, 'error') ?? field(body, 'message')
100
+ return `${response.status} ${typeof message === 'string' ? message : response.statusText}`
101
+ }
102
+
103
+ async function fetchItems(id?: string): Promise<Item[]> {
104
+ const target = supportEndpoint()
105
+ const url = new URL('/api/sootsim/support', target.origin)
106
+ if (id) url.searchParams.set('id', id)
107
+ const response = await fetch(url, {
108
+ headers: target.headers,
109
+ signal: AbortSignal.timeout(15_000),
110
+ })
111
+ if (!response.ok) throw new Error(await failure(response))
112
+ const items = field(await response.json(), 'items')
113
+ return Array.isArray(items) ? items.flatMap((item) => toItem(item) ?? []) : []
114
+ }
115
+
116
+ // an ask is done once it is answered or closed without one
117
+ function settled(item: Item): boolean {
118
+ return !['received', 'answering', 'waitlisted'].includes(item.status)
119
+ }
120
+
121
+ function indent(text: string, prefix = ' '): string {
122
+ return text
123
+ .trim()
124
+ .split('\n')
125
+ .map((line) => `${prefix}${line}`)
126
+ .join('\n')
127
+ }
128
+
129
+ function summary(note: string | null): string {
130
+ const line = (note ?? '').trim().split('\n')[0] ?? ''
131
+ return line.length > 90 ? `${line.slice(0, 89)}…` : line
132
+ }
133
+
134
+ function formatItem(item: Item, seenAt: number): string {
135
+ const fresh = item.updatedAt > seenAt || item.replies.some((r) => r.createdAt > seenAt)
136
+ const lines = [
137
+ ` ${fresh ? '* ' : ''}${item.kind} ${item.id} ${item.status} ${new Date(item.createdAt).toISOString().slice(0, 16).replace('T', ' ')}`,
138
+ ` ${summary(item.note)}`,
139
+ ]
140
+ if (item.fixCommit) {
141
+ lines.push(
142
+ item.release
143
+ ? ` fixed in ${item.fixCommit.slice(0, 10)}, shipped in runtime v${item.release} (\`peach upgrade\` picks it up)`
144
+ : ` fixed in ${item.fixCommit.slice(0, 10)}, not in a runtime release yet`,
145
+ )
146
+ }
147
+ for (const reply of item.replies) {
148
+ lines.push(
149
+ ` reply ${new Date(reply.createdAt).toISOString().slice(0, 16).replace('T', ' ')}${reply.createdAt > seenAt ? ' (new)' : ''}:`,
150
+ indent(reply.body, ' '),
151
+ )
152
+ }
153
+ return lines.join('\n')
154
+ }
155
+
156
+ function flagValue(args: string[], flag: string): string | null {
157
+ const index = args.indexOf(flag)
158
+ if (index === -1) return null
159
+ const value = args[index + 1]
160
+ if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value`)
161
+ return value
162
+ }
163
+
164
+ // an ask sends the question and the toolchain context: CLI and runtime
165
+ // versions and the dependency compatibility scan of the app directory. never
166
+ // source, env, logs, or screenshots.
167
+ async function askPayload(question: string, appDir: string) {
168
+ const context = await toolchainContext(appDir)
169
+ return {
170
+ kind: 'ask',
171
+ note: question,
172
+ timestamp: new Date().toISOString(),
173
+ sootsim_version: context.runtimeVersion,
174
+ cli_version: context.cliVersion,
175
+ bundle: context,
176
+ }
177
+ }
178
+
179
+ async function runAsk(args: string[]): Promise<number> {
180
+ const json = args.includes('--json')
181
+ const noWait = args.includes('--no-wait')
182
+ let waitMinutes = DEFAULT_WAIT_MINUTES
183
+ let app: string | null
184
+ try {
185
+ const wait = flagValue(args, '--wait')
186
+ if (wait) waitMinutes = Number(wait)
187
+ app = flagValue(args, '--app')
188
+ } catch (error) {
189
+ process.stderr.write(` ${error instanceof Error ? error.message : error}\n${USAGE}`)
190
+ return 1
191
+ }
192
+ if (!Number.isFinite(waitMinutes) || waitMinutes < 0) {
193
+ process.stderr.write(` --wait takes minutes\n${USAGE}`)
194
+ return 1
195
+ }
196
+ const valued = new Set(['--wait', '--app'])
197
+ const words = args.filter(
198
+ (arg, i) => !arg.startsWith('-') && !valued.has(args[i - 1] ?? ''),
199
+ )
200
+ const question = words.join(' ').trim()
201
+ if (!question) {
202
+ process.stderr.write(` ask a question\n${USAGE}`)
203
+ return 1
204
+ }
205
+ const target = supportEndpoint()
206
+ if (!target.signedIn) return signInHint()
207
+
208
+ const response = await fetch(new URL('/api/sootsim/report-issue', target.origin), {
209
+ method: 'POST',
210
+ headers: { ...target.headers, 'content-type': 'application/json' },
211
+ body: JSON.stringify(await askPayload(question, resolve(app ?? '.'))),
212
+ signal: AbortSignal.timeout(15_000),
213
+ }).catch((error: unknown) => error)
214
+ if (!(response instanceof Response)) {
215
+ process.stderr.write(` ask failed: ${String(response)}\n`)
216
+ return 1
217
+ }
218
+ if (!response.ok) {
219
+ process.stderr.write(` ask failed: ${await failure(response)}\n`)
220
+ return 1
221
+ }
222
+ const body: unknown = await response.json().catch(() => null)
223
+ const id = field(body, 'id')
224
+ if (typeof id !== 'string') {
225
+ process.stderr.write(' ask failed: the server returned no id\n')
226
+ return 1
227
+ }
228
+ // an ask past the account's unapproved allowance is kept, queued until approval
229
+ if (field(body, 'status') === 'waitlisted') {
230
+ const message = String(field(body, 'message') ?? '')
231
+ if (json)
232
+ process.stdout.write(`${JSON.stringify({ id, status: 'waitlisted', message })}\n`)
233
+ else process.stdout.write(`\n asked (${id}), queued: ${message}.\n\n`)
234
+ return 0
235
+ }
236
+ if (!json) {
237
+ process.stdout.write(
238
+ `\n asked (${id}). Sent the question, CLI and runtime versions, and the\n` +
239
+ ` dependency compatibility scan; no source, env, logs or screenshots.\n`,
240
+ )
241
+ }
242
+ if (noWait || waitMinutes === 0) {
243
+ if (json) process.stdout.write(`${JSON.stringify({ id, status: 'received' })}\n`)
244
+ else process.stdout.write(` the answer shows in \`peach support status\`.\n\n`)
245
+ return 0
246
+ }
247
+
248
+ if (!json)
249
+ process.stdout.write(` waiting up to ${waitMinutes} minutes for the answer…\n`)
250
+ const deadline = Date.now() + waitMinutes * 60_000
251
+ let last: Item | null = null
252
+ while (Date.now() < deadline) {
253
+ await new Promise((done) => setTimeout(done, POLL_MS))
254
+ last = (await fetchItems(id).catch(() => []))[0] ?? last
255
+ if (last && settled(last)) break
256
+ }
257
+ if (json) {
258
+ process.stdout.write(`${JSON.stringify(last ?? { id, status: 'received' })}\n`)
259
+ return 0
260
+ }
261
+ if (!last || !settled(last)) {
262
+ process.stdout.write(
263
+ ` still working on it. \`peach support status\` shows the answer when it lands.\n\n`,
264
+ )
265
+ return 0
266
+ }
267
+ const answer = last.replies.at(-1)?.body
268
+ process.stdout.write(answer ? `\n${indent(answer, ' ')}\n\n` : ` ${last.status}\n\n`)
269
+ return 0
270
+ }
271
+
272
+ // opens a prepaid top-up checkout in the default browser, waits for it to be
273
+ // paid, and binds the funded account to this login
274
+ async function runFund(args: string[]): Promise<number> {
275
+ const at = args.indexOf('--amount')
276
+ const amountUsd = at >= 0 ? Number(args[at + 1]) : TOPUP_DEFAULT_USD
277
+ if (!Number.isFinite(amountUsd) || amountUsd <= 0) {
278
+ process.stderr.write(` --amount takes dollars\n${USAGE}`)
279
+ return 1
280
+ }
281
+ const target = supportEndpoint()
282
+ if (!target.signedIn) return signInHint()
283
+ const created = await fetch(new URL('/api/sootsim/billing/checkout', target.origin), {
284
+ method: 'POST',
285
+ headers: { ...target.headers, 'content-type': 'application/json' },
286
+ body: JSON.stringify({ amountUsd }),
287
+ signal: AbortSignal.timeout(15_000),
288
+ }).catch((error: unknown) => error)
289
+ if (!(created instanceof Response)) {
290
+ process.stderr.write(` fund failed: ${String(created)}\n`)
291
+ return 1
292
+ }
293
+ if (!created.ok) {
294
+ process.stderr.write(` fund failed: ${await failure(created)}\n`)
295
+ return 1
296
+ }
297
+ const body: unknown = await created.json().catch(() => null)
298
+ const url = field(body, 'url')
299
+ const id = field(body, 'id')
300
+ if (typeof url !== 'string' || typeof id !== 'string') {
301
+ process.stderr.write(' fund failed: the server returned no checkout\n')
302
+ return 1
303
+ }
304
+ process.stdout.write(`\n opening the payment page in your browser:\n ${url}\n`)
305
+ await openUrl(url, { background: false }).catch(() => {
306
+ process.stdout.write(' could not open a browser; open the link above.\n')
307
+ })
308
+ process.stdout.write(' waiting for the payment to finish…\n')
309
+
310
+ const statusUrl = new URL('/api/sootsim/billing/checkout-status', target.origin)
311
+ statusUrl.searchParams.set('id', id)
312
+ const deadline = Date.now() + FUND_WAIT_MS
313
+ while (Date.now() < deadline) {
314
+ await new Promise((done) => setTimeout(done, FUND_POLL_MS))
315
+ const response = await fetch(statusUrl, {
316
+ headers: target.headers,
317
+ signal: AbortSignal.timeout(15_000),
318
+ }).catch(() => null)
319
+ if (!response?.ok) continue
320
+ const state: unknown = await response.json().catch(() => null)
321
+ if (field(state, 'status') === 'expired') {
322
+ process.stdout.write(' the payment page expired without a payment.\n\n')
323
+ return 1
324
+ }
325
+ if (field(state, 'paid') !== true) continue
326
+ const accountId = field(state, 'accountId')
327
+ const session = readSharedDesktopAuthSession()
328
+ if (typeof accountId === 'string' && session && session.accountId !== accountId) {
329
+ writeSharedDesktopAuthSession({ ...session, accountId })
330
+ }
331
+ process.stdout.write(' funded. this login now uses the funded account.\n\n')
332
+ return 0
333
+ }
334
+ process.stdout.write(
335
+ ' no payment yet. run `peach support fund` again once it is done.\n\n',
336
+ )
337
+ return 1
338
+ }
339
+
340
+ async function runStatus(args: string[]): Promise<number> {
341
+ if (!supportEndpoint().signedIn) return signInHint()
342
+ let items: Item[]
343
+ try {
344
+ items = await fetchItems()
345
+ } catch (error) {
346
+ process.stderr.write(
347
+ ` status failed: ${error instanceof Error ? error.message : error}\n`,
348
+ )
349
+ return 1
350
+ }
351
+ const seen = readSharedConfig()[SEEN_KEY]
352
+ const seenAt = typeof seen === 'number' ? seen : 0
353
+ const isNew = (item: Item) =>
354
+ item.updatedAt > seenAt || item.replies.some((reply) => reply.createdAt > seenAt)
355
+ const shown = args.includes('--new') ? items.filter(isNew) : items
356
+ writeSharedConfig({ [SEEN_KEY]: Date.now() })
357
+ if (args.includes('--json')) {
358
+ process.stdout.write(
359
+ `${JSON.stringify({ items: shown.map((item) => ({ ...item, new: isNew(item) })) }, null, 2)}\n`,
360
+ )
361
+ return 0
362
+ }
363
+ if (shown.length === 0) {
364
+ process.stdout.write(
365
+ args.includes('--new')
366
+ ? '\n nothing new since the last check.\n\n'
367
+ : '\n no asks or reports yet. `peach support ask "<question>"` asks for help.\n\n',
368
+ )
369
+ return 0
370
+ }
371
+ process.stdout.write(
372
+ `\n${shown.map((item) => formatItem(item, seenAt)).join('\n\n')}\n\n` +
373
+ ` * changed since the last check\n\n`,
374
+ )
375
+ return 0
376
+ }
377
+
378
+ export async function runSupport(args: string[]): Promise<number> {
379
+ const [sub, ...rest] = args
380
+ switch (sub) {
381
+ case 'ask':
382
+ return runAsk(rest)
383
+ case 'report':
384
+ return runReportIssue(rest)
385
+ case 'status':
386
+ return runStatus(rest)
387
+ case 'fund':
388
+ return runFund(rest)
389
+ case undefined:
390
+ case 'help':
391
+ case '--help':
392
+ case '-h':
393
+ process.stdout.write(USAGE)
394
+ return 0
395
+ default:
396
+ process.stderr.write(` unknown support command: ${sub}\n${USAGE}`)
397
+ return 1
398
+ }
399
+ }
@@ -2,13 +2,13 @@ import fs from 'node:fs'
2
2
  import http from 'node:http'
3
3
  import path from 'node:path'
4
4
  import { WebSocket, WebSocketServer } from 'ws'
5
- import { isLoopbackHost, resolveContrastBackendOrigin } from '../../src/backend-origin'
6
- import { swiftGuestBundle } from '../../src/swift-guest'
5
+ import { resolveContrastBackendOrigin } from '../../src/backend-origin'
6
+ import { swiftGuestBundle } from '../../src/guest-bundle'
7
7
  import { SWIFT_HMR_PATH, SWIFT_PACKAGE_PATH } from '../../src/swift-submit'
8
8
  import { authHeaderValue, resolveCliAuth } from '../auth'
9
9
  import { PeachExit } from '../run-rnx'
10
- import { createBridge } from '../ws-bridge'
11
- import { runCloseCommand, runOpenCommand, type OpenCommandResources } from './control'
10
+ import { runOpenCommand, type OpenCommandResources } from './control'
11
+ import { closeGuestResources, isLoopbackGuestRequest } from './guest-host'
12
12
  import type { SwiftEntryModuleArgs } from '../../../sootsim-swift/src/entryModule'
13
13
 
14
14
  export async function runSwift(
@@ -134,19 +134,7 @@ export async function runSwift(
134
134
  })
135
135
  return building
136
136
  }
137
- const validRequest = (req: http.IncomingMessage): boolean => {
138
- if (req.headers.host !== new URL(origin).host) return false
139
- if (!req.headers.origin) return true
140
- try {
141
- const requestOrigin = new URL(req.headers.origin)
142
- return (
143
- (requestOrigin.protocol === 'http:' || requestOrigin.protocol === 'https:') &&
144
- isLoopbackHost(requestOrigin.hostname)
145
- )
146
- } catch {
147
- return false
148
- }
149
- }
137
+ const validRequest = (req: http.IncomingMessage) => isLoopbackGuestRequest(req, origin)
150
138
  const server = http.createServer((req, res) => {
151
139
  if (!validRequest(req)) {
152
140
  res.statusCode = 403
@@ -285,41 +273,7 @@ export async function runSwift(
285
273
  sockets.close()
286
274
  server.closeAllConnections()
287
275
  await new Promise<void>((resolve) => server.close(() => resolve()))
288
- if (resources.simId && resources.port) {
289
- try {
290
- await runCloseCommand(['--sim', resources.simId], { port: resources.port })
291
- } catch (error) {
292
- console.error(` Swift preview cleanup failed: ${String(error)}`)
293
- code = 1
294
- }
295
- }
296
- if (
297
- resources.driver?.driverId === 'playwright' &&
298
- resources.driver.launched &&
299
- resources.driver.pid
300
- ) {
301
- try {
302
- process.kill(resources.driver.pid, 'SIGTERM')
303
- } catch (error) {
304
- if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) {
305
- console.error(String(error))
306
- code = 1
307
- }
308
- }
309
- }
310
- if (resources.bridge && !resources.bridgeReady) resources.bridge.kill('SIGTERM')
311
- if (resources.bridge && resources.bridgeReady && resources.port) {
312
- const bridge = createBridge(resources.port)
313
- try {
314
- if ((await bridge.listSims()).length === 0) resources.bridge.kill('SIGTERM')
315
- else console.log(' preserving the bridge adopted by another simulator')
316
- } catch (error) {
317
- console.error(` cannot verify owned bridge cleanup: ${String(error)}`)
318
- code = 1
319
- } finally {
320
- bridge.close()
321
- }
322
- }
276
+ if (!(await closeGuestResources(resources, 'Swift preview'))) code = 1
323
277
  host.disposeSwiftProject(packagePath)
324
278
  }
325
279
  return code
@@ -1,27 +1,24 @@
1
- // `peach config telemetry on|off` — retained as the command spelling for the
2
- // product analytics preference shown by guided setup and Settings.
1
+ // `peach config telemetry on|off` — the bounded diagnostics and automatic
2
+ // crash report preference shown by guided setup and Settings. both are on until
3
+ // turned off here.
3
4
  //
4
5
  // this is the process-wide preference; `DO_NOT_TRACK=1` still forces opt-out
5
- // for a single run regardless of stored consent, including CI and agents.
6
+ // for a single run regardless of the stored choice, including CI and agents.
6
7
 
7
8
  import {
8
9
  configFilePath,
9
10
  readPrivacyPreferences,
10
11
  writePrivacyPreferences,
11
12
  } from '../../src/home-paths'
13
+ import { PRIVACY_NOTICE } from '../privacy'
12
14
  import { rnxExit } from '../run-rnx'
13
15
 
14
16
  function printStatus(): void {
15
17
  const preferences = readPrivacyPreferences()
16
- const enabled = preferences.configured && preferences.productAnalytics
17
- console.log(` bounded diagnostics are ${enabled ? 'ON' : 'OFF'}`)
18
- console.log(' fields: CLI/runtime versions, OS/arch, command name, random install ID')
19
18
  console.log(
20
- ' diagnostics: package name/version/unsupported API; error class and first-line error/warning messages (300 characters, paths redacted)',
21
- )
22
- console.log(
23
- ' explicit opt-in also applies in CI, pipes and agent runs; DO_NOT_TRACK wins',
19
+ ` bounded diagnostics are ${preferences.productAnalytics ? 'ON' : 'OFF'}, automatic crash reports are ${preferences.crashReports ? 'ON' : 'OFF'}`,
24
20
  )
21
+ for (const line of PRIVACY_NOTICE) console.log(` ${line}`)
25
22
  console.log(` config: ${configFilePath()}`)
26
23
  const doNotTrack = process.env.DO_NOT_TRACK
27
24
  if (doNotTrack === '1' || doNotTrack === 'true') {
@@ -41,14 +38,14 @@ export async function runTelemetry(args: string[]): Promise<void> {
41
38
  return
42
39
 
43
40
  case 'on':
44
- writePrivacyPreferences({ productAnalytics: true, crashReports: false })
45
- console.log(' bounded diagnostics ON')
41
+ writePrivacyPreferences({ productAnalytics: true, crashReports: true })
42
+ console.log(' diagnostics and crash reports ON')
46
43
  console.log(` config: ${configFilePath()}`)
47
44
  return
48
45
 
49
46
  case 'off':
50
47
  writePrivacyPreferences({ productAnalytics: false, crashReports: false })
51
- console.log(' bounded diagnostics OFF')
48
+ console.log(' diagnostics and crash reports OFF')
52
49
  console.log(` config: ${configFilePath()}`)
53
50
  return
54
51
 
package/cli/main.ts CHANGED
@@ -23,9 +23,9 @@ import { IS_STANDALONE } from './standalone'
23
23
  // rejections that don't match this shape still get the verbose stack so we
24
24
  // don't paper over real bugs.
25
25
  async function handleTopLevelError(err: unknown): Promise<never> {
26
- const { trackCliError, flushCliTelemetry } = await import('./telemetry')
26
+ const { trackCliError, flushCliTelemetry, reportCliCrash } = await import('./telemetry')
27
27
  trackCliError(err)
28
- await flushCliTelemetry()
28
+ await flushCliTelemetry(1)
29
29
  const msg = err instanceof Error ? err.message : String(err)
30
30
  // peach's known top-level errors are all single-line, narrow strings.
31
31
  // everything else gets the full stack so unexpected crashes still surface.
@@ -48,6 +48,8 @@ async function handleTopLevelError(err: unknown): Promise<never> {
48
48
  process.stderr.write(` ${msg}\n`)
49
49
  process.exit(1)
50
50
  }
51
+ // an unexpected crash is ours to fix: it goes to the issue queue on its own
52
+ await reportCliCrash(err)
51
53
  // bun can render an async-thrown error's stack with a bare `Error` header, so
52
54
  // the message always goes first.
53
55
  process.stderr.write(` ${msg}\n`)
@@ -114,8 +116,8 @@ if (
114
116
  parsed.command !== 'setup' &&
115
117
  parsed.command !== 'telemetry'
116
118
  ) {
117
- const { ensurePrivacyChoice } = await import('./privacy')
118
- await ensurePrivacyChoice({ prompt: true })
119
+ const { noticePrivacyDefaults } = await import('./privacy')
120
+ noticePrivacyDefaults()
119
121
  }
120
122
 
121
123
  // one-shot "engine upgraded in the background" banner. the daemon's hourly
@@ -229,7 +231,7 @@ async function scheduleAutomaticCliUpdate(exitCode: number): Promise<void> {
229
231
 
230
232
  async function exitWithFlush(code: number): Promise<never> {
231
233
  const { flushCliTelemetry } = await import('./telemetry')
232
- await flushCliTelemetry()
234
+ await flushCliTelemetry(code)
233
235
  await scheduleAutomaticCliUpdate(code)
234
236
  await scheduleAutomaticCleanup()
235
237
  process.exit(code)
@@ -562,6 +564,17 @@ try {
562
564
  )
563
565
  }
564
566
 
567
+ case 'kotlin': {
568
+ if (globalSimTarget) {
569
+ console.error(' peach kotlin owns a new preview and cannot target --sim')
570
+ await exitWithFlush(1)
571
+ }
572
+ const { runKotlin } = await import('./commands/kotlin')
573
+ await exitWithFlush(
574
+ await runKotlin(parsed.commandArgs, { port, device, driver, headless }),
575
+ )
576
+ }
577
+
565
578
  case 'preview': {
566
579
  const { runPreview } = await import('./commands/preview')
567
580
  await exitWithFlush(await runPreview(parsed.commandArgs))
@@ -614,10 +627,9 @@ try {
614
627
  await exitWithFlush(runProjectReport(parsed.commandArgs))
615
628
  }
616
629
 
617
- case 'report-issue': {
618
- const { runReportIssue } = await import('./commands/report-issue')
619
- const code = await runReportIssue(parsed.commandArgs)
620
- await exitWithFlush(code)
630
+ case 'support': {
631
+ const { runSupport } = await import('./commands/support')
632
+ await exitWithFlush(await runSupport(parsed.commandArgs))
621
633
  }
622
634
 
623
635
  case 'desktop': {
@@ -734,6 +746,6 @@ try {
734
746
  }
735
747
 
736
748
  const { flushCliTelemetry } = await import('./telemetry')
737
- await flushCliTelemetry()
749
+ await flushCliTelemetry(0)
738
750
  await scheduleAutomaticCliUpdate(0)
739
751
  await scheduleAutomaticCleanup()
@@ -44,9 +44,16 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
44
44
  bounded_diagnostics: {
45
45
  category: 'bounded-diagnostics',
46
46
  destination: 'configured PostHog ingestion host',
47
- preConsent: false,
47
+ preConsent: true,
48
48
  payload:
49
- 'anonymous install id, CLI/runtime versions, OS/arch, command name, package compatibility fields, error class and bounded path-redacted first-line error/warning messages',
49
+ 'anonymous install id, CLI/runtime versions, OS/arch, command name, exit code and duration bucket, package compatibility fields, error class and bounded path-redacted first-line error/warning messages; on until `peach config telemetry off` or DO_NOT_TRACK',
50
+ },
51
+ crash_report: {
52
+ category: 'bounded-diagnostics',
53
+ destination: 'configured Contrast origin, /api/sootsim/report-issue',
54
+ preConsent: true,
55
+ payload:
56
+ 'an unexpected CLI crash: error class, bounded path-redacted first-line message, command name, CLI/runtime versions, OS/arch and a dedupe signature, at most once a day per signature; on until `peach config telemetry off` or DO_NOT_TRACK',
50
57
  },
51
58
  contrast_user_action: {
52
59
  category: 'explicit-user-action',
@@ -544,6 +551,18 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
544
551
  category: 'contrast_user_action',
545
552
  count: 1,
546
553
  },
554
+ "packages/peachsim/cli/commands/support.ts :: fetch :: new URL('/api/sootsim/billing/checkout', target.origin)":
555
+ { category: 'contrast_user_action', count: 1 },
556
+ "packages/peachsim/cli/commands/support.ts :: fetch :: new URL('/api/sootsim/report-issue', target.origin)":
557
+ { category: 'contrast_user_action', count: 1 },
558
+ 'packages/peachsim/cli/commands/support.ts :: fetch :: statusUrl': {
559
+ category: 'contrast_user_action',
560
+ count: 1,
561
+ },
562
+ 'packages/peachsim/cli/commands/support.ts :: fetch :: url': {
563
+ category: 'contrast_user_action',
564
+ count: 1,
565
+ },
547
566
  'packages/peachsim/cli/commands/runtime.ts :: websocket :: `ws://127.0.0.1:${port}`': {
548
567
  category: 'local_development',
549
568
  count: 1,
@@ -578,6 +597,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
578
597
  category: 'release_notes',
579
598
  count: 1,
580
599
  },
600
+ 'packages/peachsim/cli/telemetry.ts :: fetch :: `${endpoint.origin}/api/sootsim/report-issue`':
601
+ { category: 'crash_report', count: 1 },
581
602
  'packages/peachsim/cli/telemetry.ts :: fetch :: `${config.host}/batch/`': {
582
603
  category: 'bounded_diagnostics',
583
604
  count: 1,