@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/index.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util'
3
3
 
4
- import { formatIssueLine, issueList, issuePatch } from './issue.js'
4
+ import {
5
+ fetchBundle,
6
+ formatIssueLine,
7
+ listIssues,
8
+ noteIssue,
9
+ resolveIssue,
10
+ } from './issue.js'
11
+ import { isStrict, lenientFail, stripStrict } from './lenient.js'
5
12
  import { runMcpServer } from './mcp.js'
6
13
  import { uploadDsym, uploadMapping } from './native-artifacts.js'
14
+ import { scanProbes, syncProbes } from './probes.js'
7
15
  import {
8
16
  parseJsonArg,
9
17
  pushCredsDelete,
@@ -13,123 +21,53 @@ import {
13
21
  pushSend,
14
22
  } from './push.js'
15
23
  import { reactNativeUpload } from './react-native.js'
16
- import { uploadSourceBundle } from './source-bundle.js'
17
- import { uploadSourcemaps } from './upload.js'
24
+ import { uploadArtifact } from './upload.js'
18
25
 
19
26
  const HELP = `sentori-cli — Sentori command-line interface
20
27
 
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"
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
+ (mapping = the R8/proguard map; stored as kind "proguard")
34
+ sentori-cli react-native upload --release <r> --token <t> \\
35
+ --metro-map <m> --hermes-map <h> [--bundle <b>]
36
+
37
+ Regression tripwires (design: probes):
38
+ sentori-cli probes sync --release <r> --token <t> [--dir .]
39
+ Statically scans source for sentori.probe('REF') call sites and
40
+ registers them, so a silent probe is visibly alive.
41
+
42
+ CI triage (the same /api surface an AI agent uses):
43
+ sentori-cli issue list [--status open] [--kind error]
44
+ sentori-cli issue resolve <issue-id> [--in-release <r>]
45
+ sentori-cli issue note <issue-id> --body "fixed in abc123"
46
+ sentori-cli issue bundle <issue-id>
47
+
48
+ MCP (for Claude Code and friends):
49
+ sentori-cli mcp serve --token <api-token> [--api-url <url>]
50
+
51
+ Push (carried):
52
+ sentori-cli push send / receipt / creds ...
53
+
54
+ Common options:
55
+ --token api-scope token (or $SENTORI_TOKEN)
56
+ --api-url instance URL (or $SENTORI_API_URL; default https://sentori.golia.jp)
57
+ --strict upload commands: exit non-zero on failure
118
58
  `
119
59
 
120
- type Common = { apiUrl: string; dryRun: boolean; release: string; token: string }
60
+ type Common = { apiUrl: string; release: string; token: string }
121
61
 
122
- /** Parse the shared options, or print an error + return null. */
123
62
  function parseCommon(values: Record<string, unknown>): Common | null {
124
63
  const release = typeof values.release === 'string' ? values.release : undefined
125
64
  if (!release) {
126
- console.error('error: --release is required (must match the SDKs init({ release }))')
65
+ console.error("error: --release is required (must match the SDK's init({ release }))")
127
66
  return null
128
67
  }
129
- const dryRun = values['dry-run'] === true
130
68
  const token =
131
69
  (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
132
- if (!token && !dryRun) {
70
+ if (!token) {
133
71
  console.error('error: --token (or $SENTORI_TOKEN) is required')
134
72
  return null
135
73
  }
@@ -138,27 +76,107 @@ function parseCommon(values: Record<string, unknown>): Common | null {
138
76
  (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
139
77
  process.env.SENTORI_API_URL ??
140
78
  'https://sentori.golia.jp'
141
- return { apiUrl, dryRun, release, token: token ?? '' }
79
+ return { apiUrl, release, token }
142
80
  }
143
81
 
82
+ type ApiOnly = { apiUrl: string; token: string }
83
+
84
+ function parseApiCfg(values: Record<string, unknown>): ApiOnly | null {
85
+ const token =
86
+ (typeof values.token === 'string' ? values.token : undefined) ??
87
+ process.env.SENTORI_ADMIN_TOKEN ??
88
+ process.env.SENTORI_TOKEN
89
+ if (!token) {
90
+ console.error('error: --token (or $SENTORI_TOKEN) is required')
91
+ return null
92
+ }
93
+ const apiUrl =
94
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
95
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
96
+ process.env.SENTORI_API_URL ??
97
+ 'https://sentori.golia.jp'
98
+ return { apiUrl, token }
99
+ }
100
+
101
+ // Kept for the push commands, which carried over unchanged and only
102
+ // need url+token (projectId rides in their own paths).
103
+ type AdminCfg = { apiUrl: string; projectId: string; token: string }
104
+
105
+ function parseAdminCfg(values: Record<string, unknown>): AdminCfg | null {
106
+ const projectId =
107
+ (typeof values.project === 'string' ? values.project : undefined) ??
108
+ process.env.SENTORI_PROJECT_ID
109
+ if (!projectId) {
110
+ console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required')
111
+ return null
112
+ }
113
+ const api = parseApiCfg(values)
114
+ if (!api) return null
115
+ return { apiUrl: api.apiUrl, projectId, token: api.token }
116
+ }
117
+
118
+ const UPLOAD_OPTS = {
119
+ 'api-url': { type: 'string' },
120
+ help: { short: 'h', type: 'boolean' },
121
+ 'ingest-url': { type: 'string' },
122
+ release: { type: 'string' },
123
+ token: { type: 'string' },
124
+ } as const
125
+
126
+ // ── upload commands (lenient by contract) ─────────────────────────
127
+
144
128
  async function cmdUploadSourcemap(argv: string[]): Promise<number> {
129
+ const strict = isStrict(argv)
130
+ let parsed
131
+ try {
132
+ parsed = parseArgs({ allowPositionals: true, args: stripStrict(argv), options: UPLOAD_OPTS })
133
+ } catch (e) {
134
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
135
+ return 2
136
+ }
137
+ if (parsed.values.help) {
138
+ console.log(HELP)
139
+ return 0
140
+ }
141
+ const c = parseCommon(parsed.values)
142
+ if (!c) return 2
143
+ if (parsed.positionals.length === 0) {
144
+ console.error('error: at least one sourcemap path is required')
145
+ return 2
146
+ }
147
+ try {
148
+ for (const p of parsed.positionals) {
149
+ await uploadArtifact({ ...c, kind: 'sourcemap', path: p })
150
+ }
151
+ console.log(
152
+ `uploaded ${parsed.positionals.length} sourcemap(s) for "${c.release}" — minified stacks on this release now resolve to source.`,
153
+ )
154
+ return 0
155
+ } catch (e) {
156
+ return lenientFail(strict, {
157
+ failure: `sourcemap upload failed (${(e as Error).message})`,
158
+ impact: `crashes from ${c.release} will show minified stacks until the map is uploaded.`,
159
+ retry: `sentori-cli upload sourcemap --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
160
+ })
161
+ }
162
+ }
163
+
164
+ async function cmdUploadDsym(argv: string[]): Promise<number> {
165
+ const strict = isStrict(argv)
145
166
  let parsed
146
167
  try {
147
168
  parsed = parseArgs({
148
169
  allowPositionals: true,
149
- args: argv,
170
+ args: stripStrict(argv),
150
171
  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' },
172
+ ...UPLOAD_OPTS,
173
+ arch: { type: 'string' },
174
+ 'debug-id': { type: 'string' },
175
+ 'object-name': { type: 'string' },
157
176
  },
158
177
  })
159
178
  } catch (e) {
160
- console.error(`error: ${(e as Error).message}\n`)
161
- console.error(HELP)
179
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
162
180
  return 2
163
181
  }
164
182
  if (parsed.values.help) {
@@ -167,46 +185,102 @@ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
167
185
  }
168
186
  const c = parseCommon(parsed.values)
169
187
  if (!c) return 2
170
- if (parsed.positionals.length === 0) {
171
- console.error('error: at least one path (file or directory) is required')
188
+ const path = parsed.positionals[0]
189
+ if (!path) {
190
+ console.error('error: a path to a .dSYM bundle or DWARF binary is required')
191
+ return 2
192
+ }
193
+ const debugId = parsed.values['debug-id']
194
+ const arch = parsed.values.arch
195
+ if ((debugId && !arch) || (arch && !debugId)) {
196
+ console.error('error: --debug-id and --arch must be passed together (or both omitted)')
172
197
  return 2
173
198
  }
174
199
  try {
175
- const result = await uploadSourcemaps({
200
+ const r = await uploadDsym({
176
201
  apiUrl: c.apiUrl,
177
- dryRun: c.dryRun,
178
- paths: parsed.positionals,
202
+ arch: typeof arch === 'string' ? arch : undefined,
203
+ debugId: typeof debugId === 'string' ? debugId : undefined,
204
+ objectName:
205
+ typeof parsed.values['object-name'] === 'string'
206
+ ? parsed.values['object-name']
207
+ : undefined,
208
+ path,
179
209
  release: c.release,
180
210
  token: c.token,
181
211
  })
182
- reportUpload(result, c)
212
+ console.log(`uploaded ${r.slices.length} dSYM slice(s):`)
213
+ for (const s of r.slices) console.log(` ${s.debugId} (${s.arch})`)
183
214
  return 0
184
215
  } catch (e) {
185
- console.error(`upload failed: ${(e as Error).message}`)
186
- return 1
216
+ return lenientFail(strict, {
217
+ failure: `dSYM upload failed (${(e as Error).message})`,
218
+ impact: `native iOS stacks from ${c.release} stay unsymbolicated until the dSYM lands.`,
219
+ retry: `sentori-cli upload dsym --release "${c.release}" --token <t> ${path}`,
220
+ })
221
+ }
222
+ }
223
+
224
+ async function cmdUploadMapping(argv: string[]): Promise<number> {
225
+ const strict = isStrict(argv)
226
+ let parsed
227
+ try {
228
+ parsed = parseArgs({
229
+ allowPositionals: true,
230
+ args: stripStrict(argv),
231
+ options: { ...UPLOAD_OPTS, 'debug-id': { type: 'string' } },
232
+ })
233
+ } catch (e) {
234
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
235
+ return 2
236
+ }
237
+ if (parsed.values.help) {
238
+ console.log(HELP)
239
+ return 0
240
+ }
241
+ const c = parseCommon(parsed.values)
242
+ if (!c) return 2
243
+ const path = parsed.positionals[0]
244
+ if (!path) {
245
+ console.error('error: a path to mapping.txt is required')
246
+ return 2
247
+ }
248
+ try {
249
+ await uploadMapping({
250
+ apiUrl: c.apiUrl,
251
+ debugId:
252
+ typeof parsed.values['debug-id'] === 'string' ? parsed.values['debug-id'] : undefined,
253
+ path,
254
+ release: c.release,
255
+ token: c.token,
256
+ })
257
+ console.log(`uploaded mapping for "${c.release}" — R8 names on this release now demangle.`)
258
+ return 0
259
+ } catch (e) {
260
+ return lenientFail(strict, {
261
+ failure: `mapping upload failed (${(e as Error).message})`,
262
+ impact: `Android stacks from ${c.release} stay R8-obfuscated until the mapping lands.`,
263
+ retry: `sentori-cli upload mapping --release "${c.release}" --token <t> ${path}`,
264
+ })
187
265
  }
188
266
  }
189
267
 
190
268
  async function cmdReactNativeUpload(argv: string[]): Promise<number> {
269
+ const strict = isStrict(argv)
191
270
  let parsed
192
271
  try {
193
272
  parsed = parseArgs({
194
- args: argv,
273
+ args: stripStrict(argv),
195
274
  options: {
196
- 'api-url': { type: 'string' },
275
+ ...UPLOAD_OPTS,
197
276
  bundle: { type: 'string' },
198
277
  'dry-run': { type: 'boolean' },
199
- help: { short: 'h', type: 'boolean' },
200
278
  'hermes-map': { type: 'string' },
201
- 'ingest-url': { type: 'string' },
202
279
  'metro-map': { type: 'string' },
203
- release: { type: 'string' },
204
- token: { type: 'string' },
205
280
  },
206
281
  })
207
282
  } catch (e) {
208
- console.error(`error: ${(e as Error).message}\n`)
209
- console.error(HELP)
283
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
210
284
  return 2
211
285
  }
212
286
  if (parsed.values.help) {
@@ -225,81 +299,32 @@ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
225
299
  const result = await reactNativeUpload({
226
300
  apiUrl: c.apiUrl,
227
301
  bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
228
- dryRun: c.dryRun,
302
+ dryRun: parsed.values['dry-run'] === true,
229
303
  hermesMap,
230
304
  metroMap,
231
305
  release: c.release,
232
306
  token: c.token,
233
307
  })
234
- reportUpload(result, c)
308
+ console.log(`uploaded ${result.uploaded ?? result.files.length} file(s) for "${c.release}".`)
235
309
  return 0
236
310
  } 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
- )
311
+ return lenientFail(strict, {
312
+ failure: `react-native upload failed (${(e as Error).message})`,
313
+ impact: `Hermes stacks from ${c.release} stay unsymbolicated until the composed map lands.`,
314
+ retry: `sentori-cli react-native upload --release "${c.release}" --token <t> --metro-map ${metroMap} --hermes-map ${hermesMap}`,
315
+ })
255
316
  }
256
317
  }
257
318
 
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
- }
319
+ // ── probes sync ───────────────────────────────────────────────────
287
320
 
288
- async function cmdIssueList(argv: string[]): Promise<number> {
321
+ async function cmdProbesSync(argv: string[]): Promise<number> {
322
+ const strict = isStrict(argv)
289
323
  let parsed
290
324
  try {
291
325
  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
- },
326
+ args: stripStrict(argv),
327
+ options: { ...UPLOAD_OPTS, dir: { type: 'string' } },
303
328
  })
304
329
  } catch (e) {
305
330
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -309,52 +334,42 @@ async function cmdIssueList(argv: string[]): Promise<number> {
309
334
  console.log(HELP)
310
335
  return 0
311
336
  }
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
337
+ const c = parseCommon(parsed.values)
338
+ if (!c) return 2
339
+ const dir = typeof parsed.values.dir === 'string' ? parsed.values.dir : '.'
340
+ const refs = scanProbes(dir)
341
+ if (refs.length === 0) {
342
+ console.log(`no sentori.probe() call sites found under ${dir} — nothing to register.`)
343
+ return 0
318
344
  }
319
- const limitStr = parsed.values.limit
320
- const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined
321
345
  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))
346
+ const r = await syncProbes({ apiUrl: c.apiUrl, token: c.token, release: c.release, refs })
347
+ console.log(`registered ${r.registered} probe(s) for "${c.release}": ${refs.join(', ')}`)
333
348
  return 0
334
349
  } catch (e) {
335
- console.error(`issue list failed: ${(e as Error).message}`)
336
- return 1
350
+ return lenientFail(strict, {
351
+ failure: `probes sync failed (${(e as Error).message})`,
352
+ impact: `silent probes on ${c.release} can't be told apart from deleted code until registered.`,
353
+ retry: `sentori-cli probes sync --release "${c.release}" --token <t> --dir ${dir}`,
354
+ })
337
355
  }
338
356
  }
339
357
 
340
- async function cmdIssuePatch(
341
- argv: string[],
342
- body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
343
- verb: 'closed' | 'resolved' | 'silenced',
344
- ): Promise<number> {
358
+ // ── issue commands (the /api surface) ─────────────────────────────
359
+
360
+ const ISSUE_OPTS = {
361
+ 'api-url': { type: 'string' },
362
+ help: { short: 'h', type: 'boolean' },
363
+ 'ingest-url': { type: 'string' },
364
+ token: { type: 'string' },
365
+ } as const
366
+
367
+ async function cmdIssueList(argv: string[]): Promise<number> {
345
368
  let parsed
346
369
  try {
347
370
  parsed = parseArgs({
348
- allowPositionals: true,
349
371
  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
- },
372
+ options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' } },
358
373
  })
359
374
  } catch (e) {
360
375
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -364,47 +379,32 @@ async function cmdIssuePatch(
364
379
  console.log(HELP)
365
380
  return 0
366
381
  }
367
- const cfg = parseAdminCfg(parsed.values)
382
+ const cfg = parseApiCfg(parsed.values)
368
383
  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
384
  try {
378
- const updated = await issuePatch(cfg, issueId, body)
379
- console.log(
380
- `${issueId} → ${verb}${body.resolvedInRelease ? ` (in ${body.resolvedInRelease})` : ''}: ${updated.errorType}`,
381
- )
385
+ const rows = await listIssues(cfg, {
386
+ kind: parsed.values.kind as string | undefined,
387
+ status: (parsed.values.status as string | undefined) ?? 'open',
388
+ })
389
+ if (rows.length === 0) {
390
+ console.log('(no matching issues)')
391
+ return 0
392
+ }
393
+ for (const r of rows) console.log(formatIssueLine(r))
382
394
  return 0
383
395
  } catch (e) {
384
- console.error(`issue ${verb} failed: ${(e as Error).message}`)
396
+ console.error(`issue list failed: ${(e as Error).message}`)
385
397
  return 1
386
398
  }
387
399
  }
388
400
 
389
- // ── native artifact upload ────────────────────────────────────────
390
-
391
- async function cmdUploadDsym(argv: string[]): Promise<number> {
401
+ async function cmdIssueResolve(argv: string[]): Promise<number> {
392
402
  let parsed
393
403
  try {
394
404
  parsed = parseArgs({
395
405
  allowPositionals: true,
396
406
  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
- },
407
+ options: { ...ISSUE_OPTS, 'in-release': { type: 'string' } },
408
408
  })
409
409
  } catch (e) {
410
410
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -414,54 +414,30 @@ async function cmdUploadDsym(argv: string[]): Promise<number> {
414
414
  console.log(HELP)
415
415
  return 0
416
416
  }
417
- const cfg = parseAdminCfg(parsed.values)
417
+ const cfg = parseApiCfg(parsed.values)
418
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)')
419
+ const issueId = parsed.positionals[0]
420
+ if (!issueId) {
421
+ console.error('error: <issue-id> is required')
428
422
  return 2
429
423
  }
430
424
  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})`)
425
+ await resolveIssue(cfg, issueId, parsed.values['in-release'] as string | undefined)
426
+ console.log(`${issueId} → resolved${parsed.values['in-release'] ? ` (in ${parsed.values['in-release']})` : ''}`)
443
427
  return 0
444
428
  } catch (e) {
445
- console.error(`dsym upload failed: ${(e as Error).message}`)
429
+ console.error(`issue resolve failed: ${(e as Error).message}`)
446
430
  return 1
447
431
  }
448
432
  }
449
433
 
450
- async function cmdUploadMapping(argv: string[]): Promise<number> {
434
+ async function cmdIssueNote(argv: string[]): Promise<number> {
451
435
  let parsed
452
436
  try {
453
437
  parsed = parseArgs({
454
438
  allowPositionals: true,
455
439
  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
- },
440
+ options: { ...ISSUE_OPTS, body: { type: 'string' } },
465
441
  })
466
442
  } catch (e) {
467
443
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -471,50 +447,28 @@ async function cmdUploadMapping(argv: string[]): Promise<number> {
471
447
  console.log(HELP)
472
448
  return 0
473
449
  }
474
- const cfg = parseAdminCfg(parsed.values)
450
+ const cfg = parseApiCfg(parsed.values)
475
451
  if (!cfg) return 2
476
- const path = parsed.positionals[0]
477
- if (!path) {
478
- console.error('error: a path to mapping.txt is required')
452
+ const issueId = parsed.positionals[0]
453
+ const body = parsed.values.body as string | undefined
454
+ if (!issueId || !body) {
455
+ console.error('error: <issue-id> and --body are required')
479
456
  return 2
480
457
  }
481
458
  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}` : ''}`)
459
+ await noteIssue(cfg, issueId, body)
460
+ console.log(`${issueId} ← note added`)
491
461
  return 0
492
462
  } catch (e) {
493
- console.error(`mapping upload failed: ${(e as Error).message}`)
463
+ console.error(`issue note failed: ${(e as Error).message}`)
494
464
  return 1
495
465
  }
496
466
  }
497
467
 
498
- async function cmdUploadSourceBundle(argv: string[]): Promise<number> {
468
+ async function cmdIssueBundle(argv: string[]): Promise<number> {
499
469
  let parsed
500
470
  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
- })
471
+ parsed = parseArgs({ allowPositionals: true, args: argv, options: ISSUE_OPTS })
518
472
  } catch (e) {
519
473
  console.error(`error: ${(e as Error).message}\n${HELP}`)
520
474
  return 2
@@ -523,54 +477,28 @@ async function cmdUploadSourceBundle(argv: string[]): Promise<number> {
523
477
  console.log(HELP)
524
478
  return 0
525
479
  }
526
- const cfg = parseAdminCfg(parsed.values)
480
+ const cfg = parseApiCfg(parsed.values)
527
481
  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')
482
+ const issueId = parsed.positionals[0]
483
+ if (!issueId) {
484
+ console.error('error: <issue-id> is required')
541
485
  return 2
542
486
  }
543
487
  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)}…)`)
488
+ console.log(await fetchBundle(cfg, issueId))
554
489
  return 0
555
490
  } catch (e) {
556
- console.error(`source-bundle upload failed: ${(e as Error).message}`)
491
+ console.error(`issue bundle failed: ${(e as Error).message}`)
557
492
  return 1
558
493
  }
559
494
  }
560
495
 
496
+ // ── mcp ───────────────────────────────────────────────────────────
497
+
561
498
  async function cmdMcpServe(argv: string[]): Promise<number> {
562
499
  let parsed
563
500
  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
- })
501
+ parsed = parseArgs({ args: argv, options: ISSUE_OPTS })
574
502
  } catch (e) {
575
503
  console.error(`error: ${(e as Error).message}\n${HELP}`)
576
504
  return 2
@@ -579,10 +507,10 @@ async function cmdMcpServe(argv: string[]): Promise<number> {
579
507
  console.log(HELP)
580
508
  return 0
581
509
  }
582
- const cfg = parseAdminCfg(parsed.values)
510
+ const cfg = parseApiCfg(parsed.values)
583
511
  if (!cfg) return 2
584
512
  try {
585
- await runMcpServer({ apiUrl: cfg.apiUrl, projectId: cfg.projectId, token: cfg.token })
513
+ await runMcpServer({ apiUrl: cfg.apiUrl, token: cfg.token })
586
514
  return 0
587
515
  } catch (e) {
588
516
  console.error(`mcp serve failed: ${(e as Error).message}`)
@@ -599,13 +527,13 @@ async function main(argv: string[]): Promise<number> {
599
527
  if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
600
528
  if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
601
529
  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
530
  if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
531
+ if (a === 'probes' && b === 'sync') return cmdProbesSync(rest)
532
+ if (a === 'mcp' && b === 'serve') return cmdMcpServe(rest)
605
533
  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')
534
+ if (a === 'issue' && b === 'resolve') return cmdIssueResolve(rest)
535
+ if (a === 'issue' && b === 'note') return cmdIssueNote(rest)
536
+ if (a === 'issue' && b === 'bundle') return cmdIssueBundle(rest)
609
537
  if (a === 'push' && b === 'send') return cmdPushSend(rest)
610
538
  if (a === 'push' && b === 'receipt') return cmdPushReceipt(rest)
611
539
  if (a === 'push' && b === 'creds') {
@@ -790,6 +718,7 @@ async function cmdPushCredsDelete(argv: string[]): Promise<number> {
790
718
  }
791
719
  }
792
720
 
721
+
793
722
  main(process.argv.slice(2)).then(
794
723
  (code) => process.exit(code),
795
724
  (e: unknown) => {