@goliapkg/sentori-cli 0.5.3 → 1.0.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 (58) 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 +723 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/issue.d.ts +26 -0
  7. package/lib/issue.d.ts.map +1 -0
  8. package/lib/issue.js +50 -0
  9. package/lib/issue.js.map +1 -0
  10. package/lib/lenient.d.ts +13 -0
  11. package/lib/lenient.d.ts.map +1 -0
  12. package/lib/lenient.js +25 -0
  13. package/lib/lenient.js.map +1 -0
  14. package/lib/mcp.d.ts +16 -0
  15. package/lib/mcp.d.ts.map +1 -0
  16. package/lib/mcp.js +194 -0
  17. package/lib/mcp.js.map +1 -0
  18. package/lib/native-artifacts.d.ts +42 -0
  19. package/lib/native-artifacts.d.ts.map +1 -0
  20. package/lib/native-artifacts.js +137 -0
  21. package/lib/native-artifacts.js.map +1 -0
  22. package/lib/probes.d.ts +10 -0
  23. package/lib/probes.d.ts.map +1 -0
  24. package/lib/probes.js +75 -0
  25. package/lib/probes.js.map +1 -0
  26. package/lib/push.d.ts +42 -0
  27. package/lib/push.d.ts.map +1 -0
  28. package/lib/push.js +92 -0
  29. package/lib/push.js.map +1 -0
  30. package/lib/react-native.d.ts +25 -0
  31. package/lib/react-native.d.ts.map +1 -0
  32. package/lib/react-native.js +82 -0
  33. package/lib/react-native.js.map +1 -0
  34. package/lib/source-bundle.d.ts +28 -0
  35. package/lib/source-bundle.d.ts.map +1 -0
  36. package/lib/source-bundle.js +193 -0
  37. package/lib/source-bundle.js.map +1 -0
  38. package/lib/upload.d.ts +13 -0
  39. package/lib/upload.d.ts.map +1 -0
  40. package/lib/upload.js +28 -0
  41. package/lib/upload.js.map +1 -0
  42. package/package.json +23 -13
  43. package/src/__tests__/lenient.test.ts +32 -0
  44. package/src/__tests__/mcp.test.ts +32 -0
  45. package/src/__tests__/native-artifacts.test.ts +125 -0
  46. package/src/__tests__/probes.test.ts +41 -0
  47. package/src/__tests__/react-native.test.ts +37 -0
  48. package/src/index.ts +727 -0
  49. package/src/issue.ts +82 -0
  50. package/src/lenient.ts +39 -0
  51. package/src/mcp.ts +230 -0
  52. package/src/native-artifacts.ts +175 -0
  53. package/src/probes.ts +75 -0
  54. package/src/push.ts +174 -0
  55. package/src/react-native.ts +94 -0
  56. package/src/upload.ts +44 -0
  57. package/bin/sentori-cli.js +0 -32
  58. package/scripts/postinstall.js +0 -90
package/src/index.ts ADDED
@@ -0,0 +1,727 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+
4
+ import {
5
+ fetchBundle,
6
+ formatIssueLine,
7
+ listIssues,
8
+ noteIssue,
9
+ resolveIssue,
10
+ } from './issue.js'
11
+ import { isStrict, lenientFail, stripStrict } from './lenient.js'
12
+ import { runMcpServer } from './mcp.js'
13
+ import { uploadDsym, uploadMapping } from './native-artifacts.js'
14
+ import { scanProbes, syncProbes } from './probes.js'
15
+ import {
16
+ parseJsonArg,
17
+ pushCredsDelete,
18
+ pushCredsList,
19
+ pushCredsSet,
20
+ pushReceipt,
21
+ pushSend,
22
+ } from './push.js'
23
+ import { reactNativeUpload } from './react-native.js'
24
+ import { uploadArtifact } from './upload.js'
25
+
26
+ const HELP = `sentori-cli — Sentori command-line interface
27
+
28
+ Symbolication artifacts (api-scope token; failures NEVER block your
29
+ build — exit 0 with a friendly note unless --strict):
30
+ sentori-cli upload sourcemap --release <r> --token <t> <path...>
31
+ sentori-cli upload dsym --release <r> --token <t> <path.dSYM>
32
+ sentori-cli upload mapping --release <r> --token <t> mapping.txt
33
+ sentori-cli react-native upload --release <r> --token <t> \\
34
+ --metro-map <m> --hermes-map <h> [--bundle <b>]
35
+
36
+ Regression tripwires (design: probes):
37
+ sentori-cli probes sync --release <r> --token <t> [--dir .]
38
+ Statically scans source for sentori.probe('REF') call sites and
39
+ registers them, so a silent probe is visibly alive.
40
+
41
+ CI triage (the same /api surface an AI agent uses):
42
+ sentori-cli issue list [--status open] [--kind error]
43
+ sentori-cli issue resolve <issue-id> [--in-release <r>]
44
+ sentori-cli issue note <issue-id> --body "fixed in abc123"
45
+ sentori-cli issue bundle <issue-id>
46
+
47
+ MCP (for Claude Code and friends):
48
+ sentori-cli mcp serve --token <api-token> [--api-url <url>]
49
+
50
+ Push (carried):
51
+ sentori-cli push send / receipt / creds ...
52
+
53
+ Common options:
54
+ --token api-scope token (or $SENTORI_TOKEN)
55
+ --api-url instance URL (or $SENTORI_API_URL; default https://sentori.golia.jp)
56
+ --strict upload commands: exit non-zero on failure
57
+ `
58
+
59
+ type Common = { apiUrl: string; release: string; token: string }
60
+
61
+ function parseCommon(values: Record<string, unknown>): Common | null {
62
+ const release = typeof values.release === 'string' ? values.release : undefined
63
+ if (!release) {
64
+ console.error("error: --release is required (must match the SDK's init({ release }))")
65
+ return null
66
+ }
67
+ const token =
68
+ (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
69
+ if (!token) {
70
+ console.error('error: --token (or $SENTORI_TOKEN) is required')
71
+ return null
72
+ }
73
+ const apiUrl =
74
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
75
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
76
+ process.env.SENTORI_API_URL ??
77
+ 'https://sentori.golia.jp'
78
+ return { apiUrl, release, token }
79
+ }
80
+
81
+ type ApiOnly = { apiUrl: string; token: string }
82
+
83
+ function parseApiCfg(values: Record<string, unknown>): ApiOnly | null {
84
+ const token =
85
+ (typeof values.token === 'string' ? values.token : undefined) ??
86
+ process.env.SENTORI_ADMIN_TOKEN ??
87
+ process.env.SENTORI_TOKEN
88
+ if (!token) {
89
+ console.error('error: --token (or $SENTORI_TOKEN) is required')
90
+ return null
91
+ }
92
+ const apiUrl =
93
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
94
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
95
+ process.env.SENTORI_API_URL ??
96
+ 'https://sentori.golia.jp'
97
+ return { apiUrl, token }
98
+ }
99
+
100
+ // Kept for the push commands, which carried over unchanged and only
101
+ // need url+token (projectId rides in their own paths).
102
+ type AdminCfg = { apiUrl: string; projectId: string; token: string }
103
+
104
+ function parseAdminCfg(values: Record<string, unknown>): AdminCfg | null {
105
+ const projectId =
106
+ (typeof values.project === 'string' ? values.project : undefined) ??
107
+ process.env.SENTORI_PROJECT_ID
108
+ if (!projectId) {
109
+ console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required')
110
+ return null
111
+ }
112
+ const api = parseApiCfg(values)
113
+ if (!api) return null
114
+ return { apiUrl: api.apiUrl, projectId, token: api.token }
115
+ }
116
+
117
+ const UPLOAD_OPTS = {
118
+ 'api-url': { type: 'string' },
119
+ help: { short: 'h', type: 'boolean' },
120
+ 'ingest-url': { type: 'string' },
121
+ release: { type: 'string' },
122
+ token: { type: 'string' },
123
+ } as const
124
+
125
+ // ── upload commands (lenient by contract) ─────────────────────────
126
+
127
+ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
128
+ const strict = isStrict(argv)
129
+ let parsed
130
+ try {
131
+ parsed = parseArgs({ allowPositionals: true, args: stripStrict(argv), options: UPLOAD_OPTS })
132
+ } catch (e) {
133
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
134
+ return 2
135
+ }
136
+ if (parsed.values.help) {
137
+ console.log(HELP)
138
+ return 0
139
+ }
140
+ const c = parseCommon(parsed.values)
141
+ if (!c) return 2
142
+ if (parsed.positionals.length === 0) {
143
+ console.error('error: at least one sourcemap path is required')
144
+ return 2
145
+ }
146
+ try {
147
+ for (const p of parsed.positionals) {
148
+ await uploadArtifact({ ...c, kind: 'sourcemap', path: p })
149
+ }
150
+ console.log(
151
+ `uploaded ${parsed.positionals.length} sourcemap(s) for "${c.release}" — minified stacks on this release now resolve to source.`,
152
+ )
153
+ return 0
154
+ } catch (e) {
155
+ return lenientFail(strict, {
156
+ failure: `sourcemap upload failed (${(e as Error).message})`,
157
+ impact: `crashes from ${c.release} will show minified stacks until the map is uploaded.`,
158
+ retry: `sentori-cli upload sourcemap --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
159
+ })
160
+ }
161
+ }
162
+
163
+ async function cmdUploadDsym(argv: string[]): Promise<number> {
164
+ const strict = isStrict(argv)
165
+ let parsed
166
+ try {
167
+ parsed = parseArgs({
168
+ allowPositionals: true,
169
+ args: stripStrict(argv),
170
+ options: {
171
+ ...UPLOAD_OPTS,
172
+ arch: { type: 'string' },
173
+ 'debug-id': { type: 'string' },
174
+ 'object-name': { type: 'string' },
175
+ },
176
+ })
177
+ } catch (e) {
178
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
179
+ return 2
180
+ }
181
+ if (parsed.values.help) {
182
+ console.log(HELP)
183
+ return 0
184
+ }
185
+ const c = parseCommon(parsed.values)
186
+ if (!c) return 2
187
+ const path = parsed.positionals[0]
188
+ if (!path) {
189
+ console.error('error: a path to a .dSYM bundle or DWARF binary is required')
190
+ return 2
191
+ }
192
+ const debugId = parsed.values['debug-id']
193
+ const arch = parsed.values.arch
194
+ if ((debugId && !arch) || (arch && !debugId)) {
195
+ console.error('error: --debug-id and --arch must be passed together (or both omitted)')
196
+ return 2
197
+ }
198
+ try {
199
+ const r = await uploadDsym({
200
+ apiUrl: c.apiUrl,
201
+ arch: typeof arch === 'string' ? arch : undefined,
202
+ debugId: typeof debugId === 'string' ? debugId : undefined,
203
+ objectName:
204
+ typeof parsed.values['object-name'] === 'string'
205
+ ? parsed.values['object-name']
206
+ : undefined,
207
+ path,
208
+ release: c.release,
209
+ token: c.token,
210
+ })
211
+ console.log(`uploaded ${r.slices.length} dSYM slice(s):`)
212
+ for (const s of r.slices) console.log(` ${s.debugId} (${s.arch})`)
213
+ return 0
214
+ } catch (e) {
215
+ return lenientFail(strict, {
216
+ failure: `dSYM upload failed (${(e as Error).message})`,
217
+ impact: `native iOS stacks from ${c.release} stay unsymbolicated until the dSYM lands.`,
218
+ retry: `sentori-cli upload dsym --release "${c.release}" --token <t> ${path}`,
219
+ })
220
+ }
221
+ }
222
+
223
+ async function cmdUploadMapping(argv: string[]): Promise<number> {
224
+ const strict = isStrict(argv)
225
+ let parsed
226
+ try {
227
+ parsed = parseArgs({
228
+ allowPositionals: true,
229
+ args: stripStrict(argv),
230
+ options: { ...UPLOAD_OPTS, 'debug-id': { type: 'string' } },
231
+ })
232
+ } catch (e) {
233
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
234
+ return 2
235
+ }
236
+ if (parsed.values.help) {
237
+ console.log(HELP)
238
+ return 0
239
+ }
240
+ const c = parseCommon(parsed.values)
241
+ if (!c) return 2
242
+ const path = parsed.positionals[0]
243
+ if (!path) {
244
+ console.error('error: a path to mapping.txt is required')
245
+ return 2
246
+ }
247
+ try {
248
+ await uploadMapping({
249
+ apiUrl: c.apiUrl,
250
+ debugId:
251
+ typeof parsed.values['debug-id'] === 'string' ? parsed.values['debug-id'] : undefined,
252
+ path,
253
+ release: c.release,
254
+ token: c.token,
255
+ })
256
+ console.log(`uploaded mapping for "${c.release}" — R8 names on this release now demangle.`)
257
+ return 0
258
+ } catch (e) {
259
+ return lenientFail(strict, {
260
+ failure: `mapping upload failed (${(e as Error).message})`,
261
+ impact: `Android stacks from ${c.release} stay R8-obfuscated until the mapping lands.`,
262
+ retry: `sentori-cli upload mapping --release "${c.release}" --token <t> ${path}`,
263
+ })
264
+ }
265
+ }
266
+
267
+ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
268
+ const strict = isStrict(argv)
269
+ let parsed
270
+ try {
271
+ parsed = parseArgs({
272
+ args: stripStrict(argv),
273
+ options: {
274
+ ...UPLOAD_OPTS,
275
+ bundle: { type: 'string' },
276
+ 'dry-run': { type: 'boolean' },
277
+ 'hermes-map': { type: 'string' },
278
+ 'metro-map': { type: 'string' },
279
+ },
280
+ })
281
+ } catch (e) {
282
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
283
+ return 2
284
+ }
285
+ if (parsed.values.help) {
286
+ console.log(HELP)
287
+ return 0
288
+ }
289
+ const c = parseCommon(parsed.values)
290
+ if (!c) return 2
291
+ const metroMap = parsed.values['metro-map']
292
+ const hermesMap = parsed.values['hermes-map']
293
+ if (typeof metroMap !== 'string' || typeof hermesMap !== 'string') {
294
+ console.error('error: --metro-map and --hermes-map are both required')
295
+ return 2
296
+ }
297
+ try {
298
+ const result = await reactNativeUpload({
299
+ apiUrl: c.apiUrl,
300
+ bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
301
+ dryRun: parsed.values['dry-run'] === true,
302
+ hermesMap,
303
+ metroMap,
304
+ release: c.release,
305
+ token: c.token,
306
+ })
307
+ console.log(`uploaded ${result.uploaded ?? result.files.length} file(s) for "${c.release}".`)
308
+ return 0
309
+ } catch (e) {
310
+ return lenientFail(strict, {
311
+ failure: `react-native upload failed (${(e as Error).message})`,
312
+ impact: `Hermes stacks from ${c.release} stay unsymbolicated until the composed map lands.`,
313
+ retry: `sentori-cli react-native upload --release "${c.release}" --token <t> --metro-map ${metroMap} --hermes-map ${hermesMap}`,
314
+ })
315
+ }
316
+ }
317
+
318
+ // ── probes sync ───────────────────────────────────────────────────
319
+
320
+ async function cmdProbesSync(argv: string[]): Promise<number> {
321
+ const strict = isStrict(argv)
322
+ let parsed
323
+ try {
324
+ parsed = parseArgs({
325
+ args: stripStrict(argv),
326
+ options: { ...UPLOAD_OPTS, dir: { type: 'string' } },
327
+ })
328
+ } catch (e) {
329
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
330
+ return 2
331
+ }
332
+ if (parsed.values.help) {
333
+ console.log(HELP)
334
+ return 0
335
+ }
336
+ const c = parseCommon(parsed.values)
337
+ if (!c) return 2
338
+ const dir = typeof parsed.values.dir === 'string' ? parsed.values.dir : '.'
339
+ const refs = scanProbes(dir)
340
+ if (refs.length === 0) {
341
+ console.log(`no sentori.probe() call sites found under ${dir} — nothing to register.`)
342
+ return 0
343
+ }
344
+ try {
345
+ const r = await syncProbes({ apiUrl: c.apiUrl, token: c.token, release: c.release, refs })
346
+ console.log(`registered ${r.registered} probe(s) for "${c.release}": ${refs.join(', ')}`)
347
+ return 0
348
+ } catch (e) {
349
+ return lenientFail(strict, {
350
+ failure: `probes sync failed (${(e as Error).message})`,
351
+ impact: `silent probes on ${c.release} can't be told apart from deleted code until registered.`,
352
+ retry: `sentori-cli probes sync --release "${c.release}" --token <t> --dir ${dir}`,
353
+ })
354
+ }
355
+ }
356
+
357
+ // ── issue commands (the /api surface) ─────────────────────────────
358
+
359
+ const ISSUE_OPTS = {
360
+ 'api-url': { type: 'string' },
361
+ help: { short: 'h', type: 'boolean' },
362
+ 'ingest-url': { type: 'string' },
363
+ token: { type: 'string' },
364
+ } as const
365
+
366
+ async function cmdIssueList(argv: string[]): Promise<number> {
367
+ let parsed
368
+ try {
369
+ parsed = parseArgs({
370
+ args: argv,
371
+ options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' } },
372
+ })
373
+ } catch (e) {
374
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
375
+ return 2
376
+ }
377
+ if (parsed.values.help) {
378
+ console.log(HELP)
379
+ return 0
380
+ }
381
+ const cfg = parseApiCfg(parsed.values)
382
+ if (!cfg) return 2
383
+ try {
384
+ const rows = await listIssues(cfg, {
385
+ kind: parsed.values.kind as string | undefined,
386
+ status: (parsed.values.status as string | undefined) ?? 'open',
387
+ })
388
+ if (rows.length === 0) {
389
+ console.log('(no matching issues)')
390
+ return 0
391
+ }
392
+ for (const r of rows) console.log(formatIssueLine(r))
393
+ return 0
394
+ } catch (e) {
395
+ console.error(`issue list failed: ${(e as Error).message}`)
396
+ return 1
397
+ }
398
+ }
399
+
400
+ async function cmdIssueResolve(argv: string[]): Promise<number> {
401
+ let parsed
402
+ try {
403
+ parsed = parseArgs({
404
+ allowPositionals: true,
405
+ args: argv,
406
+ options: { ...ISSUE_OPTS, 'in-release': { type: 'string' } },
407
+ })
408
+ } catch (e) {
409
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
410
+ return 2
411
+ }
412
+ if (parsed.values.help) {
413
+ console.log(HELP)
414
+ return 0
415
+ }
416
+ const cfg = parseApiCfg(parsed.values)
417
+ if (!cfg) return 2
418
+ const issueId = parsed.positionals[0]
419
+ if (!issueId) {
420
+ console.error('error: <issue-id> is required')
421
+ return 2
422
+ }
423
+ try {
424
+ await resolveIssue(cfg, issueId, parsed.values['in-release'] as string | undefined)
425
+ console.log(`${issueId} → resolved${parsed.values['in-release'] ? ` (in ${parsed.values['in-release']})` : ''}`)
426
+ return 0
427
+ } catch (e) {
428
+ console.error(`issue resolve failed: ${(e as Error).message}`)
429
+ return 1
430
+ }
431
+ }
432
+
433
+ async function cmdIssueNote(argv: string[]): Promise<number> {
434
+ let parsed
435
+ try {
436
+ parsed = parseArgs({
437
+ allowPositionals: true,
438
+ args: argv,
439
+ options: { ...ISSUE_OPTS, body: { type: 'string' } },
440
+ })
441
+ } catch (e) {
442
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
443
+ return 2
444
+ }
445
+ if (parsed.values.help) {
446
+ console.log(HELP)
447
+ return 0
448
+ }
449
+ const cfg = parseApiCfg(parsed.values)
450
+ if (!cfg) return 2
451
+ const issueId = parsed.positionals[0]
452
+ const body = parsed.values.body as string | undefined
453
+ if (!issueId || !body) {
454
+ console.error('error: <issue-id> and --body are required')
455
+ return 2
456
+ }
457
+ try {
458
+ await noteIssue(cfg, issueId, body)
459
+ console.log(`${issueId} ← note added`)
460
+ return 0
461
+ } catch (e) {
462
+ console.error(`issue note failed: ${(e as Error).message}`)
463
+ return 1
464
+ }
465
+ }
466
+
467
+ async function cmdIssueBundle(argv: string[]): Promise<number> {
468
+ let parsed
469
+ try {
470
+ parsed = parseArgs({ allowPositionals: true, args: argv, options: ISSUE_OPTS })
471
+ } catch (e) {
472
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
473
+ return 2
474
+ }
475
+ if (parsed.values.help) {
476
+ console.log(HELP)
477
+ return 0
478
+ }
479
+ const cfg = parseApiCfg(parsed.values)
480
+ if (!cfg) return 2
481
+ const issueId = parsed.positionals[0]
482
+ if (!issueId) {
483
+ console.error('error: <issue-id> is required')
484
+ return 2
485
+ }
486
+ try {
487
+ console.log(await fetchBundle(cfg, issueId))
488
+ return 0
489
+ } catch (e) {
490
+ console.error(`issue bundle failed: ${(e as Error).message}`)
491
+ return 1
492
+ }
493
+ }
494
+
495
+ // ── mcp ───────────────────────────────────────────────────────────
496
+
497
+ async function cmdMcpServe(argv: string[]): Promise<number> {
498
+ let parsed
499
+ try {
500
+ parsed = parseArgs({ args: argv, options: ISSUE_OPTS })
501
+ } catch (e) {
502
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
503
+ return 2
504
+ }
505
+ if (parsed.values.help) {
506
+ console.log(HELP)
507
+ return 0
508
+ }
509
+ const cfg = parseApiCfg(parsed.values)
510
+ if (!cfg) return 2
511
+ try {
512
+ await runMcpServer({ apiUrl: cfg.apiUrl, token: cfg.token })
513
+ return 0
514
+ } catch (e) {
515
+ console.error(`mcp serve failed: ${(e as Error).message}`)
516
+ return 1
517
+ }
518
+ }
519
+
520
+ async function main(argv: string[]): Promise<number> {
521
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
522
+ console.log(HELP)
523
+ return 0
524
+ }
525
+ const [a, b, ...rest] = argv
526
+ if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
527
+ if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
528
+ if (a === 'upload' && b === 'mapping') return cmdUploadMapping(rest)
529
+ if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
530
+ if (a === 'probes' && b === 'sync') return cmdProbesSync(rest)
531
+ if (a === 'mcp' && b === 'serve') return cmdMcpServe(rest)
532
+ if (a === 'issue' && b === 'list') return cmdIssueList(rest)
533
+ if (a === 'issue' && b === 'resolve') return cmdIssueResolve(rest)
534
+ if (a === 'issue' && b === 'note') return cmdIssueNote(rest)
535
+ if (a === 'issue' && b === 'bundle') return cmdIssueBundle(rest)
536
+ if (a === 'push' && b === 'send') return cmdPushSend(rest)
537
+ if (a === 'push' && b === 'receipt') return cmdPushReceipt(rest)
538
+ if (a === 'push' && b === 'creds') {
539
+ const [c, ...rest2] = rest
540
+ if (c === 'list') return cmdPushCredsList(rest2)
541
+ if (c === 'set') return cmdPushCredsSet(rest2)
542
+ if (c === 'delete') return cmdPushCredsDelete(rest2)
543
+ }
544
+ console.error(`unknown command: ${[a, b].filter(Boolean).join(' ') || '(none)'}\n`)
545
+ console.error(HELP)
546
+ return 2
547
+ }
548
+
549
+ // ── push commands (v2.12) ─────────────────────────────────────────
550
+
551
+ async function cmdPushSend(argv: string[]): Promise<number> {
552
+ const parsed = parseArgs({
553
+ args: argv,
554
+ options: {
555
+ 'api-url': { type: 'string' },
556
+ body: { type: 'string' },
557
+ data: { type: 'string' },
558
+ 'idempotency-key': { type: 'string' },
559
+ 'ingest-url': { type: 'string' },
560
+ priority: { type: 'string' },
561
+ project: { type: 'string' },
562
+ title: { type: 'string' },
563
+ to: { type: 'string' },
564
+ token: { type: 'string' },
565
+ ttl: { type: 'string' },
566
+ },
567
+ strict: true,
568
+ })
569
+ const cfg = parseAdminCfg(parsed.values)
570
+ if (!cfg) return 2
571
+ const to = parsed.values.to as string | undefined
572
+ if (!to) {
573
+ console.error('error: --to <ipt_handle> is required')
574
+ return 2
575
+ }
576
+ try {
577
+ const data = parsed.values.data ? (parseJsonArg(parsed.values.data as string, '--data') as Record<string, unknown>) : undefined
578
+ const priority = parsed.values.priority as 'high' | 'normal' | undefined
579
+ const ticket = await pushSend(cfg, {
580
+ to,
581
+ title: parsed.values.title as string | undefined,
582
+ body: parsed.values.body as string | undefined,
583
+ data,
584
+ priority,
585
+ ttl: parsed.values.ttl ? Number(parsed.values.ttl) : undefined,
586
+ idempotencyKey: parsed.values['idempotency-key'] as string | undefined,
587
+ })
588
+ console.log(`${ticket.id} ${ticket.status}`)
589
+ return 0
590
+ } catch (e) {
591
+ console.error(`push send failed: ${(e as Error).message}`)
592
+ return 1
593
+ }
594
+ }
595
+
596
+ async function cmdPushReceipt(argv: string[]): Promise<number> {
597
+ const parsed = parseArgs({
598
+ args: argv,
599
+ options: {
600
+ 'api-url': { type: 'string' },
601
+ 'ingest-url': { type: 'string' },
602
+ project: { type: 'string' },
603
+ token: { type: 'string' },
604
+ },
605
+ allowPositionals: true,
606
+ strict: true,
607
+ })
608
+ const sendId = parsed.positionals[0]
609
+ if (!sendId) {
610
+ console.error('error: <send-id> positional is required')
611
+ return 2
612
+ }
613
+ const cfg = parseAdminCfg(parsed.values)
614
+ if (!cfg) return 2
615
+ try {
616
+ const r = await pushReceipt(cfg, sendId)
617
+ console.log(`${r.ticket.id} ${r.ticket.status}${r.ticket.providerOutcome ? ` (${r.ticket.providerOutcome})` : ''}${r.ticket.error ? ` — ${r.ticket.error}` : ''}`)
618
+ return 0
619
+ } catch (e) {
620
+ console.error(`push receipt failed: ${(e as Error).message}`)
621
+ return 1
622
+ }
623
+ }
624
+
625
+ async function cmdPushCredsList(argv: string[]): Promise<number> {
626
+ const parsed = parseArgs({
627
+ args: argv,
628
+ options: {
629
+ 'api-url': { type: 'string' },
630
+ 'ingest-url': { type: 'string' },
631
+ project: { type: 'string' },
632
+ token: { type: 'string' },
633
+ },
634
+ strict: true,
635
+ })
636
+ const cfg = parseAdminCfg(parsed.values)
637
+ if (!cfg) return 2
638
+ try {
639
+ const rows = await pushCredsList(cfg)
640
+ if (rows.length === 0) {
641
+ console.log('(no providers configured)')
642
+ return 0
643
+ }
644
+ for (const r of rows) {
645
+ console.log(`${r.provider}\t${r.updatedAt}\t${JSON.stringify(r.config)}`)
646
+ }
647
+ return 0
648
+ } catch (e) {
649
+ console.error(`push creds list failed: ${(e as Error).message}`)
650
+ return 1
651
+ }
652
+ }
653
+
654
+ async function cmdPushCredsSet(argv: string[]): Promise<number> {
655
+ const parsed = parseArgs({
656
+ args: argv,
657
+ options: {
658
+ 'api-url': { type: 'string' },
659
+ config: { type: 'string' },
660
+ 'ingest-url': { type: 'string' },
661
+ project: { type: 'string' },
662
+ secret: { type: 'string' },
663
+ token: { type: 'string' },
664
+ },
665
+ allowPositionals: true,
666
+ strict: true,
667
+ })
668
+ const provider = parsed.positionals[0]
669
+ if (!provider) {
670
+ console.error('error: <provider> positional (apns/fcm/webpush/hcm/mipush) is required')
671
+ return 2
672
+ }
673
+ const cfg = parseAdminCfg(parsed.values)
674
+ if (!cfg) return 2
675
+ if (!parsed.values.config || !parsed.values.secret) {
676
+ console.error('error: --config @file.json and --secret @file.json are both required')
677
+ return 2
678
+ }
679
+ try {
680
+ const config = parseJsonArg(parsed.values.config as string, '--config')
681
+ const secret = parseJsonArg(parsed.values.secret as string, '--secret')
682
+ await pushCredsSet(cfg, provider, config, secret)
683
+ console.log(`${provider} ✓ saved`)
684
+ return 0
685
+ } catch (e) {
686
+ console.error(`push creds set failed: ${(e as Error).message}`)
687
+ return 1
688
+ }
689
+ }
690
+
691
+ async function cmdPushCredsDelete(argv: string[]): Promise<number> {
692
+ const parsed = parseArgs({
693
+ args: argv,
694
+ options: {
695
+ 'api-url': { type: 'string' },
696
+ 'ingest-url': { type: 'string' },
697
+ project: { type: 'string' },
698
+ token: { type: 'string' },
699
+ },
700
+ allowPositionals: true,
701
+ strict: true,
702
+ })
703
+ const provider = parsed.positionals[0]
704
+ if (!provider) {
705
+ console.error('error: <provider> positional is required')
706
+ return 2
707
+ }
708
+ const cfg = parseAdminCfg(parsed.values)
709
+ if (!cfg) return 2
710
+ try {
711
+ await pushCredsDelete(cfg, provider)
712
+ console.log(`${provider} ✓ deleted`)
713
+ return 0
714
+ } catch (e) {
715
+ console.error(`push creds delete failed: ${(e as Error).message}`)
716
+ return 1
717
+ }
718
+ }
719
+
720
+
721
+ main(process.argv.slice(2)).then(
722
+ (code) => process.exit(code),
723
+ (e: unknown) => {
724
+ console.error(`fatal: ${(e as Error).message}`)
725
+ process.exit(1)
726
+ },
727
+ )