// rnx report-issue — submit an explicitly approved compatibility report. import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { getCliVersion } from '../../src/cli-version' import { readActiveRuntime } from '../../src/home-paths' import { authHeaderValue, resolveCliAuth } from '../auth' import { confirm } from '../prompt' import type { ScanResult } from '@sootsim/compat/web' const DEFAULT_REPORT_ORIGIN = 'https://contrast.dev' const MAX_DESCRIPTION_LENGTH = 8_000 type PackageManifest = { name?: string dependencies?: Record devDependencies?: Record } type ReportIssueOptions = { appDir: string description: string dryRun: boolean yes: boolean } export type ReportIssuePayload = { note: string timestamp: string sootsim_version: string | null cli_version: string bundle: { activeApp: { appName: string } | null reportSource: 'cli' runtimeVersion: string | null cliVersion: string platform: NodeJS.Platform arch: string compatibility: ScanResult | null } } export type ReportIssueDependencies = { request: (url: string, init: RequestInit) => Promise confirmSubmission: (message: string, defaultYes: boolean) => Promise isInteractive: boolean now: () => Date stdout: (message: string) => void stderr: (message: string) => void } function defaultDependencies(): ReportIssueDependencies { return { request: (url, init) => fetch(url, init), confirmSubmission: confirm, isInteractive: process.stdin.isTTY === true, now: () => new Date(), stdout: (message) => process.stdout.write(message), stderr: (message) => process.stderr.write(message), } } function getFlagValue(args: string[], flag: string): string | null { const index = args.indexOf(flag) if (index === -1) return null const value = args[index + 1] if (!value || value.startsWith('-')) { throw new Error(`${flag} requires a value`) } return value } export function parseReportIssueOptions( args: string[], cwd = process.cwd(), ): ReportIssueOptions { const app = getFlagValue(args, '--app') const consumed = new Set() const appIndex = args.indexOf('--app') if (appIndex !== -1) { consumed.add(appIndex) consumed.add(appIndex + 1) } const descriptionParts: string[] = [] for (let index = 0; index < args.length; index += 1) { if (consumed.has(index)) continue const arg = args[index] if (arg === '--yes' || arg === '-y' || arg === '--dry-run') continue if (arg.startsWith('-')) throw new Error(`unknown flag: ${arg}`) descriptionParts.push(arg) } const description = descriptionParts.join(' ').trim() if (!description) { throw new Error('describe the missing or broken compatibility behavior') } if (description.length > MAX_DESCRIPTION_LENGTH) { throw new Error( `description is too long (maximum ${MAX_DESCRIPTION_LENGTH} characters)`, ) } return { appDir: resolve(cwd, app ?? '.'), description, dryRun: args.includes('--dry-run'), yes: args.includes('--yes') || args.includes('-y'), } } function readStringRecord(value: unknown): Record | undefined { if (!value || typeof value !== 'object') return undefined const output: Record = {} for (const key of Object.keys(value)) { const next = Object.getOwnPropertyDescriptor(value, key)?.value if (typeof next === 'string') output[key] = next } return output } function readManifest(appDir: string): PackageManifest | null { const path = resolve(appDir, 'package.json') if (!existsSync(path)) return null let parsed: unknown try { parsed = JSON.parse(readFileSync(path, 'utf8')) } catch { throw new Error(`could not parse ${path}`) } if (!parsed || typeof parsed !== 'object') { throw new Error(`${path} must contain a JSON object`) } const name = Object.getOwnPropertyDescriptor(parsed, 'name')?.value const dependencies = Object.getOwnPropertyDescriptor(parsed, 'dependencies')?.value const devDependencies = Object.getOwnPropertyDescriptor( parsed, 'devDependencies', )?.value const dependencyRecord = readStringRecord(dependencies) const devDependencyRecord = readStringRecord(devDependencies) return { ...(typeof name === 'string' ? { name } : {}), ...(dependencyRecord ? { dependencies: dependencyRecord } : {}), ...(devDependencyRecord ? { devDependencies: devDependencyRecord } : {}), } } export async function createReportIssuePayload( options: Pick, now = new Date(), ): Promise { const manifest = readManifest(options.appDir) const compatibility = manifest ? (await import('@sootsim/compat/web')).scanDeps(manifest) : null const cliVersion = getCliVersion() const runtimeVersion = readActiveRuntime() return { note: options.description, timestamp: now.toISOString(), sootsim_version: runtimeVersion, cli_version: cliVersion, bundle: { activeApp: compatibility && compatibility.projectName !== 'unknown' ? { appName: compatibility.projectName } : null, reportSource: 'cli', runtimeVersion, cliVersion, platform: process.platform, arch: process.arch, compatibility, }, } } function reportEndpoint(): { url: string; authorization: string | null } { const auth = resolveCliAuth() const origin = auth?.kind === 'session' ? auth.origin.replace(/\/+$/, '') : DEFAULT_REPORT_ORIGIN const authorization = auth && auth.kind !== 'github' ? authHeaderValue(auth) : null return { url: `${origin}/api/sootsim/report-issue`, authorization, } } function compatibilitySummary(scan: ScanResult | null): string { if (!scan) return 'no package.json found; no compatibility scan attached' const attention = scan.packages.filter( (item) => item.status !== 'full' && item.status !== 'not-relevant', ).length return `${scan.packages.length} React Native ecosystem packages, ${attention} needing attention` } function readResponseId(value: unknown): string | null { if (!value || typeof value !== 'object') return null const id = Object.getOwnPropertyDescriptor(value, 'id')?.value return typeof id === 'string' && id ? id : null } export async function runReportIssue( args: string[], dependencies: ReportIssueDependencies = defaultDependencies(), ): Promise { let options: ReportIssueOptions try { options = parseReportIssueOptions(args) } catch (error) { dependencies.stderr( ` ${error instanceof Error ? error.message : String(error)}\n` + ` usage: rnx report-issue [--app ] [--dry-run] [--yes] ""\n`, ) return 1 } if (!existsSync(options.appDir)) { dependencies.stderr(` directory not found: ${options.appDir}\n`) return 1 } let payload: ReportIssuePayload try { payload = await createReportIssuePayload(options, dependencies.now()) } catch (error) { dependencies.stderr( ` could not prepare the issue report: ${ error instanceof Error ? error.message : String(error) }\n`, ) return 1 } dependencies.stdout( `\n rnx issue report preview\n\n` + ` description: ${payload.note}\n` + ` compatibility: ${compatibilitySummary(payload.bundle.compatibility)}\n` + ` environment: CLI ${payload.bundle.cliVersion}, runtime ${ payload.bundle.runtimeVersion ?? 'not installed' }, ${payload.bundle.platform}/${payload.bundle.arch}\n\n` + ` Includes only the description, compatibility scan, and the version/platform details above.\n` + ` It does not include source files, environment variables, terminal output, logs, screenshots, or app data.\n`, ) if (options.dryRun) { dependencies.stdout(`\n dry run: nothing was sent.\n`) return 0 } if (!options.yes) { if (!dependencies.isInteractive) { dependencies.stderr( `\n not sent: ask the user for explicit approval, then rerun with --yes.\n`, ) return 2 } const approved = await dependencies.confirmSubmission( 'send this compatibility report to the rnx team?', false, ) if (!approved) { dependencies.stdout(`\n not sent.\n`) return 2 } } const endpoint = reportEndpoint() const headers: Record = { 'content-type': 'application/json' } if (endpoint.authorization) headers.authorization = endpoint.authorization let response: Response try { response = await dependencies.request(endpoint.url, { method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(15_000), }) } catch (error) { dependencies.stderr( ` report failed: ${error instanceof Error ? error.message : String(error)}\n`, ) return 1 } if (!response.ok) { const detail = (await response.text().catch(() => '')).slice(0, 240) dependencies.stderr( ` report failed: ${response.status} ${response.statusText}${ detail ? ` — ${detail}` : '' }\n`, ) return 1 } const parsed: unknown = await response.json().catch(() => null) const id = readResponseId(parsed) dependencies.stdout(id ? `\n sent. Report id: ${id}\n` : `\n sent. Thank you.\n`) return 0 }