@goliapkg/sentori-cli 0.5.3 → 0.6.0

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 (51) hide show
  1. package/README.md +27 -32
  2. package/lib/index.d.ts +3 -0
  3. package/lib/index.d.ts.map +1 -0
  4. package/lib/index.js +795 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/issue.d.ts +28 -0
  7. package/lib/issue.d.ts.map +1 -0
  8. package/lib/issue.js +53 -0
  9. package/lib/issue.js.map +1 -0
  10. package/lib/mcp.d.ts +14 -0
  11. package/lib/mcp.d.ts.map +1 -0
  12. package/lib/mcp.js +371 -0
  13. package/lib/mcp.js.map +1 -0
  14. package/lib/native-artifacts.d.ts +43 -0
  15. package/lib/native-artifacts.d.ts.map +1 -0
  16. package/lib/native-artifacts.js +148 -0
  17. package/lib/native-artifacts.js.map +1 -0
  18. package/lib/push.d.ts +42 -0
  19. package/lib/push.d.ts.map +1 -0
  20. package/lib/push.js +92 -0
  21. package/lib/push.js.map +1 -0
  22. package/lib/react-native.d.ts +25 -0
  23. package/lib/react-native.d.ts.map +1 -0
  24. package/lib/react-native.js +74 -0
  25. package/lib/react-native.js.map +1 -0
  26. package/lib/source-bundle.d.ts +28 -0
  27. package/lib/source-bundle.d.ts.map +1 -0
  28. package/lib/source-bundle.js +193 -0
  29. package/lib/source-bundle.js.map +1 -0
  30. package/lib/upload.d.ts +24 -0
  31. package/lib/upload.d.ts.map +1 -0
  32. package/lib/upload.js +65 -0
  33. package/lib/upload.js.map +1 -0
  34. package/package.json +23 -13
  35. package/src/__tests__/issue.test.ts +95 -0
  36. package/src/__tests__/mcp.test.ts +124 -0
  37. package/src/__tests__/native-artifacts.test.ts +132 -0
  38. package/src/__tests__/react-native.test.ts +37 -0
  39. package/src/__tests__/source-bundle-from-dir.test.ts +85 -0
  40. package/src/__tests__/source-bundle.test.ts +105 -0
  41. package/src/__tests__/upload.test.ts +121 -0
  42. package/src/index.ts +799 -0
  43. package/src/issue.ts +81 -0
  44. package/src/mcp.ts +399 -0
  45. package/src/native-artifacts.ts +182 -0
  46. package/src/push.ts +174 -0
  47. package/src/react-native.ts +87 -0
  48. package/src/source-bundle.ts +234 -0
  49. package/src/upload.ts +85 -0
  50. package/bin/sentori-cli.js +0 -32
  51. package/scripts/postinstall.js +0 -90
package/src/issue.ts ADDED
@@ -0,0 +1,81 @@
1
+ // `sentori-cli issue list / resolve / silence` — CI triage helpers.
2
+
3
+ type Issue = {
4
+ errorType: string
5
+ eventCount: number
6
+ id: string
7
+ lastSeen: string
8
+ messageSample: string
9
+ status: 'active' | 'closed' | 'resolved' | 'silenced'
10
+ }
11
+
12
+ type AdminConfig = {
13
+ apiUrl: string
14
+ projectId: string
15
+ token: string
16
+ }
17
+
18
+ function url(c: AdminConfig, path: string): string {
19
+ return `${c.apiUrl.replace(/\/+$/, '')}/admin/api/projects/${c.projectId}${path}`
20
+ }
21
+
22
+ async function adminFetch<T>(c: AdminConfig, path: string, init?: RequestInit): Promise<T> {
23
+ const resp = await fetch(url(c, path), {
24
+ ...init,
25
+ headers: {
26
+ Authorization: `Bearer ${c.token}`,
27
+ 'Content-Type': 'application/json',
28
+ ...(init?.headers ?? {}),
29
+ },
30
+ })
31
+ if (!resp.ok) {
32
+ let detail = ''
33
+ try {
34
+ detail = await resp.text()
35
+ } catch {
36
+ // ignore
37
+ }
38
+ throw new Error(
39
+ `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
40
+ )
41
+ }
42
+ // PATCH /issues/<id> returns the row; some endpoints might return no
43
+ // content — handle both.
44
+ const txt = await resp.text()
45
+ return (txt ? JSON.parse(txt) : null) as T
46
+ }
47
+
48
+ export type IssueListOptions = {
49
+ config: AdminConfig
50
+ errorType?: string
51
+ limit?: number
52
+ status?: 'active' | 'closed' | 'resolved' | 'silenced'
53
+ }
54
+
55
+ export async function issueList(opts: IssueListOptions): Promise<Issue[]> {
56
+ const q = new URLSearchParams()
57
+ if (opts.status) q.set('status', opts.status)
58
+ if (opts.limit) q.set('limit', String(opts.limit))
59
+ if (opts.errorType) q.set('errorType', opts.errorType)
60
+ const qs = q.toString()
61
+ return adminFetch<Issue[]>(opts.config, `/issues${qs ? '?' + qs : ''}`)
62
+ }
63
+
64
+ export async function issuePatch(
65
+ config: AdminConfig,
66
+ issueId: string,
67
+ body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
68
+ ): Promise<Issue> {
69
+ return adminFetch<Issue>(config, `/issues/${encodeURIComponent(issueId)}`, {
70
+ body: JSON.stringify(body),
71
+ method: 'PATCH',
72
+ })
73
+ }
74
+
75
+ /** Format one issue for terminal output — short, one line, scannable. */
76
+ export function formatIssueLine(i: Issue): string {
77
+ const status = i.status.padEnd(9)
78
+ const title = `${i.errorType}${i.messageSample ? `: ${i.messageSample}` : ''}`
79
+ const events = `${i.eventCount}×`
80
+ return `${i.id} ${status} ${title.slice(0, 80).padEnd(80)} ${events}`
81
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,399 @@
1
+ // v1.2 W9 — Sentori MCP server.
2
+ //
3
+ // Stdio JSON-RPC 2.0 transport per the Model Context Protocol spec
4
+ // (https://modelcontextprotocol.io/). LLM clients (Claude Code,
5
+ // custom agents) spawn `sentori-cli mcp serve` as a subprocess and
6
+ // pipe MCP messages over stdin/stdout. Each tool call translates
7
+ // 1:1 to the existing admin API; the MCP layer is pure protocol
8
+ // glue + auth-passthrough.
9
+ //
10
+ // Why CLI-hosted instead of server-hosted MCP:
11
+ // - The Sentori server exposes admin endpoints over HTTPS already;
12
+ // spinning up a parallel MCP endpoint would duplicate auth +
13
+ // route boilerplate.
14
+ // - LLM clients expect MCP servers to be local stdio subprocesses
15
+ // (Claude Code config, gptme, etc.). The CLI is the natural
16
+ // binary to embed it in — the operator already has it installed
17
+ // and a token configured.
18
+ // - Easier to ship + version with the rest of the CLI.
19
+
20
+ import { createInterface } from 'node:readline'
21
+
22
+ import type { AdminUpload } from './native-artifacts.js'
23
+
24
+ type JsonRpcRequest = {
25
+ id?: number | string | null
26
+ jsonrpc: '2.0'
27
+ method: string
28
+ params?: Record<string, unknown>
29
+ }
30
+
31
+ type JsonRpcResponse = {
32
+ id?: number | string | null
33
+ jsonrpc: '2.0'
34
+ result?: unknown
35
+ error?: { code: number; message: string; data?: unknown }
36
+ }
37
+
38
+ type ToolHandler = (args: Record<string, unknown>, ctx: McpCtx) => Promise<unknown>
39
+
40
+ type ToolDef = {
41
+ name: string
42
+ description: string
43
+ inputSchema: Record<string, unknown>
44
+ handler: ToolHandler
45
+ }
46
+
47
+ type McpCtx = AdminUpload
48
+
49
+ /** Run the MCP server over stdio. Returns when stdin closes. */
50
+ export async function runMcpServer(ctx: McpCtx): Promise<void> {
51
+ const tools = buildTools()
52
+ const toolMap = new Map(tools.map((t) => [t.name, t]))
53
+
54
+ const rl = createInterface({ input: process.stdin })
55
+ for await (const line of rl) {
56
+ const trimmed = line.trim()
57
+ if (!trimmed) continue
58
+ let req: JsonRpcRequest
59
+ try {
60
+ req = JSON.parse(trimmed)
61
+ } catch {
62
+ // Per spec, malformed requests get a parse-error response with
63
+ // null id.
64
+ send({
65
+ error: { code: -32700, message: 'Parse error' },
66
+ id: null,
67
+ jsonrpc: '2.0',
68
+ })
69
+ continue
70
+ }
71
+ // Notifications (no `id`) get no response.
72
+ const isNotification = req.id === undefined || req.id === null
73
+ try {
74
+ const result = await dispatch(req, toolMap, ctx, tools)
75
+ if (!isNotification) {
76
+ send({ id: req.id ?? null, jsonrpc: '2.0', result })
77
+ }
78
+ } catch (e) {
79
+ if (!isNotification) {
80
+ send({
81
+ error: { code: -32603, message: (e as Error).message },
82
+ id: req.id ?? null,
83
+ jsonrpc: '2.0',
84
+ })
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ function send(resp: JsonRpcResponse): void {
91
+ process.stdout.write(JSON.stringify(resp) + '\n')
92
+ }
93
+
94
+ async function dispatch(
95
+ req: JsonRpcRequest,
96
+ toolMap: Map<string, ToolDef>,
97
+ ctx: McpCtx,
98
+ tools: ToolDef[],
99
+ ): Promise<unknown> {
100
+ switch (req.method) {
101
+ case 'initialize':
102
+ return {
103
+ capabilities: { tools: {} },
104
+ protocolVersion: '2024-11-05',
105
+ serverInfo: { name: 'sentori', version: '1.0' },
106
+ }
107
+ case 'notifications/initialized':
108
+ return {}
109
+ case 'tools/list':
110
+ return {
111
+ tools: tools.map((t) => ({
112
+ description: t.description,
113
+ inputSchema: t.inputSchema,
114
+ name: t.name,
115
+ })),
116
+ }
117
+ case 'tools/call': {
118
+ const params = (req.params ?? {}) as { arguments?: Record<string, unknown>; name?: string }
119
+ const name = params.name
120
+ if (typeof name !== 'string') throw new Error('missing tools/call.name')
121
+ const tool = toolMap.get(name)
122
+ if (!tool) throw new Error(`unknown tool: ${name}`)
123
+ const result = await tool.handler(params.arguments ?? {}, ctx)
124
+ return {
125
+ content: [
126
+ {
127
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
128
+ type: 'text',
129
+ },
130
+ ],
131
+ }
132
+ }
133
+ default:
134
+ throw new Error(`method not found: ${req.method}`)
135
+ }
136
+ }
137
+
138
+ // ── Tool implementations ─────────────────────────────────────────
139
+
140
+ async function adminGet<T>(ctx: McpCtx, path: string): Promise<T> {
141
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
142
+ const resp = await fetch(url, {
143
+ headers: { Authorization: `Bearer ${ctx.token}` },
144
+ })
145
+ if (!resp.ok) throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`)
146
+ return (await resp.json()) as T
147
+ }
148
+
149
+ async function adminPatch<T>(ctx: McpCtx, path: string, body: unknown): Promise<T> {
150
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
151
+ const resp = await fetch(url, {
152
+ body: JSON.stringify(body),
153
+ headers: {
154
+ Authorization: `Bearer ${ctx.token}`,
155
+ 'Content-Type': 'application/json',
156
+ },
157
+ method: 'PATCH',
158
+ })
159
+ if (!resp.ok) throw new Error(`PATCH ${path} → ${resp.status} ${resp.statusText}`)
160
+ return (await resp.json()) as T
161
+ }
162
+
163
+ async function adminPost<T>(ctx: McpCtx, path: string, body: unknown): Promise<T> {
164
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
165
+ const resp = await fetch(url, {
166
+ body: body !== undefined ? JSON.stringify(body) : undefined,
167
+ headers: {
168
+ Authorization: `Bearer ${ctx.token}`,
169
+ 'Content-Type': 'application/json',
170
+ },
171
+ method: 'POST',
172
+ })
173
+ if (!resp.ok) throw new Error(`POST ${path} → ${resp.status} ${resp.statusText}`)
174
+ if (resp.status === 204) return null as T
175
+ return (await resp.json()) as T
176
+ }
177
+
178
+ async function adminPut<T>(ctx: McpCtx, path: string): Promise<T> {
179
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
180
+ const resp = await fetch(url, {
181
+ headers: { Authorization: `Bearer ${ctx.token}` },
182
+ method: 'PUT',
183
+ })
184
+ if (!resp.ok) throw new Error(`PUT ${path} → ${resp.status} ${resp.statusText}`)
185
+ if (resp.status === 204) return null as T
186
+ return (await resp.json()) as T
187
+ }
188
+
189
+ async function adminDelete<T>(ctx: McpCtx, path: string): Promise<T> {
190
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
191
+ const resp = await fetch(url, {
192
+ headers: { Authorization: `Bearer ${ctx.token}` },
193
+ method: 'DELETE',
194
+ })
195
+ if (!resp.ok) throw new Error(`DELETE ${path} → ${resp.status} ${resp.statusText}`)
196
+ if (resp.status === 204) return null as T
197
+ return (await resp.json()) as T
198
+ }
199
+
200
+ function asString(v: unknown, name: string): string {
201
+ if (typeof v !== 'string' || v.length === 0) {
202
+ throw new Error(`${name} is required (string)`)
203
+ }
204
+ return v
205
+ }
206
+
207
+ function asOptionalString(v: unknown): string | undefined {
208
+ if (v === undefined || v === null) return undefined
209
+ if (typeof v !== 'string') throw new Error('expected string')
210
+ return v
211
+ }
212
+
213
+ export function buildTools(): ToolDef[] {
214
+ return [
215
+ {
216
+ description:
217
+ 'List issues for a Sentori project, with optional status / priority / label filters.',
218
+ handler: async (args, ctx) => {
219
+ const projectId = asString(args.projectId, 'projectId')
220
+ const usp = new URLSearchParams()
221
+ const status = asOptionalString(args.status) ?? 'any'
222
+ usp.set('status', status)
223
+ if (args.priority) usp.set('priority', String(args.priority))
224
+ if (args.label) usp.set('labels', String(args.label))
225
+ if (typeof args.limit === 'number') usp.set('limit', String(args.limit))
226
+ return await adminGet(ctx, `/projects/${projectId}/issues?${usp}`)
227
+ },
228
+ inputSchema: {
229
+ properties: {
230
+ label: { type: 'string' },
231
+ limit: { type: 'number' },
232
+ priority: { type: 'string' },
233
+ projectId: { type: 'string' },
234
+ status: { type: 'string' },
235
+ },
236
+ required: ['projectId'],
237
+ type: 'object',
238
+ },
239
+ name: 'sentori_issue_list',
240
+ },
241
+ {
242
+ description: 'Get full detail for one Sentori issue including its activity feed.',
243
+ handler: async (args, ctx) => {
244
+ const projectId = asString(args.projectId, 'projectId')
245
+ const issueId = asString(args.issueId, 'issueId')
246
+ const [issue, activity] = await Promise.all([
247
+ adminGet(ctx, `/projects/${projectId}/issues/${issueId}`),
248
+ adminGet(ctx, `/projects/${projectId}/issues/${issueId}/activity`),
249
+ ])
250
+ return { activity, issue }
251
+ },
252
+ inputSchema: {
253
+ properties: {
254
+ issueId: { type: 'string' },
255
+ projectId: { type: 'string' },
256
+ },
257
+ required: ['projectId', 'issueId'],
258
+ type: 'object',
259
+ },
260
+ name: 'sentori_issue_get',
261
+ },
262
+ {
263
+ description: 'Add a comment to a Sentori issue.',
264
+ handler: async (args, ctx) => {
265
+ const projectId = asString(args.projectId, 'projectId')
266
+ const issueId = asString(args.issueId, 'issueId')
267
+ const body = asString(args.body, 'body')
268
+ return await adminPost(ctx, `/projects/${projectId}/issues/${issueId}/comments`, {
269
+ body,
270
+ })
271
+ },
272
+ inputSchema: {
273
+ properties: {
274
+ body: { type: 'string' },
275
+ issueId: { type: 'string' },
276
+ projectId: { type: 'string' },
277
+ },
278
+ required: ['projectId', 'issueId', 'body'],
279
+ type: 'object',
280
+ },
281
+ name: 'sentori_issue_comment',
282
+ },
283
+ {
284
+ description:
285
+ 'Transition an issue to a new status (active|silenced|muted|resolved|closed).',
286
+ handler: async (args, ctx) => {
287
+ const projectId = asString(args.projectId, 'projectId')
288
+ const issueId = asString(args.issueId, 'issueId')
289
+ const status = asString(args.status, 'status')
290
+ return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
291
+ status,
292
+ })
293
+ },
294
+ inputSchema: {
295
+ properties: {
296
+ issueId: { type: 'string' },
297
+ projectId: { type: 'string' },
298
+ status: {
299
+ enum: ['active', 'silenced', 'muted', 'resolved', 'closed'],
300
+ type: 'string',
301
+ },
302
+ },
303
+ required: ['projectId', 'issueId', 'status'],
304
+ type: 'object',
305
+ },
306
+ name: 'sentori_issue_transition',
307
+ },
308
+ {
309
+ description: 'Assign an issue to a user, or pass userId=null to unassign.',
310
+ handler: async (args, ctx) => {
311
+ const projectId = asString(args.projectId, 'projectId')
312
+ const issueId = asString(args.issueId, 'issueId')
313
+ const userId = args.userId === null ? null : asOptionalString(args.userId)
314
+ return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
315
+ assigneeUserId: userId ?? null,
316
+ })
317
+ },
318
+ inputSchema: {
319
+ properties: {
320
+ issueId: { type: 'string' },
321
+ projectId: { type: 'string' },
322
+ userId: { type: ['string', 'null'] },
323
+ },
324
+ required: ['projectId', 'issueId', 'userId'],
325
+ type: 'object',
326
+ },
327
+ name: 'sentori_issue_assign',
328
+ },
329
+ {
330
+ description: 'Set the triage priority on an issue.',
331
+ handler: async (args, ctx) => {
332
+ const projectId = asString(args.projectId, 'projectId')
333
+ const issueId = asString(args.issueId, 'issueId')
334
+ const priority = asString(args.priority, 'priority')
335
+ return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
336
+ priority,
337
+ })
338
+ },
339
+ inputSchema: {
340
+ properties: {
341
+ issueId: { type: 'string' },
342
+ priority: { enum: ['p0', 'p1', 'p2', 'p3'], type: 'string' },
343
+ projectId: { type: 'string' },
344
+ },
345
+ required: ['projectId', 'issueId', 'priority'],
346
+ type: 'object',
347
+ },
348
+ name: 'sentori_issue_set_priority',
349
+ },
350
+ {
351
+ description: 'Replace the label set on an issue. Pass [] to clear all.',
352
+ handler: async (args, ctx) => {
353
+ const projectId = asString(args.projectId, 'projectId')
354
+ const issueId = asString(args.issueId, 'issueId')
355
+ if (!Array.isArray(args.labels)) throw new Error('labels must be string[]')
356
+ const labels = args.labels.map((l) => {
357
+ if (typeof l !== 'string') throw new Error('each label must be a string')
358
+ return l
359
+ })
360
+ return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
361
+ labels,
362
+ })
363
+ },
364
+ inputSchema: {
365
+ properties: {
366
+ issueId: { type: 'string' },
367
+ labels: { items: { type: 'string' }, type: 'array' },
368
+ projectId: { type: 'string' },
369
+ },
370
+ required: ['projectId', 'issueId', 'labels'],
371
+ type: 'object',
372
+ },
373
+ name: 'sentori_issue_set_labels',
374
+ },
375
+ {
376
+ description:
377
+ 'Subscribe (watch=true) or unsubscribe (watch=false) the configured caller to an issue.',
378
+ handler: async (args, ctx) => {
379
+ const projectId = asString(args.projectId, 'projectId')
380
+ const issueId = asString(args.issueId, 'issueId')
381
+ const watch = args.watch === true
382
+ if (watch) {
383
+ return await adminPut(ctx, `/projects/${projectId}/issues/${issueId}/watch`)
384
+ }
385
+ return await adminDelete(ctx, `/projects/${projectId}/issues/${issueId}/watch`)
386
+ },
387
+ inputSchema: {
388
+ properties: {
389
+ issueId: { type: 'string' },
390
+ projectId: { type: 'string' },
391
+ watch: { type: 'boolean' },
392
+ },
393
+ required: ['projectId', 'issueId', 'watch'],
394
+ type: 'object',
395
+ },
396
+ name: 'sentori_issue_watch',
397
+ },
398
+ ]
399
+ }
@@ -0,0 +1,182 @@
1
+ // `sentori-cli upload dsym` (iOS) + `sentori-cli upload mapping` (Android).
2
+ // Both endpoints take the raw artifact bytes (NOT multipart) with a few
3
+ // headers / query params.
4
+
5
+ import { spawnSync } from 'node:child_process'
6
+ import { readFile } from 'node:fs/promises'
7
+ import { basename, extname, join } from 'node:path'
8
+ import { statSync, readdirSync } from 'node:fs'
9
+
10
+ export type AdminUpload = {
11
+ apiUrl: string
12
+ projectId: string
13
+ release?: string
14
+ token: string
15
+ }
16
+
17
+ async function postBytes(
18
+ url: string,
19
+ body: Buffer,
20
+ token: string,
21
+ headers: Record<string, string> = {},
22
+ ): Promise<unknown> {
23
+ const resp = await fetch(url, {
24
+ body,
25
+ headers: {
26
+ Authorization: `Bearer ${token}`,
27
+ 'Content-Type': 'application/octet-stream',
28
+ ...headers,
29
+ },
30
+ method: 'POST',
31
+ })
32
+ if (!resp.ok) {
33
+ let detail = ''
34
+ try {
35
+ detail = await resp.text()
36
+ } catch {
37
+ // ignore
38
+ }
39
+ throw new Error(
40
+ `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
41
+ )
42
+ }
43
+ const txt = await resp.text()
44
+ return txt ? JSON.parse(txt) : null
45
+ }
46
+
47
+ // ── dSYM ──────────────────────────────────────────────────────────
48
+
49
+ export type DsymSlice = { arch: string; debugId: string; file: string }
50
+
51
+ const ARCHES = new Set([
52
+ 'arm64',
53
+ 'arm64_32',
54
+ 'arm64e',
55
+ 'armv7',
56
+ 'armv7k',
57
+ 'armv7s',
58
+ 'i386',
59
+ 'x86_64',
60
+ 'x86_64h',
61
+ ])
62
+
63
+ /**
64
+ * Use `dwarfdump --uuid <path>` to enumerate `(arch, debug_id, file)`
65
+ * for each Mach-O slice. Returns [] if dwarfdump isn't installed or the
66
+ * output couldn't be parsed; callers should fall back to explicit
67
+ * `--debug-id` / `--arch` flags in that case.
68
+ */
69
+ export function dsymSlicesFromDwarfdump(path: string): DsymSlice[] {
70
+ const r = spawnSync('dwarfdump', ['--uuid', path])
71
+ if (r.status !== 0 || !r.stdout) return []
72
+ const out: DsymSlice[] = []
73
+ // Output lines look like:
74
+ // UUID: 1234ABCD-... (arm64) /path/to/Foo.dSYM/Contents/Resources/DWARF/Foo
75
+ const re = /^UUID:\s+([0-9A-Fa-f-]{32,36})\s+\(([^)]+)\)\s+(.+)\s*$/m
76
+ for (const line of r.stdout.toString().split('\n')) {
77
+ const m = re.exec(line)
78
+ if (!m) continue
79
+ const [, debugId, arch, file] = m as unknown as [string, string, string, string]
80
+ if (!ARCHES.has(arch)) continue
81
+ out.push({ arch, debugId: debugId.toUpperCase(), file: file.trim() })
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** Walk a `.dSYM` bundle and return the DWARF binary files inside
87
+ * `Contents/Resources/DWARF/`. If `path` already points at a binary
88
+ * (not a bundle), returns `[path]`. */
89
+ export function dwarfBinariesIn(path: string): string[] {
90
+ let st
91
+ try {
92
+ st = statSync(path)
93
+ } catch {
94
+ return []
95
+ }
96
+ if (st.isFile()) return [path]
97
+ const dwarfDir = join(path, 'Contents/Resources/DWARF')
98
+ try {
99
+ return readdirSync(dwarfDir)
100
+ .filter((n) => !n.startsWith('.'))
101
+ .map((n) => join(dwarfDir, n))
102
+ } catch {
103
+ // not a .dSYM bundle layout — try the top-level path
104
+ return [path]
105
+ }
106
+ }
107
+
108
+ export type DsymUploadOptions = AdminUpload & {
109
+ /** Explicit overrides when dwarfdump isn't available. */
110
+ arch?: string
111
+ debugId?: string
112
+ /** A `Foo.dSYM` bundle or a raw DWARF binary. */
113
+ path: string
114
+ objectName?: string
115
+ }
116
+
117
+ export type DsymUploadResult = { slices: { arch: string; debugId: string }[] }
118
+
119
+ export async function uploadDsym(opts: DsymUploadOptions): Promise<DsymUploadResult> {
120
+ const slices: DsymSlice[] = []
121
+ if (opts.debugId && opts.arch) {
122
+ // Explicit single-slice upload — no parsing needed.
123
+ const binaries = dwarfBinariesIn(opts.path)
124
+ if (binaries.length === 0) throw new Error(`no DWARF binary at: ${opts.path}`)
125
+ slices.push({ arch: opts.arch, debugId: opts.debugId.toUpperCase(), file: binaries[0]! })
126
+ } else {
127
+ // Auto-discover via dwarfdump.
128
+ const found = dsymSlicesFromDwarfdump(opts.path)
129
+ if (found.length === 0) {
130
+ throw new Error(
131
+ 'couldn’t enumerate dSYM slices — install Xcode command-line tools ' +
132
+ '(for `dwarfdump`), or pass --debug-id and --arch explicitly',
133
+ )
134
+ }
135
+ slices.push(...found)
136
+ }
137
+
138
+ const base = opts.apiUrl.replace(/\/+$/, '')
139
+ const q = new URLSearchParams()
140
+ if (opts.release) q.set('release', opts.release)
141
+ if (opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, ''))
142
+ q.set('objectName', opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, ''))
143
+ const qs = q.toString()
144
+ const url = `${base}/admin/api/projects/${encodeURIComponent(opts.projectId)}/dsyms${qs ? '?' + qs : ''}`
145
+
146
+ const uploaded: { arch: string; debugId: string }[] = []
147
+ for (const s of slices) {
148
+ const body = await readFile(s.file)
149
+ await postBytes(url, body, opts.token, {
150
+ 'x-sentori-arch': s.arch,
151
+ 'x-sentori-debug-id': s.debugId,
152
+ })
153
+ uploaded.push({ arch: s.arch, debugId: s.debugId })
154
+ }
155
+ return { slices: uploaded }
156
+ }
157
+
158
+ // ── ProGuard / R8 mapping ─────────────────────────────────────────
159
+
160
+ export type MappingUploadOptions = AdminUpload & {
161
+ debugId?: string
162
+ path: string
163
+ }
164
+
165
+ export async function uploadMapping(opts: MappingUploadOptions): Promise<void> {
166
+ const ext = extname(opts.path).toLowerCase()
167
+ if (ext && ext !== '.txt' && ext !== '.map') {
168
+ // R8 emits `mapping.txt` by default; accept anything but warn.
169
+ console.warn(`[sentori-cli] upload mapping: unexpected extension ${ext} — uploading anyway`)
170
+ }
171
+ const body = await readFile(opts.path)
172
+ if (body.length === 0) throw new Error(`empty mapping file: ${opts.path}`)
173
+
174
+ const base = opts.apiUrl.replace(/\/+$/, '')
175
+ const q = new URLSearchParams()
176
+ if (opts.release) q.set('release', opts.release)
177
+ const qs = q.toString()
178
+ const url = `${base}/admin/api/projects/${encodeURIComponent(opts.projectId)}/mappings${qs ? '?' + qs : ''}`
179
+ const headers: Record<string, string> = {}
180
+ if (opts.debugId) headers['x-sentori-debug-id'] = opts.debugId
181
+ await postBytes(url, body, opts.token, headers)
182
+ }