@goliapkg/sentori-cli 0.6.0 → 1.1.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 (47) hide show
  1. package/lib/index.js +269 -340
  2. package/lib/index.js.map +1 -1
  3. package/lib/issue.d.ts +16 -18
  4. package/lib/issue.d.ts.map +1 -1
  5. package/lib/issue.js +29 -32
  6. package/lib/issue.js.map +1 -1
  7. package/lib/lenient.d.ts +13 -0
  8. package/lib/lenient.d.ts.map +1 -0
  9. package/lib/lenient.js +25 -0
  10. package/lib/lenient.js.map +1 -0
  11. package/lib/mcp.d.ts +4 -2
  12. package/lib/mcp.d.ts.map +1 -1
  13. package/lib/mcp.js +43 -220
  14. package/lib/mcp.js.map +1 -1
  15. package/lib/native-artifacts.d.ts +0 -1
  16. package/lib/native-artifacts.d.ts.map +1 -1
  17. package/lib/native-artifacts.js +34 -41
  18. package/lib/native-artifacts.js.map +1 -1
  19. package/lib/probes.d.ts +10 -0
  20. package/lib/probes.d.ts.map +1 -0
  21. package/lib/probes.js +75 -0
  22. package/lib/probes.js.map +1 -0
  23. package/lib/react-native.d.ts.map +1 -1
  24. package/lib/react-native.js +17 -9
  25. package/lib/react-native.js.map +1 -1
  26. package/lib/upload.d.ts +21 -20
  27. package/lib/upload.d.ts.map +1 -1
  28. package/lib/upload.js +65 -57
  29. package/lib/upload.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/__tests__/lenient.test.ts +32 -0
  32. package/src/__tests__/mcp.test.ts +20 -112
  33. package/src/__tests__/native-artifacts.test.ts +43 -12
  34. package/src/__tests__/probes.test.ts +41 -0
  35. package/src/index.ts +291 -362
  36. package/src/issue.ts +47 -46
  37. package/src/lenient.ts +39 -0
  38. package/src/mcp.ts +46 -215
  39. package/src/native-artifacts.ts +42 -44
  40. package/src/probes.ts +75 -0
  41. package/src/react-native.ts +16 -9
  42. package/src/upload.ts +78 -68
  43. package/src/__tests__/issue.test.ts +0 -95
  44. package/src/__tests__/source-bundle-from-dir.test.ts +0 -85
  45. package/src/__tests__/source-bundle.test.ts +0 -105
  46. package/src/__tests__/upload.test.ts +0 -121
  47. package/src/source-bundle.ts +0 -234
package/src/issue.ts CHANGED
@@ -1,26 +1,26 @@
1
- // `sentori-cli issue list / resolve / silence` — CI triage helpers.
1
+ // `sentori-cli issue list / resolve` — CI triage over the AI
2
+ // surface (`/api/*`, api-scope token; the same loop an agent runs).
2
3
 
3
4
  type Issue = {
4
- errorType: string
5
- eventCount: number
6
5
  id: string
7
- lastSeen: string
6
+ kind: string
7
+ title: string
8
8
  messageSample: string
9
- status: 'active' | 'closed' | 'resolved' | 'silenced'
9
+ status: string
10
+ eventCount: number
11
+ usersCount: number
12
+ maxPerUser: number
13
+ lastSeen: string
14
+ regressed: boolean
10
15
  }
11
16
 
12
- type AdminConfig = {
17
+ export type ApiConfig = {
13
18
  apiUrl: string
14
- projectId: string
15
19
  token: string
16
20
  }
17
21
 
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), {
22
+ async function apiFetch<T>(c: ApiConfig, path: string, init?: RequestInit): Promise<T> {
23
+ const resp = await fetch(`${c.apiUrl.replace(/\/+$/, '')}${path}`, {
24
24
  ...init,
25
25
  headers: {
26
26
  Authorization: `Bearer ${c.token}`,
@@ -29,53 +29,54 @@ async function adminFetch<T>(c: AdminConfig, path: string, init?: RequestInit):
29
29
  },
30
30
  })
31
31
  if (!resp.ok) {
32
- let detail = ''
33
- try {
34
- detail = await resp.text()
35
- } catch {
36
- // ignore
37
- }
32
+ const detail = await resp.text().catch(() => '')
38
33
  throw new Error(
39
34
  `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
40
35
  )
41
36
  }
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
37
+ return (await resp.json()) as T
46
38
  }
47
39
 
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[]> {
40
+ export async function listIssues(
41
+ c: ApiConfig,
42
+ opts: { status?: string; kind?: string } = {},
43
+ ): Promise<Issue[]> {
56
44
  const q = new URLSearchParams()
57
45
  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)
46
+ if (opts.kind) q.set('kind', opts.kind)
60
47
  const qs = q.toString()
61
- return adminFetch<Issue[]>(opts.config, `/issues${qs ? '?' + qs : ''}`)
48
+ const body = await apiFetch<{ issues: Issue[] }>(c, `/api/issues${qs ? `?${qs}` : ''}`)
49
+ return body.issues
62
50
  }
63
51
 
64
- export async function issuePatch(
65
- config: AdminConfig,
52
+ export async function resolveIssue(
53
+ c: ApiConfig,
66
54
  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',
55
+ release?: string,
56
+ ): Promise<void> {
57
+ await apiFetch(c, `/api/issues/${encodeURIComponent(issueId)}/resolve`, {
58
+ method: 'POST',
59
+ body: JSON.stringify(release ? { release } : {}),
60
+ })
61
+ }
62
+
63
+ export async function noteIssue(c: ApiConfig, issueId: string, body: string): Promise<void> {
64
+ await apiFetch(c, `/api/issues/${encodeURIComponent(issueId)}/notes`, {
65
+ method: 'POST',
66
+ body: JSON.stringify({ body }),
72
67
  })
73
68
  }
74
69
 
75
- /** Format one issue for terminal output — short, one line, scannable. */
70
+ export async function fetchBundle(c: ApiConfig, issueId: string): Promise<string> {
71
+ const resp = await fetch(
72
+ `${c.apiUrl.replace(/\/+$/, '')}/api/issues/${encodeURIComponent(issueId)}/bundle`,
73
+ { headers: { Authorization: `Bearer ${c.token}` } },
74
+ )
75
+ if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
76
+ return resp.text()
77
+ }
78
+
76
79
  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}`
80
+ const flag = i.regressed ? ' REGRESSED' : ''
81
+ return `${i.id} [${i.kind}] ${i.title} ${i.usersCount}u×${i.maxPerUser} ${i.eventCount}ev${flag}`
81
82
  }
package/src/lenient.ts ADDED
@@ -0,0 +1,39 @@
1
+ // The iron rule reaches build-time (design.md §6 dim 3): an upload
2
+ // failure must never break the customer's build or release. Every
3
+ // upload-family command runs through this wrapper — on failure it
4
+ // prints a friendly, actionable note and exits 0. `--strict` opts
5
+ // into a real non-zero exit for pipelines that want the hard
6
+ // guarantee.
7
+
8
+ export type LenientOutcome = {
9
+ /** What failed, one line. */
10
+ failure: string
11
+ /** What it means for the customer, one or two lines. */
12
+ impact: string
13
+ /** A copy-pasteable retry command. */
14
+ retry: string
15
+ }
16
+
17
+ export const isStrict = (argv: string[]): boolean => argv.includes('--strict')
18
+
19
+ /** Strip --strict before handing argv to parseArgs. */
20
+ export const stripStrict = (argv: string[]): string[] =>
21
+ argv.filter((a) => a !== '--strict')
22
+
23
+ export const lenientFail = (strict: boolean, o: LenientOutcome): number => {
24
+ console.error(`[sentori] ⚠ ${o.failure}`)
25
+ if (!strict) {
26
+ console.error('[sentori] Your build is NOT blocked by this.')
27
+ }
28
+ console.error(`[sentori] Impact: ${o.impact}`)
29
+ console.error('[sentori] Retry anytime:')
30
+ console.error(`[sentori] ${o.retry}`)
31
+ console.error(
32
+ '[sentori] Events received in the meantime are re-symbolicated automatically after upload.',
33
+ )
34
+ if (strict) {
35
+ console.error('[sentori] (--strict: exiting non-zero)')
36
+ return 1
37
+ }
38
+ return 0
39
+ }
package/src/mcp.ts CHANGED
@@ -19,8 +19,6 @@
19
19
 
20
20
  import { createInterface } from 'node:readline'
21
21
 
22
- import type { AdminUpload } from './native-artifacts.js'
23
-
24
22
  type JsonRpcRequest = {
25
23
  id?: number | string | null
26
24
  jsonrpc: '2.0'
@@ -44,7 +42,7 @@ type ToolDef = {
44
42
  handler: ToolHandler
45
43
  }
46
44
 
47
- type McpCtx = AdminUpload
45
+ type McpCtx = { apiUrl: string; token: string }
48
46
 
49
47
  /** Run the MCP server over stdio. Returns when stdin closes. */
50
48
  export async function runMcpServer(ctx: McpCtx): Promise<void> {
@@ -135,265 +133,98 @@ async function dispatch(
135
133
  }
136
134
  }
137
135
 
138
- // ── Tool implementations ─────────────────────────────────────────
136
+ // ── Tool implementations — the /api closed loop ──────────────────
137
+ //
138
+ // Four tools, mirroring exactly what an agent needs (design.md §9):
139
+ // pick work, pull the evidence, write back, resolve. The bundle is
140
+ // the product; everything else is triage plumbing.
139
141
 
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
- })
142
+ async function apiGet(ctx: McpCtx, path: string, raw = false): Promise<unknown> {
143
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}${path}`
144
+ const resp = await fetch(url, { headers: { Authorization: `Bearer ${ctx.token}` } })
145
145
  if (!resp.ok) throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`)
146
- return (await resp.json()) as T
146
+ return raw ? resp.text() : resp.json()
147
147
  }
148
148
 
149
- async function adminPatch<T>(ctx: McpCtx, path: string, body: unknown): Promise<T> {
150
- const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
149
+ async function apiPost(ctx: McpCtx, path: string, body: unknown): Promise<unknown> {
150
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}${path}`
151
151
  const resp = await fetch(url, {
152
152
  body: JSON.stringify(body),
153
153
  headers: {
154
154
  Authorization: `Bearer ${ctx.token}`,
155
155
  'Content-Type': 'application/json',
156
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
157
  method: 'POST',
172
158
  })
173
159
  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
160
+ return resp.json()
198
161
  }
199
162
 
200
163
  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')
164
+ if (typeof v !== 'string' || v.length === 0) throw new Error(`${name} must be a non-empty string`)
210
165
  return v
211
166
  }
212
167
 
213
168
  export function buildTools(): ToolDef[] {
214
169
  return [
215
170
  {
171
+ name: 'sentori_issue_list',
216
172
  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
- },
173
+ 'List issues (default: open, ordered by objective importance — regressed first, then breadth × depth). Filter with status (open|resolved|ignored) and kind (error|warn|trace|assert|probe).',
228
174
  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
175
  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
176
  properties: {
254
- issueId: { type: 'string' },
255
- projectId: { type: 'string' },
177
+ status: { type: 'string', enum: ['open', 'resolved', 'ignored'] },
178
+ kind: { type: 'string', enum: ['assert', 'error', 'probe', 'trace', 'warn'] },
256
179
  },
257
- required: ['projectId', 'issueId'],
258
- type: 'object',
259
180
  },
260
- name: 'sentori_issue_get',
261
- },
262
- {
263
- description: 'Add a comment to a Sentori issue.',
264
181
  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',
182
+ const q = new URLSearchParams()
183
+ if (typeof args.status === 'string') q.set('status', args.status)
184
+ if (typeof args.kind === 'string') q.set('kind', args.kind)
185
+ const qs = q.toString()
186
+ return apiGet(ctx, `/api/issues${qs ? `?${qs}` : ''}`)
280
187
  },
281
- name: 'sentori_issue_comment',
282
188
  },
283
189
  {
190
+ name: 'sentori_issue_bundle',
284
191
  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
- },
192
+ 'Fetch the full evidence bundle for one issue as markdown — read it and you have everything needed to fix: stack, user timeline, environment, distribution, guard-probe status.',
294
193
  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
194
  type: 'object',
195
+ properties: { issueId: { type: 'string' } },
196
+ required: ['issueId'],
305
197
  },
306
- name: 'sentori_issue_transition',
198
+ handler: async (args, ctx) =>
199
+ apiGet(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/bundle`, true),
307
200
  },
308
201
  {
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
- },
202
+ name: 'sentori_issue_note',
203
+ description:
204
+ 'Append a note to an issue — write back what you did ("fixed in abc123, probe SENT-42 planted").',
364
205
  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
206
  type: 'object',
207
+ properties: { issueId: { type: 'string' }, body: { type: 'string' } },
208
+ required: ['issueId', 'body'],
372
209
  },
373
- name: 'sentori_issue_set_labels',
210
+ handler: async (args, ctx) =>
211
+ apiPost(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/notes`, {
212
+ body: asString(args.body, 'body'),
213
+ }),
374
214
  },
375
215
  {
216
+ name: 'sentori_issue_resolve',
376
217
  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
- },
218
+ 'Resolve an issue, anchored on the release that carries the fix — only a recurrence in that release or newer counts as a regression.',
387
219
  inputSchema: {
388
- properties: {
389
- issueId: { type: 'string' },
390
- projectId: { type: 'string' },
391
- watch: { type: 'boolean' },
392
- },
393
- required: ['projectId', 'issueId', 'watch'],
394
220
  type: 'object',
221
+ properties: { issueId: { type: 'string' }, release: { type: 'string' } },
222
+ required: ['issueId'],
395
223
  },
396
- name: 'sentori_issue_watch',
224
+ handler: async (args, ctx) =>
225
+ apiPost(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/resolve`, {
226
+ release: typeof args.release === 'string' ? args.release : undefined,
227
+ }),
397
228
  },
398
229
  ]
399
230
  }
@@ -1,47 +1,46 @@
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.
1
+ // `sentori-cli upload dsym` (iOS) + `sentori-cli upload mapping`
2
+ // (Android). dwarfdump slice discovery stays local; the bytes land
3
+ // on the unified artifacts endpoint (multipart kind + file).
4
4
 
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { readFile } from 'node:fs/promises'
7
7
  import { basename, extname, join } from 'node:path'
8
8
  import { statSync, readdirSync } from 'node:fs'
9
9
 
10
+ import { gzipForWire, wireArrayBuffer } from './upload.js'
11
+
10
12
  export type AdminUpload = {
11
13
  apiUrl: string
12
- projectId: string
13
14
  release?: string
14
15
  token: string
15
16
  }
16
17
 
17
- async function postBytes(
18
- url: string,
19
- body: Buffer,
20
- token: string,
21
- headers: Record<string, string> = {},
22
- ): Promise<unknown> {
18
+ async function uploadBytes(opts: {
19
+ apiUrl: string
20
+ token: string
21
+ release: string
22
+ kind: 'dsym' | 'proguard'
23
+ name: string
24
+ bytes: Buffer
25
+ }): Promise<void> {
26
+ // Gzip on the wire — DWARF ~3:1, R8 mapping ~10:1; a real app's
27
+ // main dSYM only fits the server's transport cap compressed.
28
+ const { wire, wireName } = gzipForWire(opts.bytes, opts.name)
29
+ const form = new FormData()
30
+ form.append('kind', opts.kind)
31
+ form.append('file', new Blob([wireArrayBuffer(wire)]), wireName)
32
+ const url = `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/${encodeURIComponent(opts.release)}/artifacts`
23
33
  const resp = await fetch(url, {
24
- body,
25
- headers: {
26
- Authorization: `Bearer ${token}`,
27
- 'Content-Type': 'application/octet-stream',
28
- ...headers,
29
- },
34
+ body: form,
35
+ headers: { Authorization: `Bearer ${opts.token}` },
30
36
  method: 'POST',
31
37
  })
32
38
  if (!resp.ok) {
33
- let detail = ''
34
- try {
35
- detail = await resp.text()
36
- } catch {
37
- // ignore
38
- }
39
+ const detail = await resp.text().catch(() => '')
39
40
  throw new Error(
40
41
  `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
41
42
  )
42
43
  }
43
- const txt = await resp.text()
44
- return txt ? JSON.parse(txt) : null
45
44
  }
46
45
 
47
46
  // ── dSYM ──────────────────────────────────────────────────────────
@@ -135,20 +134,19 @@ export async function uploadDsym(opts: DsymUploadOptions): Promise<DsymUploadRes
135
134
  slices.push(...found)
136
135
  }
137
136
 
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
-
137
+ const objectName = opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, '')
146
138
  const uploaded: { arch: string; debugId: string }[] = []
147
139
  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,
140
+ const bytes = await readFile(s.file)
141
+ // One artifact per slice; the name carries the lookup identity
142
+ // (object-arch-debugId) until the dwarf wiring lands server-side.
143
+ await uploadBytes({
144
+ apiUrl: opts.apiUrl,
145
+ token: opts.token,
146
+ release: opts.release ?? '',
147
+ kind: 'dsym',
148
+ name: `${objectName}-${s.arch}-${s.debugId}`,
149
+ bytes,
152
150
  })
153
151
  uploaded.push({ arch: s.arch, debugId: s.debugId })
154
152
  }
@@ -171,12 +169,12 @@ export async function uploadMapping(opts: MappingUploadOptions): Promise<void> {
171
169
  const body = await readFile(opts.path)
172
170
  if (body.length === 0) throw new Error(`empty mapping file: ${opts.path}`)
173
171
 
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)
172
+ await uploadBytes({
173
+ apiUrl: opts.apiUrl,
174
+ token: opts.token,
175
+ release: opts.release ?? '',
176
+ kind: 'proguard',
177
+ name: opts.debugId ? `mapping-${opts.debugId}.txt` : basename(opts.path),
178
+ bytes: body,
179
+ })
182
180
  }