@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/index.ts ADDED
@@ -0,0 +1,799 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+
4
+ import { formatIssueLine, issueList, issuePatch } from './issue.js'
5
+ import { runMcpServer } from './mcp.js'
6
+ import { uploadDsym, uploadMapping } from './native-artifacts.js'
7
+ import {
8
+ parseJsonArg,
9
+ pushCredsDelete,
10
+ pushCredsList,
11
+ pushCredsSet,
12
+ pushReceipt,
13
+ pushSend,
14
+ } from './push.js'
15
+ import { reactNativeUpload } from './react-native.js'
16
+ import { uploadSourceBundle } from './source-bundle.js'
17
+ import { uploadSourcemaps } from './upload.js'
18
+
19
+ const HELP = `sentori-cli — Sentori command-line interface
20
+
21
+ Source-map upload:
22
+ sentori-cli upload sourcemap [options] <path...>
23
+ Upload one or more files or directories. A directory is scanned
24
+ (one level) for *.map / *.js / *.jsbundle / *.bundle / *.hbc;
25
+ a file given explicitly is uploaded as-is. Use this when you
26
+ already have a composed sourcemap on disk:
27
+ - web bundlers (point at the build dir),
28
+ - iOS post-\`react-native-xcode.sh\` where the build phase has
29
+ already composed packager + Hermes maps into the final
30
+ \`$SOURCEMAP_FILE\` and deleted the intermediates.
31
+ Composed-then-uploaded vs raw-then-server-composed yield
32
+ identical symbolication — the server stores the same shape
33
+ either way.
34
+
35
+ sentori-cli react-native upload [options]
36
+ Compose a Metro packager map + a Hermes map into one source map
37
+ (uses react-native's \`scripts/compose-source-maps.js\`) and
38
+ upload the result. Use this when you have both raw maps on
39
+ disk — typical Android release path where the gradle
40
+ \`bundleReleaseJsAndAssets\` task leaves both maps untouched.
41
+ Requires --metro-map and --hermes-map. On iOS the build phase
42
+ deletes the intermediates, so use \`upload sourcemap\` instead.
43
+
44
+ Native artifacts (project-scoped, need --project + admin token):
45
+ sentori-cli upload dsym --project <uuid> [--release <r>] [--object-name <n>] [--debug-id <uuid> --arch <a>] <path>
46
+ Upload iOS dSYM debug info. By default walks a Foo.dSYM bundle
47
+ and uses "dwarfdump --uuid" to enumerate slices, uploading each.
48
+ Pass --debug-id and --arch to upload a single slice without
49
+ dwarfdump (useful in Linux CI where the toolchain isn't there).
50
+
51
+ sentori-cli upload mapping --project <uuid> [--release <r>] [--debug-id <uuid>] <mapping.txt>
52
+ Upload an R8 / ProGuard mapping (raw bytes). If the file starts
53
+ with a "# pg_map_id:" line the server sniffs the debug-id from
54
+ it; otherwise you can pass it explicitly.
55
+
56
+ sentori-cli upload source-bundle --project <uuid> --release <r> --platform ios|android [--module <label>] <archive.tar.gz>
57
+ Upload a pre-built tar.gz of your project's source so the
58
+ dashboard can render inline source for native (Swift / Kotlin /
59
+ Objective-C) frames the way it already does for JS via source
60
+ maps. Build the archive yourself:
61
+ tar -czf ios-source.tar.gz Sources/
62
+ Pass --module to upload multiple bundles per (release, platform)
63
+ — e.g. \`--module main\`, \`--module watch-ext\`. Omitting --module
64
+ reuses the v1.3 single-bundle slot (re-uploading replaces it).
65
+
66
+ CI triage:
67
+ sentori-cli issue list --project <uuid> [--status active|silenced|resolved|closed] [--limit N] [--error-type <t>]
68
+ sentori-cli issue resolve <issue-uuid> --project <uuid> [--in-release <r>]
69
+ sentori-cli issue silence <issue-uuid> --project <uuid>
70
+
71
+ LLM agents (MCP):
72
+ sentori-cli mcp serve --project <uuid> [--token <t>] [--api-url <url>]
73
+ Run a stdio MCP server. Connect from Claude Code / any MCP
74
+ client by pointing at \`sentori-cli mcp serve …\` as the command.
75
+ Exposes sentori_issue_list / _get / _comment / _transition /
76
+ _assign / _set_priority / _set_labels / _watch tools.
77
+
78
+ Options (upload commands):
79
+ --release <r> release identifier — MUST equal the value the SDK
80
+ reports via init({ release }). Required.
81
+ --token <t> Sentori token (or set $SENTORI_TOKEN).
82
+ --api-url <url> Sentori API base (default https://sentori.golia.jp,
83
+ or $SENTORI_API_URL). For a self-hosted instance, your
84
+ host. (Accepts --ingest-url as an alias.)
85
+ --dry-run describe what would be uploaded; don't upload.
86
+ -h, --help show this help.
87
+
88
+ Options (react-native upload):
89
+ --metro-map <p> the *.packager.map Metro emits (--sourcemap-output).
90
+ --hermes-map <p> the *.hbc.map the Hermes compiler emits.
91
+ --bundle <p> optional: also upload the bundle (.jsbundle / .bundle).
92
+
93
+ Options (issue commands):
94
+ --project <uuid> project id (or set $SENTORI_PROJECT_ID).
95
+ --token <t> admin token, sk_… prefix (or $SENTORI_ADMIN_TOKEN /
96
+ $SENTORI_TOKEN). The ingest st_pk_ token may also work
97
+ on a self-hosted instance.
98
+ --api-url <url> Sentori API base (same as above).
99
+ --in-release <r> (resolve only) mark this release as where the fix
100
+ landed; the regression detector flips the issue back
101
+ to "regressed" if a matching event lands later.
102
+
103
+ Hermes release build, by hand:
104
+
105
+ Android (raw maps still on disk after \`./gradlew bundleRelease\`):
106
+ npx @goliapkg/sentori-cli react-native upload \\
107
+ --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
108
+ --metro-map android/app/build/intermediates/sourcemaps/react/release/index.android.bundle.packager.map \\
109
+ --hermes-map android/app/build/intermediates/sourcemaps/react/release/index.android.bundle.compiler.map \\
110
+ --bundle android/app/build/generated/assets/react/release/index.android.bundle
111
+
112
+ iOS (already-composed map after \`xcodebuild archive\`; the build
113
+ phase deletes intermediates so you only have the composed map):
114
+ npx @goliapkg/sentori-cli upload sourcemap \\
115
+ --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
116
+ "$BUILT_PRODUCTS_DIR/main.jsbundle.map" \\
117
+ "$BUILT_PRODUCTS_DIR/main.jsbundle"
118
+ `
119
+
120
+ type Common = { apiUrl: string; dryRun: boolean; release: string; token: string }
121
+
122
+ /** Parse the shared options, or print an error + return null. */
123
+ function parseCommon(values: Record<string, unknown>): Common | null {
124
+ const release = typeof values.release === 'string' ? values.release : undefined
125
+ if (!release) {
126
+ console.error('error: --release is required (must match the SDK’s init({ release }))')
127
+ return null
128
+ }
129
+ const dryRun = values['dry-run'] === true
130
+ const token =
131
+ (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
132
+ if (!token && !dryRun) {
133
+ console.error('error: --token (or $SENTORI_TOKEN) is required')
134
+ return null
135
+ }
136
+ const apiUrl =
137
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
138
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
139
+ process.env.SENTORI_API_URL ??
140
+ 'https://sentori.golia.jp'
141
+ return { apiUrl, dryRun, release, token: token ?? '' }
142
+ }
143
+
144
+ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
145
+ let parsed
146
+ try {
147
+ parsed = parseArgs({
148
+ allowPositionals: true,
149
+ args: argv,
150
+ options: {
151
+ 'api-url': { type: 'string' },
152
+ 'dry-run': { type: 'boolean' },
153
+ help: { short: 'h', type: 'boolean' },
154
+ 'ingest-url': { type: 'string' },
155
+ release: { type: 'string' },
156
+ token: { type: 'string' },
157
+ },
158
+ })
159
+ } catch (e) {
160
+ console.error(`error: ${(e as Error).message}\n`)
161
+ console.error(HELP)
162
+ return 2
163
+ }
164
+ if (parsed.values.help) {
165
+ console.log(HELP)
166
+ return 0
167
+ }
168
+ const c = parseCommon(parsed.values)
169
+ if (!c) return 2
170
+ if (parsed.positionals.length === 0) {
171
+ console.error('error: at least one path (file or directory) is required')
172
+ return 2
173
+ }
174
+ try {
175
+ const result = await uploadSourcemaps({
176
+ apiUrl: c.apiUrl,
177
+ dryRun: c.dryRun,
178
+ paths: parsed.positionals,
179
+ release: c.release,
180
+ token: c.token,
181
+ })
182
+ reportUpload(result, c)
183
+ return 0
184
+ } catch (e) {
185
+ console.error(`upload failed: ${(e as Error).message}`)
186
+ return 1
187
+ }
188
+ }
189
+
190
+ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
191
+ let parsed
192
+ try {
193
+ parsed = parseArgs({
194
+ args: argv,
195
+ options: {
196
+ 'api-url': { type: 'string' },
197
+ bundle: { type: 'string' },
198
+ 'dry-run': { type: 'boolean' },
199
+ help: { short: 'h', type: 'boolean' },
200
+ 'hermes-map': { type: 'string' },
201
+ 'ingest-url': { type: 'string' },
202
+ 'metro-map': { type: 'string' },
203
+ release: { type: 'string' },
204
+ token: { type: 'string' },
205
+ },
206
+ })
207
+ } catch (e) {
208
+ console.error(`error: ${(e as Error).message}\n`)
209
+ console.error(HELP)
210
+ return 2
211
+ }
212
+ if (parsed.values.help) {
213
+ console.log(HELP)
214
+ return 0
215
+ }
216
+ const c = parseCommon(parsed.values)
217
+ if (!c) return 2
218
+ const metroMap = parsed.values['metro-map']
219
+ const hermesMap = parsed.values['hermes-map']
220
+ if (typeof metroMap !== 'string' || typeof hermesMap !== 'string') {
221
+ console.error('error: --metro-map and --hermes-map are both required')
222
+ return 2
223
+ }
224
+ try {
225
+ const result = await reactNativeUpload({
226
+ apiUrl: c.apiUrl,
227
+ bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
228
+ dryRun: c.dryRun,
229
+ hermesMap,
230
+ metroMap,
231
+ release: c.release,
232
+ token: c.token,
233
+ })
234
+ reportUpload(result, c)
235
+ return 0
236
+ } catch (e) {
237
+ console.error(`react-native upload failed: ${(e as Error).message}`)
238
+ return 1
239
+ }
240
+ }
241
+
242
+ function reportUpload(
243
+ result: { files: string[]; uploaded?: number },
244
+ c: Common,
245
+ ): void {
246
+ if (c.dryRun) {
247
+ console.log(
248
+ `would upload ${result.files.length} file(s) to ${c.apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(c.release)}/sourcemaps:`,
249
+ )
250
+ for (const f of result.files) console.log(` ${f}`)
251
+ } else {
252
+ console.log(
253
+ `uploaded ${result.uploaded ?? result.files.length} file(s) for release "${c.release}" — minified stacks on this release will now resolve to source.`,
254
+ )
255
+ }
256
+ }
257
+
258
+ // ── issue commands ────────────────────────────────────────────────
259
+
260
+ type AdminCfg = { apiUrl: string; projectId: string; token: string }
261
+
262
+ function parseAdminCfg(values: Record<string, unknown>): AdminCfg | null {
263
+ const projectId =
264
+ (typeof values.project === 'string' ? values.project : undefined) ??
265
+ process.env.SENTORI_PROJECT_ID
266
+ if (!projectId) {
267
+ console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required')
268
+ return null
269
+ }
270
+ const token =
271
+ (typeof values.token === 'string' ? values.token : undefined) ??
272
+ process.env.SENTORI_ADMIN_TOKEN ??
273
+ process.env.SENTORI_TOKEN
274
+ if (!token) {
275
+ console.error(
276
+ 'error: --token (or $SENTORI_ADMIN_TOKEN / $SENTORI_TOKEN) is required for issue commands',
277
+ )
278
+ return null
279
+ }
280
+ const apiUrl =
281
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
282
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
283
+ process.env.SENTORI_API_URL ??
284
+ 'https://sentori.golia.jp'
285
+ return { apiUrl, projectId, token }
286
+ }
287
+
288
+ async function cmdIssueList(argv: string[]): Promise<number> {
289
+ let parsed
290
+ try {
291
+ parsed = parseArgs({
292
+ args: argv,
293
+ options: {
294
+ 'api-url': { type: 'string' },
295
+ 'error-type': { type: 'string' },
296
+ help: { short: 'h', type: 'boolean' },
297
+ 'ingest-url': { type: 'string' },
298
+ limit: { type: 'string' },
299
+ project: { type: 'string' },
300
+ status: { type: 'string' },
301
+ token: { type: 'string' },
302
+ },
303
+ })
304
+ } catch (e) {
305
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
306
+ return 2
307
+ }
308
+ if (parsed.values.help) {
309
+ console.log(HELP)
310
+ return 0
311
+ }
312
+ const cfg = parseAdminCfg(parsed.values)
313
+ if (!cfg) return 2
314
+ const status = parsed.values.status
315
+ if (status && !['active', 'closed', 'resolved', 'silenced'].includes(status)) {
316
+ console.error(`error: --status must be one of: active, silenced, resolved, closed`)
317
+ return 2
318
+ }
319
+ const limitStr = parsed.values.limit
320
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined
321
+ try {
322
+ const rows = await issueList({
323
+ config: cfg,
324
+ errorType: parsed.values['error-type'],
325
+ limit,
326
+ status: status as 'active' | 'closed' | 'resolved' | 'silenced' | undefined,
327
+ })
328
+ if (rows.length === 0) {
329
+ console.log('(no matching issues)')
330
+ return 0
331
+ }
332
+ for (const r of rows) console.log(formatIssueLine(r))
333
+ return 0
334
+ } catch (e) {
335
+ console.error(`issue list failed: ${(e as Error).message}`)
336
+ return 1
337
+ }
338
+ }
339
+
340
+ async function cmdIssuePatch(
341
+ argv: string[],
342
+ body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
343
+ verb: 'closed' | 'resolved' | 'silenced',
344
+ ): Promise<number> {
345
+ let parsed
346
+ try {
347
+ parsed = parseArgs({
348
+ allowPositionals: true,
349
+ args: argv,
350
+ options: {
351
+ 'api-url': { type: 'string' },
352
+ help: { short: 'h', type: 'boolean' },
353
+ 'in-release': { type: 'string' },
354
+ 'ingest-url': { type: 'string' },
355
+ project: { type: 'string' },
356
+ token: { type: 'string' },
357
+ },
358
+ })
359
+ } catch (e) {
360
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
361
+ return 2
362
+ }
363
+ if (parsed.values.help) {
364
+ console.log(HELP)
365
+ return 0
366
+ }
367
+ const cfg = parseAdminCfg(parsed.values)
368
+ if (!cfg) return 2
369
+ const issueId = parsed.positionals[0]
370
+ if (!issueId) {
371
+ console.error('error: <issue-uuid> is required')
372
+ return 2
373
+ }
374
+ if (verb === 'resolved' && typeof parsed.values['in-release'] === 'string') {
375
+ body.resolvedInRelease = parsed.values['in-release']
376
+ }
377
+ try {
378
+ const updated = await issuePatch(cfg, issueId, body)
379
+ console.log(
380
+ `${issueId} → ${verb}${body.resolvedInRelease ? ` (in ${body.resolvedInRelease})` : ''}: ${updated.errorType}`,
381
+ )
382
+ return 0
383
+ } catch (e) {
384
+ console.error(`issue ${verb} failed: ${(e as Error).message}`)
385
+ return 1
386
+ }
387
+ }
388
+
389
+ // ── native artifact upload ────────────────────────────────────────
390
+
391
+ async function cmdUploadDsym(argv: string[]): Promise<number> {
392
+ let parsed
393
+ try {
394
+ parsed = parseArgs({
395
+ allowPositionals: true,
396
+ args: argv,
397
+ options: {
398
+ 'api-url': { type: 'string' },
399
+ arch: { type: 'string' },
400
+ 'debug-id': { type: 'string' },
401
+ help: { short: 'h', type: 'boolean' },
402
+ 'ingest-url': { type: 'string' },
403
+ 'object-name': { type: 'string' },
404
+ project: { type: 'string' },
405
+ release: { type: 'string' },
406
+ token: { type: 'string' },
407
+ },
408
+ })
409
+ } catch (e) {
410
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
411
+ return 2
412
+ }
413
+ if (parsed.values.help) {
414
+ console.log(HELP)
415
+ return 0
416
+ }
417
+ const cfg = parseAdminCfg(parsed.values)
418
+ if (!cfg) return 2
419
+ const path = parsed.positionals[0]
420
+ if (!path) {
421
+ console.error('error: a path to a .dSYM bundle or DWARF binary is required')
422
+ return 2
423
+ }
424
+ const debugId = parsed.values['debug-id']
425
+ const arch = parsed.values.arch
426
+ if ((debugId && !arch) || (arch && !debugId)) {
427
+ console.error('error: --debug-id and --arch must be passed together (or both omitted)')
428
+ return 2
429
+ }
430
+ try {
431
+ const r = await uploadDsym({
432
+ apiUrl: cfg.apiUrl,
433
+ arch: typeof arch === 'string' ? arch : undefined,
434
+ debugId: typeof debugId === 'string' ? debugId : undefined,
435
+ objectName: typeof parsed.values['object-name'] === 'string' ? parsed.values['object-name'] : undefined,
436
+ path,
437
+ projectId: cfg.projectId,
438
+ release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
439
+ token: cfg.token,
440
+ })
441
+ console.log(`uploaded ${r.slices.length} dSYM slice(s):`)
442
+ for (const s of r.slices) console.log(` ${s.debugId} (${s.arch})`)
443
+ return 0
444
+ } catch (e) {
445
+ console.error(`dsym upload failed: ${(e as Error).message}`)
446
+ return 1
447
+ }
448
+ }
449
+
450
+ async function cmdUploadMapping(argv: string[]): Promise<number> {
451
+ let parsed
452
+ try {
453
+ parsed = parseArgs({
454
+ allowPositionals: true,
455
+ args: argv,
456
+ options: {
457
+ 'api-url': { type: 'string' },
458
+ 'debug-id': { type: 'string' },
459
+ help: { short: 'h', type: 'boolean' },
460
+ 'ingest-url': { type: 'string' },
461
+ project: { type: 'string' },
462
+ release: { type: 'string' },
463
+ token: { type: 'string' },
464
+ },
465
+ })
466
+ } catch (e) {
467
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
468
+ return 2
469
+ }
470
+ if (parsed.values.help) {
471
+ console.log(HELP)
472
+ return 0
473
+ }
474
+ const cfg = parseAdminCfg(parsed.values)
475
+ if (!cfg) return 2
476
+ const path = parsed.positionals[0]
477
+ if (!path) {
478
+ console.error('error: a path to mapping.txt is required')
479
+ return 2
480
+ }
481
+ try {
482
+ await uploadMapping({
483
+ apiUrl: cfg.apiUrl,
484
+ debugId: typeof parsed.values['debug-id'] === 'string' ? parsed.values['debug-id'] : undefined,
485
+ path,
486
+ projectId: cfg.projectId,
487
+ release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
488
+ token: cfg.token,
489
+ })
490
+ console.log(`uploaded mapping for project ${cfg.projectId}${parsed.values.release ? ` / ${parsed.values.release}` : ''}`)
491
+ return 0
492
+ } catch (e) {
493
+ console.error(`mapping upload failed: ${(e as Error).message}`)
494
+ return 1
495
+ }
496
+ }
497
+
498
+ async function cmdUploadSourceBundle(argv: string[]): Promise<number> {
499
+ let parsed
500
+ try {
501
+ parsed = parseArgs({
502
+ allowPositionals: true,
503
+ args: argv,
504
+ options: {
505
+ 'api-url': { type: 'string' },
506
+ help: { short: 'h', type: 'boolean' },
507
+ 'ingest-url': { type: 'string' },
508
+ // v1.4 W26 — optional module label so polyrepo apps can
509
+ // upload multiple bundles per (release, platform) without
510
+ // clobbering each other (main vs watch-ext vs share-ext…).
511
+ module: { type: 'string' },
512
+ platform: { type: 'string' },
513
+ project: { type: 'string' },
514
+ release: { type: 'string' },
515
+ token: { type: 'string' },
516
+ },
517
+ })
518
+ } catch (e) {
519
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
520
+ return 2
521
+ }
522
+ if (parsed.values.help) {
523
+ console.log(HELP)
524
+ return 0
525
+ }
526
+ const cfg = parseAdminCfg(parsed.values)
527
+ if (!cfg) return 2
528
+ const path = parsed.positionals[0]
529
+ if (!path) {
530
+ console.error('error: a path to a tar.gz archive is required')
531
+ return 2
532
+ }
533
+ const platform = parsed.values.platform
534
+ if (platform !== 'ios' && platform !== 'android') {
535
+ console.error('error: --platform must be ios or android')
536
+ return 2
537
+ }
538
+ const release = typeof parsed.values.release === 'string' ? parsed.values.release : undefined
539
+ if (!release) {
540
+ console.error('error: --release is required for source-bundle uploads')
541
+ return 2
542
+ }
543
+ try {
544
+ const r = await uploadSourceBundle({
545
+ apiUrl: cfg.apiUrl,
546
+ module: typeof parsed.values.module === 'string' ? parsed.values.module : undefined,
547
+ path,
548
+ platform,
549
+ projectId: cfg.projectId,
550
+ release,
551
+ token: cfg.token,
552
+ })
553
+ console.log(`uploaded ${r.kind} (${r.sizeBytes} bytes, sha256:${r.contentHash.slice(0, 12)}…)`)
554
+ return 0
555
+ } catch (e) {
556
+ console.error(`source-bundle upload failed: ${(e as Error).message}`)
557
+ return 1
558
+ }
559
+ }
560
+
561
+ async function cmdMcpServe(argv: string[]): Promise<number> {
562
+ let parsed
563
+ try {
564
+ parsed = parseArgs({
565
+ args: argv,
566
+ options: {
567
+ 'api-url': { type: 'string' },
568
+ help: { short: 'h', type: 'boolean' },
569
+ 'ingest-url': { type: 'string' },
570
+ project: { type: 'string' },
571
+ token: { type: 'string' },
572
+ },
573
+ })
574
+ } catch (e) {
575
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
576
+ return 2
577
+ }
578
+ if (parsed.values.help) {
579
+ console.log(HELP)
580
+ return 0
581
+ }
582
+ const cfg = parseAdminCfg(parsed.values)
583
+ if (!cfg) return 2
584
+ try {
585
+ await runMcpServer({ apiUrl: cfg.apiUrl, projectId: cfg.projectId, token: cfg.token })
586
+ return 0
587
+ } catch (e) {
588
+ console.error(`mcp serve failed: ${(e as Error).message}`)
589
+ return 1
590
+ }
591
+ }
592
+
593
+ async function main(argv: string[]): Promise<number> {
594
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
595
+ console.log(HELP)
596
+ return 0
597
+ }
598
+ const [a, b, ...rest] = argv
599
+ if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
600
+ if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
601
+ if (a === 'upload' && b === 'mapping') return cmdUploadMapping(rest)
602
+ if (a === 'upload' && b === 'source-bundle') return cmdUploadSourceBundle(rest)
603
+ if (a === 'mcp' && b === 'serve') return cmdMcpServe(rest)
604
+ if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
605
+ if (a === 'issue' && b === 'list') return cmdIssueList(rest)
606
+ if (a === 'issue' && b === 'resolve') return cmdIssuePatch(rest, { status: 'resolved' }, 'resolved')
607
+ if (a === 'issue' && b === 'silence') return cmdIssuePatch(rest, { status: 'silenced' }, 'silenced')
608
+ if (a === 'issue' && b === 'close') return cmdIssuePatch(rest, { status: 'closed' }, 'closed')
609
+ if (a === 'push' && b === 'send') return cmdPushSend(rest)
610
+ if (a === 'push' && b === 'receipt') return cmdPushReceipt(rest)
611
+ if (a === 'push' && b === 'creds') {
612
+ const [c, ...rest2] = rest
613
+ if (c === 'list') return cmdPushCredsList(rest2)
614
+ if (c === 'set') return cmdPushCredsSet(rest2)
615
+ if (c === 'delete') return cmdPushCredsDelete(rest2)
616
+ }
617
+ console.error(`unknown command: ${[a, b].filter(Boolean).join(' ') || '(none)'}\n`)
618
+ console.error(HELP)
619
+ return 2
620
+ }
621
+
622
+ // ── push commands (v2.12) ─────────────────────────────────────────
623
+
624
+ async function cmdPushSend(argv: string[]): Promise<number> {
625
+ const parsed = parseArgs({
626
+ args: argv,
627
+ options: {
628
+ 'api-url': { type: 'string' },
629
+ body: { type: 'string' },
630
+ data: { type: 'string' },
631
+ 'idempotency-key': { type: 'string' },
632
+ 'ingest-url': { type: 'string' },
633
+ priority: { type: 'string' },
634
+ project: { type: 'string' },
635
+ title: { type: 'string' },
636
+ to: { type: 'string' },
637
+ token: { type: 'string' },
638
+ ttl: { type: 'string' },
639
+ },
640
+ strict: true,
641
+ })
642
+ const cfg = parseAdminCfg(parsed.values)
643
+ if (!cfg) return 2
644
+ const to = parsed.values.to as string | undefined
645
+ if (!to) {
646
+ console.error('error: --to <ipt_handle> is required')
647
+ return 2
648
+ }
649
+ try {
650
+ const data = parsed.values.data ? (parseJsonArg(parsed.values.data as string, '--data') as Record<string, unknown>) : undefined
651
+ const priority = parsed.values.priority as 'high' | 'normal' | undefined
652
+ const ticket = await pushSend(cfg, {
653
+ to,
654
+ title: parsed.values.title as string | undefined,
655
+ body: parsed.values.body as string | undefined,
656
+ data,
657
+ priority,
658
+ ttl: parsed.values.ttl ? Number(parsed.values.ttl) : undefined,
659
+ idempotencyKey: parsed.values['idempotency-key'] as string | undefined,
660
+ })
661
+ console.log(`${ticket.id} ${ticket.status}`)
662
+ return 0
663
+ } catch (e) {
664
+ console.error(`push send failed: ${(e as Error).message}`)
665
+ return 1
666
+ }
667
+ }
668
+
669
+ async function cmdPushReceipt(argv: string[]): Promise<number> {
670
+ const parsed = parseArgs({
671
+ args: argv,
672
+ options: {
673
+ 'api-url': { type: 'string' },
674
+ 'ingest-url': { type: 'string' },
675
+ project: { type: 'string' },
676
+ token: { type: 'string' },
677
+ },
678
+ allowPositionals: true,
679
+ strict: true,
680
+ })
681
+ const sendId = parsed.positionals[0]
682
+ if (!sendId) {
683
+ console.error('error: <send-id> positional is required')
684
+ return 2
685
+ }
686
+ const cfg = parseAdminCfg(parsed.values)
687
+ if (!cfg) return 2
688
+ try {
689
+ const r = await pushReceipt(cfg, sendId)
690
+ console.log(`${r.ticket.id} ${r.ticket.status}${r.ticket.providerOutcome ? ` (${r.ticket.providerOutcome})` : ''}${r.ticket.error ? ` — ${r.ticket.error}` : ''}`)
691
+ return 0
692
+ } catch (e) {
693
+ console.error(`push receipt failed: ${(e as Error).message}`)
694
+ return 1
695
+ }
696
+ }
697
+
698
+ async function cmdPushCredsList(argv: string[]): Promise<number> {
699
+ const parsed = parseArgs({
700
+ args: argv,
701
+ options: {
702
+ 'api-url': { type: 'string' },
703
+ 'ingest-url': { type: 'string' },
704
+ project: { type: 'string' },
705
+ token: { type: 'string' },
706
+ },
707
+ strict: true,
708
+ })
709
+ const cfg = parseAdminCfg(parsed.values)
710
+ if (!cfg) return 2
711
+ try {
712
+ const rows = await pushCredsList(cfg)
713
+ if (rows.length === 0) {
714
+ console.log('(no providers configured)')
715
+ return 0
716
+ }
717
+ for (const r of rows) {
718
+ console.log(`${r.provider}\t${r.updatedAt}\t${JSON.stringify(r.config)}`)
719
+ }
720
+ return 0
721
+ } catch (e) {
722
+ console.error(`push creds list failed: ${(e as Error).message}`)
723
+ return 1
724
+ }
725
+ }
726
+
727
+ async function cmdPushCredsSet(argv: string[]): Promise<number> {
728
+ const parsed = parseArgs({
729
+ args: argv,
730
+ options: {
731
+ 'api-url': { type: 'string' },
732
+ config: { type: 'string' },
733
+ 'ingest-url': { type: 'string' },
734
+ project: { type: 'string' },
735
+ secret: { type: 'string' },
736
+ token: { type: 'string' },
737
+ },
738
+ allowPositionals: true,
739
+ strict: true,
740
+ })
741
+ const provider = parsed.positionals[0]
742
+ if (!provider) {
743
+ console.error('error: <provider> positional (apns/fcm/webpush/hcm/mipush) is required')
744
+ return 2
745
+ }
746
+ const cfg = parseAdminCfg(parsed.values)
747
+ if (!cfg) return 2
748
+ if (!parsed.values.config || !parsed.values.secret) {
749
+ console.error('error: --config @file.json and --secret @file.json are both required')
750
+ return 2
751
+ }
752
+ try {
753
+ const config = parseJsonArg(parsed.values.config as string, '--config')
754
+ const secret = parseJsonArg(parsed.values.secret as string, '--secret')
755
+ await pushCredsSet(cfg, provider, config, secret)
756
+ console.log(`${provider} ✓ saved`)
757
+ return 0
758
+ } catch (e) {
759
+ console.error(`push creds set failed: ${(e as Error).message}`)
760
+ return 1
761
+ }
762
+ }
763
+
764
+ async function cmdPushCredsDelete(argv: string[]): Promise<number> {
765
+ const parsed = parseArgs({
766
+ args: argv,
767
+ options: {
768
+ 'api-url': { type: 'string' },
769
+ 'ingest-url': { type: 'string' },
770
+ project: { type: 'string' },
771
+ token: { type: 'string' },
772
+ },
773
+ allowPositionals: true,
774
+ strict: true,
775
+ })
776
+ const provider = parsed.positionals[0]
777
+ if (!provider) {
778
+ console.error('error: <provider> positional is required')
779
+ return 2
780
+ }
781
+ const cfg = parseAdminCfg(parsed.values)
782
+ if (!cfg) return 2
783
+ try {
784
+ await pushCredsDelete(cfg, provider)
785
+ console.log(`${provider} ✓ deleted`)
786
+ return 0
787
+ } catch (e) {
788
+ console.error(`push creds delete failed: ${(e as Error).message}`)
789
+ return 1
790
+ }
791
+ }
792
+
793
+ main(process.argv.slice(2)).then(
794
+ (code) => process.exit(code),
795
+ (e: unknown) => {
796
+ console.error(`fatal: ${(e as Error).message}`)
797
+ process.exit(1)
798
+ },
799
+ )