@goliapkg/sentori-cli 0.6.0 → 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 (47) hide show
  1. package/lib/index.js +268 -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 +30 -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 +10 -21
  27. package/lib/upload.d.ts.map +1 -1
  28. package/lib/upload.js +20 -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 +5 -12
  34. package/src/__tests__/probes.test.ts +41 -0
  35. package/src/index.ts +290 -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 +37 -44
  40. package/src/probes.ts +75 -0
  41. package/src/react-native.ts +16 -9
  42. package/src/upload.ts +30 -71
  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,52 @@ 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
+ 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
118
57
  `
119
58
 
120
- type Common = { apiUrl: string; dryRun: boolean; release: string; token: string }
59
+ type Common = { apiUrl: string; release: string; token: string }
121
60
 
122
- /** Parse the shared options, or print an error + return null. */
123
61
  function parseCommon(values: Record<string, unknown>): Common | null {
124
62
  const release = typeof values.release === 'string' ? values.release : undefined
125
63
  if (!release) {
126
- console.error('error: --release is required (must match the SDKs init({ release }))')
64
+ console.error("error: --release is required (must match the SDK's init({ release }))")
127
65
  return null
128
66
  }
129
- const dryRun = values['dry-run'] === true
130
67
  const token =
131
68
  (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
132
- if (!token && !dryRun) {
69
+ if (!token) {
133
70
  console.error('error: --token (or $SENTORI_TOKEN) is required')
134
71
  return null
135
72
  }
@@ -138,27 +75,107 @@ function parseCommon(values: Record<string, unknown>): Common | null {
138
75
  (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
139
76
  process.env.SENTORI_API_URL ??
140
77
  'https://sentori.golia.jp'
141
- return { apiUrl, dryRun, release, token: token ?? '' }
78
+ return { apiUrl, release, token }
142
79
  }
143
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
+
144
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)
145
165
  let parsed
146
166
  try {
147
167
  parsed = parseArgs({
148
168
  allowPositionals: true,
149
- args: argv,
169
+ args: stripStrict(argv),
150
170
  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' },
171
+ ...UPLOAD_OPTS,
172
+ arch: { type: 'string' },
173
+ 'debug-id': { type: 'string' },
174
+ 'object-name': { type: 'string' },
157
175
  },
158
176
  })
159
177
  } catch (e) {
160
- console.error(`error: ${(e as Error).message}\n`)
161
- console.error(HELP)
178
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
162
179
  return 2
163
180
  }
164
181
  if (parsed.values.help) {
@@ -167,46 +184,102 @@ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
167
184
  }
168
185
  const c = parseCommon(parsed.values)
169
186
  if (!c) return 2
170
- if (parsed.positionals.length === 0) {
171
- console.error('error: at least one path (file or directory) is required')
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)')
172
196
  return 2
173
197
  }
174
198
  try {
175
- const result = await uploadSourcemaps({
199
+ const r = await uploadDsym({
176
200
  apiUrl: c.apiUrl,
177
- dryRun: c.dryRun,
178
- paths: parsed.positionals,
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,
179
208
  release: c.release,
180
209
  token: c.token,
181
210
  })
182
- reportUpload(result, c)
211
+ console.log(`uploaded ${r.slices.length} dSYM slice(s):`)
212
+ for (const s of r.slices) console.log(` ${s.debugId} (${s.arch})`)
183
213
  return 0
184
214
  } catch (e) {
185
- console.error(`upload failed: ${(e as Error).message}`)
186
- return 1
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
+ })
187
264
  }
188
265
  }
189
266
 
190
267
  async function cmdReactNativeUpload(argv: string[]): Promise<number> {
268
+ const strict = isStrict(argv)
191
269
  let parsed
192
270
  try {
193
271
  parsed = parseArgs({
194
- args: argv,
272
+ args: stripStrict(argv),
195
273
  options: {
196
- 'api-url': { type: 'string' },
274
+ ...UPLOAD_OPTS,
197
275
  bundle: { type: 'string' },
198
276
  'dry-run': { type: 'boolean' },
199
- help: { short: 'h', type: 'boolean' },
200
277
  'hermes-map': { type: 'string' },
201
- 'ingest-url': { type: 'string' },
202
278
  'metro-map': { type: 'string' },
203
- release: { type: 'string' },
204
- token: { type: 'string' },
205
279
  },
206
280
  })
207
281
  } catch (e) {
208
- console.error(`error: ${(e as Error).message}\n`)
209
- console.error(HELP)
282
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
210
283
  return 2
211
284
  }
212
285
  if (parsed.values.help) {
@@ -225,81 +298,32 @@ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
225
298
  const result = await reactNativeUpload({
226
299
  apiUrl: c.apiUrl,
227
300
  bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
228
- dryRun: c.dryRun,
301
+ dryRun: parsed.values['dry-run'] === true,
229
302
  hermesMap,
230
303
  metroMap,
231
304
  release: c.release,
232
305
  token: c.token,
233
306
  })
234
- reportUpload(result, c)
307
+ console.log(`uploaded ${result.uploaded ?? result.files.length} file(s) for "${c.release}".`)
235
308
  return 0
236
309
  } 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
- )
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
+ })
255
315
  }
256
316
  }
257
317
 
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
- }
318
+ // ── probes sync ───────────────────────────────────────────────────
287
319
 
288
- async function cmdIssueList(argv: string[]): Promise<number> {
320
+ async function cmdProbesSync(argv: string[]): Promise<number> {
321
+ const strict = isStrict(argv)
289
322
  let parsed
290
323
  try {
291
324
  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
- },
325
+ args: stripStrict(argv),
326
+ options: { ...UPLOAD_OPTS, dir: { type: 'string' } },
303
327
  })
304
328
  } catch (e) {
305
329
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -309,52 +333,42 @@ async function cmdIssueList(argv: string[]): Promise<number> {
309
333
  console.log(HELP)
310
334
  return 0
311
335
  }
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
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
318
343
  }
319
- const limitStr = parsed.values.limit
320
- const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined
321
344
  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))
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(', ')}`)
333
347
  return 0
334
348
  } catch (e) {
335
- console.error(`issue list failed: ${(e as Error).message}`)
336
- return 1
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
+ })
337
354
  }
338
355
  }
339
356
 
340
- async function cmdIssuePatch(
341
- argv: string[],
342
- body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
343
- verb: 'closed' | 'resolved' | 'silenced',
344
- ): Promise<number> {
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> {
345
367
  let parsed
346
368
  try {
347
369
  parsed = parseArgs({
348
- allowPositionals: true,
349
370
  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
- },
371
+ options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' } },
358
372
  })
359
373
  } catch (e) {
360
374
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -364,47 +378,32 @@ async function cmdIssuePatch(
364
378
  console.log(HELP)
365
379
  return 0
366
380
  }
367
- const cfg = parseAdminCfg(parsed.values)
381
+ const cfg = parseApiCfg(parsed.values)
368
382
  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
383
  try {
378
- const updated = await issuePatch(cfg, issueId, body)
379
- console.log(
380
- `${issueId} → ${verb}${body.resolvedInRelease ? ` (in ${body.resolvedInRelease})` : ''}: ${updated.errorType}`,
381
- )
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))
382
393
  return 0
383
394
  } catch (e) {
384
- console.error(`issue ${verb} failed: ${(e as Error).message}`)
395
+ console.error(`issue list failed: ${(e as Error).message}`)
385
396
  return 1
386
397
  }
387
398
  }
388
399
 
389
- // ── native artifact upload ────────────────────────────────────────
390
-
391
- async function cmdUploadDsym(argv: string[]): Promise<number> {
400
+ async function cmdIssueResolve(argv: string[]): Promise<number> {
392
401
  let parsed
393
402
  try {
394
403
  parsed = parseArgs({
395
404
  allowPositionals: true,
396
405
  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
- },
406
+ options: { ...ISSUE_OPTS, 'in-release': { type: 'string' } },
408
407
  })
409
408
  } catch (e) {
410
409
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -414,54 +413,30 @@ async function cmdUploadDsym(argv: string[]): Promise<number> {
414
413
  console.log(HELP)
415
414
  return 0
416
415
  }
417
- const cfg = parseAdminCfg(parsed.values)
416
+ const cfg = parseApiCfg(parsed.values)
418
417
  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)')
418
+ const issueId = parsed.positionals[0]
419
+ if (!issueId) {
420
+ console.error('error: <issue-id> is required')
428
421
  return 2
429
422
  }
430
423
  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})`)
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']})` : ''}`)
443
426
  return 0
444
427
  } catch (e) {
445
- console.error(`dsym upload failed: ${(e as Error).message}`)
428
+ console.error(`issue resolve failed: ${(e as Error).message}`)
446
429
  return 1
447
430
  }
448
431
  }
449
432
 
450
- async function cmdUploadMapping(argv: string[]): Promise<number> {
433
+ async function cmdIssueNote(argv: string[]): Promise<number> {
451
434
  let parsed
452
435
  try {
453
436
  parsed = parseArgs({
454
437
  allowPositionals: true,
455
438
  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
- },
439
+ options: { ...ISSUE_OPTS, body: { type: 'string' } },
465
440
  })
466
441
  } catch (e) {
467
442
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -471,50 +446,28 @@ async function cmdUploadMapping(argv: string[]): Promise<number> {
471
446
  console.log(HELP)
472
447
  return 0
473
448
  }
474
- const cfg = parseAdminCfg(parsed.values)
449
+ const cfg = parseApiCfg(parsed.values)
475
450
  if (!cfg) return 2
476
- const path = parsed.positionals[0]
477
- if (!path) {
478
- console.error('error: a path to mapping.txt is required')
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')
479
455
  return 2
480
456
  }
481
457
  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}` : ''}`)
458
+ await noteIssue(cfg, issueId, body)
459
+ console.log(`${issueId} ← note added`)
491
460
  return 0
492
461
  } catch (e) {
493
- console.error(`mapping upload failed: ${(e as Error).message}`)
462
+ console.error(`issue note failed: ${(e as Error).message}`)
494
463
  return 1
495
464
  }
496
465
  }
497
466
 
498
- async function cmdUploadSourceBundle(argv: string[]): Promise<number> {
467
+ async function cmdIssueBundle(argv: string[]): Promise<number> {
499
468
  let parsed
500
469
  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
- })
470
+ parsed = parseArgs({ allowPositionals: true, args: argv, options: ISSUE_OPTS })
518
471
  } catch (e) {
519
472
  console.error(`error: ${(e as Error).message}\n${HELP}`)
520
473
  return 2
@@ -523,54 +476,28 @@ async function cmdUploadSourceBundle(argv: string[]): Promise<number> {
523
476
  console.log(HELP)
524
477
  return 0
525
478
  }
526
- const cfg = parseAdminCfg(parsed.values)
479
+ const cfg = parseApiCfg(parsed.values)
527
480
  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')
481
+ const issueId = parsed.positionals[0]
482
+ if (!issueId) {
483
+ console.error('error: <issue-id> is required')
541
484
  return 2
542
485
  }
543
486
  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)}…)`)
487
+ console.log(await fetchBundle(cfg, issueId))
554
488
  return 0
555
489
  } catch (e) {
556
- console.error(`source-bundle upload failed: ${(e as Error).message}`)
490
+ console.error(`issue bundle failed: ${(e as Error).message}`)
557
491
  return 1
558
492
  }
559
493
  }
560
494
 
495
+ // ── mcp ───────────────────────────────────────────────────────────
496
+
561
497
  async function cmdMcpServe(argv: string[]): Promise<number> {
562
498
  let parsed
563
499
  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
- })
500
+ parsed = parseArgs({ args: argv, options: ISSUE_OPTS })
574
501
  } catch (e) {
575
502
  console.error(`error: ${(e as Error).message}\n${HELP}`)
576
503
  return 2
@@ -579,10 +506,10 @@ async function cmdMcpServe(argv: string[]): Promise<number> {
579
506
  console.log(HELP)
580
507
  return 0
581
508
  }
582
- const cfg = parseAdminCfg(parsed.values)
509
+ const cfg = parseApiCfg(parsed.values)
583
510
  if (!cfg) return 2
584
511
  try {
585
- await runMcpServer({ apiUrl: cfg.apiUrl, projectId: cfg.projectId, token: cfg.token })
512
+ await runMcpServer({ apiUrl: cfg.apiUrl, token: cfg.token })
586
513
  return 0
587
514
  } catch (e) {
588
515
  console.error(`mcp serve failed: ${(e as Error).message}`)
@@ -599,13 +526,13 @@ async function main(argv: string[]): Promise<number> {
599
526
  if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
600
527
  if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
601
528
  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
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)
605
532
  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')
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)
609
536
  if (a === 'push' && b === 'send') return cmdPushSend(rest)
610
537
  if (a === 'push' && b === 'receipt') return cmdPushReceipt(rest)
611
538
  if (a === 'push' && b === 'creds') {
@@ -790,6 +717,7 @@ async function cmdPushCredsDelete(argv: string[]): Promise<number> {
790
717
  }
791
718
  }
792
719
 
720
+
793
721
  main(process.argv.slice(2)).then(
794
722
  (code) => process.exit(code),
795
723
  (e: unknown) => {