@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.
- package/README.md +27 -32
- package/lib/index.d.ts +3 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +723 -0
- package/lib/index.js.map +1 -0
- package/lib/issue.d.ts +26 -0
- package/lib/issue.d.ts.map +1 -0
- package/lib/issue.js +50 -0
- package/lib/issue.js.map +1 -0
- package/lib/lenient.d.ts +13 -0
- package/lib/lenient.d.ts.map +1 -0
- package/lib/lenient.js +25 -0
- package/lib/lenient.js.map +1 -0
- package/lib/mcp.d.ts +16 -0
- package/lib/mcp.d.ts.map +1 -0
- package/lib/mcp.js +194 -0
- package/lib/mcp.js.map +1 -0
- package/lib/native-artifacts.d.ts +42 -0
- package/lib/native-artifacts.d.ts.map +1 -0
- package/lib/native-artifacts.js +137 -0
- package/lib/native-artifacts.js.map +1 -0
- package/lib/probes.d.ts +10 -0
- package/lib/probes.d.ts.map +1 -0
- package/lib/probes.js +75 -0
- package/lib/probes.js.map +1 -0
- package/lib/push.d.ts +42 -0
- package/lib/push.d.ts.map +1 -0
- package/lib/push.js +92 -0
- package/lib/push.js.map +1 -0
- package/lib/react-native.d.ts +25 -0
- package/lib/react-native.d.ts.map +1 -0
- package/lib/react-native.js +82 -0
- package/lib/react-native.js.map +1 -0
- package/lib/source-bundle.d.ts +28 -0
- package/lib/source-bundle.d.ts.map +1 -0
- package/lib/source-bundle.js +193 -0
- package/lib/source-bundle.js.map +1 -0
- package/lib/upload.d.ts +13 -0
- package/lib/upload.d.ts.map +1 -0
- package/lib/upload.js +28 -0
- package/lib/upload.js.map +1 -0
- package/package.json +23 -13
- package/src/__tests__/lenient.test.ts +32 -0
- package/src/__tests__/mcp.test.ts +32 -0
- package/src/__tests__/native-artifacts.test.ts +125 -0
- package/src/__tests__/probes.test.ts +41 -0
- package/src/__tests__/react-native.test.ts +37 -0
- package/src/index.ts +727 -0
- package/src/issue.ts +82 -0
- package/src/lenient.ts +39 -0
- package/src/mcp.ts +230 -0
- package/src/native-artifacts.ts +175 -0
- package/src/probes.ts +75 -0
- package/src/push.ts +174 -0
- package/src/react-native.ts +94 -0
- package/src/upload.ts +44 -0
- package/bin/sentori-cli.js +0 -32
- 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
|
+
)
|