@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/lib/index.js CHANGED
@@ -1,122 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
- import { formatIssueLine, issueList, issuePatch } from './issue.js';
3
+ import { fetchBundle, formatIssueLine, listIssues, noteIssue, resolveIssue, } from './issue.js';
4
+ import { isStrict, lenientFail, stripStrict } from './lenient.js';
4
5
  import { runMcpServer } from './mcp.js';
5
6
  import { uploadDsym, uploadMapping } from './native-artifacts.js';
7
+ import { scanProbes, syncProbes } from './probes.js';
6
8
  import { parseJsonArg, pushCredsDelete, pushCredsList, pushCredsSet, pushReceipt, pushSend, } from './push.js';
7
9
  import { reactNativeUpload } from './react-native.js';
8
- import { uploadSourceBundle } from './source-bundle.js';
9
- import { uploadSourcemaps } from './upload.js';
10
+ import { uploadArtifact } from './upload.js';
10
11
  const HELP = `sentori-cli — Sentori command-line interface
11
12
 
12
- Source-map upload:
13
- sentori-cli upload sourcemap [options] <path...>
14
- Upload one or more files or directories. A directory is scanned
15
- (one level) for *.map / *.js / *.jsbundle / *.bundle / *.hbc;
16
- a file given explicitly is uploaded as-is. Use this when you
17
- already have a composed sourcemap on disk:
18
- - web bundlers (point at the build dir),
19
- - iOS post-\`react-native-xcode.sh\` where the build phase has
20
- already composed packager + Hermes maps into the final
21
- \`$SOURCEMAP_FILE\` and deleted the intermediates.
22
- Composed-then-uploaded vs raw-then-server-composed yield
23
- identical symbolication — the server stores the same shape
24
- either way.
13
+ Symbolication artifacts (api-scope token; failures NEVER block your
14
+ build exit 0 with a friendly note unless --strict):
15
+ sentori-cli upload sourcemap --release <r> --token <t> <path...>
16
+ sentori-cli upload dsym --release <r> --token <t> <path.dSYM>
17
+ sentori-cli upload mapping --release <r> --token <t> mapping.txt
18
+ sentori-cli react-native upload --release <r> --token <t> \\
19
+ --metro-map <m> --hermes-map <h> [--bundle <b>]
25
20
 
26
- sentori-cli react-native upload [options]
27
- Compose a Metro packager map + a Hermes map into one source map
28
- (uses react-native's \`scripts/compose-source-maps.js\`) and
29
- upload the result. Use this when you have both raw maps on
30
- disk — typical Android release path where the gradle
31
- \`bundleReleaseJsAndAssets\` task leaves both maps untouched.
32
- Requires --metro-map and --hermes-map. On iOS the build phase
33
- deletes the intermediates, so use \`upload sourcemap\` instead.
21
+ Regression tripwires (design: probes):
22
+ sentori-cli probes sync --release <r> --token <t> [--dir .]
23
+ Statically scans source for sentori.probe('REF') call sites and
24
+ registers them, so a silent probe is visibly alive.
34
25
 
35
- Native artifacts (project-scoped, need --project + admin token):
36
- sentori-cli upload dsym --project <uuid> [--release <r>] [--object-name <n>] [--debug-id <uuid> --arch <a>] <path>
37
- Upload iOS dSYM debug info. By default walks a Foo.dSYM bundle
38
- and uses "dwarfdump --uuid" to enumerate slices, uploading each.
39
- Pass --debug-id and --arch to upload a single slice without
40
- dwarfdump (useful in Linux CI where the toolchain isn't there).
26
+ CI triage (the same /api surface an AI agent uses):
27
+ sentori-cli issue list [--status open] [--kind error]
28
+ sentori-cli issue resolve <issue-id> [--in-release <r>]
29
+ sentori-cli issue note <issue-id> --body "fixed in abc123"
30
+ sentori-cli issue bundle <issue-id>
41
31
 
42
- sentori-cli upload mapping --project <uuid> [--release <r>] [--debug-id <uuid>] <mapping.txt>
43
- Upload an R8 / ProGuard mapping (raw bytes). If the file starts
44
- with a "# pg_map_id:" line the server sniffs the debug-id from
45
- it; otherwise you can pass it explicitly.
32
+ MCP (for Claude Code and friends):
33
+ sentori-cli mcp serve --token <api-token> [--api-url <url>]
46
34
 
47
- sentori-cli upload source-bundle --project <uuid> --release <r> --platform ios|android [--module <label>] <archive.tar.gz>
48
- Upload a pre-built tar.gz of your project's source so the
49
- dashboard can render inline source for native (Swift / Kotlin /
50
- Objective-C) frames the way it already does for JS via source
51
- maps. Build the archive yourself:
52
- tar -czf ios-source.tar.gz Sources/
53
- Pass --module to upload multiple bundles per (release, platform)
54
- — e.g. \`--module main\`, \`--module watch-ext\`. Omitting --module
55
- reuses the v1.3 single-bundle slot (re-uploading replaces it).
35
+ Push (carried):
36
+ sentori-cli push send / receipt / creds ...
56
37
 
57
- CI triage:
58
- sentori-cli issue list --project <uuid> [--status active|silenced|resolved|closed] [--limit N] [--error-type <t>]
59
- sentori-cli issue resolve <issue-uuid> --project <uuid> [--in-release <r>]
60
- sentori-cli issue silence <issue-uuid> --project <uuid>
61
-
62
- LLM agents (MCP):
63
- sentori-cli mcp serve --project <uuid> [--token <t>] [--api-url <url>]
64
- Run a stdio MCP server. Connect from Claude Code / any MCP
65
- client by pointing at \`sentori-cli mcp serve …\` as the command.
66
- Exposes sentori_issue_list / _get / _comment / _transition /
67
- _assign / _set_priority / _set_labels / _watch tools.
68
-
69
- Options (upload commands):
70
- --release <r> release identifier — MUST equal the value the SDK
71
- reports via init({ release }). Required.
72
- --token <t> Sentori token (or set $SENTORI_TOKEN).
73
- --api-url <url> Sentori API base (default https://sentori.golia.jp,
74
- or $SENTORI_API_URL). For a self-hosted instance, your
75
- host. (Accepts --ingest-url as an alias.)
76
- --dry-run describe what would be uploaded; don't upload.
77
- -h, --help show this help.
78
-
79
- Options (react-native upload):
80
- --metro-map <p> the *.packager.map Metro emits (--sourcemap-output).
81
- --hermes-map <p> the *.hbc.map the Hermes compiler emits.
82
- --bundle <p> optional: also upload the bundle (.jsbundle / .bundle).
83
-
84
- Options (issue commands):
85
- --project <uuid> project id (or set $SENTORI_PROJECT_ID).
86
- --token <t> admin token, sk_… prefix (or $SENTORI_ADMIN_TOKEN /
87
- $SENTORI_TOKEN). The ingest st_pk_ token may also work
88
- on a self-hosted instance.
89
- --api-url <url> Sentori API base (same as above).
90
- --in-release <r> (resolve only) mark this release as where the fix
91
- landed; the regression detector flips the issue back
92
- to "regressed" if a matching event lands later.
93
-
94
- Hermes release build, by hand:
95
-
96
- Android (raw maps still on disk after \`./gradlew bundleRelease\`):
97
- npx @goliapkg/sentori-cli react-native upload \\
98
- --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
99
- --metro-map android/app/build/intermediates/sourcemaps/react/release/index.android.bundle.packager.map \\
100
- --hermes-map android/app/build/intermediates/sourcemaps/react/release/index.android.bundle.compiler.map \\
101
- --bundle android/app/build/generated/assets/react/release/index.android.bundle
102
-
103
- iOS (already-composed map after \`xcodebuild archive\`; the build
104
- phase deletes intermediates so you only have the composed map):
105
- npx @goliapkg/sentori-cli upload sourcemap \\
106
- --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
107
- "$BUILT_PRODUCTS_DIR/main.jsbundle.map" \\
108
- "$BUILT_PRODUCTS_DIR/main.jsbundle"
38
+ Common options:
39
+ --token api-scope token (or $SENTORI_TOKEN)
40
+ --api-url instance URL (or $SENTORI_API_URL; default https://sentori.golia.jp)
41
+ --strict upload commands: exit non-zero on failure
109
42
  `;
110
- /** Parse the shared options, or print an error + return null. */
111
43
  function parseCommon(values) {
112
44
  const release = typeof values.release === 'string' ? values.release : undefined;
113
45
  if (!release) {
114
- console.error('error: --release is required (must match the SDKs init({ release }))');
46
+ console.error("error: --release is required (must match the SDK's init({ release }))");
115
47
  return null;
116
48
  }
117
- const dryRun = values['dry-run'] === true;
118
49
  const token = (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN;
119
- if (!token && !dryRun) {
50
+ if (!token) {
120
51
  console.error('error: --token (or $SENTORI_TOKEN) is required');
121
52
  return null;
122
53
  }
@@ -124,27 +55,95 @@ function parseCommon(values) {
124
55
  (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
125
56
  process.env.SENTORI_API_URL ??
126
57
  'https://sentori.golia.jp';
127
- return { apiUrl, dryRun, release, token: token ?? '' };
58
+ return { apiUrl, release, token };
128
59
  }
60
+ function parseApiCfg(values) {
61
+ const token = (typeof values.token === 'string' ? values.token : undefined) ??
62
+ process.env.SENTORI_ADMIN_TOKEN ??
63
+ process.env.SENTORI_TOKEN;
64
+ if (!token) {
65
+ console.error('error: --token (or $SENTORI_TOKEN) is required');
66
+ return null;
67
+ }
68
+ const apiUrl = (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
69
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
70
+ process.env.SENTORI_API_URL ??
71
+ 'https://sentori.golia.jp';
72
+ return { apiUrl, token };
73
+ }
74
+ function parseAdminCfg(values) {
75
+ const projectId = (typeof values.project === 'string' ? values.project : undefined) ??
76
+ process.env.SENTORI_PROJECT_ID;
77
+ if (!projectId) {
78
+ console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required');
79
+ return null;
80
+ }
81
+ const api = parseApiCfg(values);
82
+ if (!api)
83
+ return null;
84
+ return { apiUrl: api.apiUrl, projectId, token: api.token };
85
+ }
86
+ const UPLOAD_OPTS = {
87
+ 'api-url': { type: 'string' },
88
+ help: { short: 'h', type: 'boolean' },
89
+ 'ingest-url': { type: 'string' },
90
+ release: { type: 'string' },
91
+ token: { type: 'string' },
92
+ };
93
+ // ── upload commands (lenient by contract) ─────────────────────────
129
94
  async function cmdUploadSourcemap(argv) {
95
+ const strict = isStrict(argv);
96
+ let parsed;
97
+ try {
98
+ parsed = parseArgs({ allowPositionals: true, args: stripStrict(argv), options: UPLOAD_OPTS });
99
+ }
100
+ catch (e) {
101
+ console.error(`error: ${e.message}\n${HELP}`);
102
+ return 2;
103
+ }
104
+ if (parsed.values.help) {
105
+ console.log(HELP);
106
+ return 0;
107
+ }
108
+ const c = parseCommon(parsed.values);
109
+ if (!c)
110
+ return 2;
111
+ if (parsed.positionals.length === 0) {
112
+ console.error('error: at least one sourcemap path is required');
113
+ return 2;
114
+ }
115
+ try {
116
+ for (const p of parsed.positionals) {
117
+ await uploadArtifact({ ...c, kind: 'sourcemap', path: p });
118
+ }
119
+ console.log(`uploaded ${parsed.positionals.length} sourcemap(s) for "${c.release}" — minified stacks on this release now resolve to source.`);
120
+ return 0;
121
+ }
122
+ catch (e) {
123
+ return lenientFail(strict, {
124
+ failure: `sourcemap upload failed (${e.message})`,
125
+ impact: `crashes from ${c.release} will show minified stacks until the map is uploaded.`,
126
+ retry: `sentori-cli upload sourcemap --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
127
+ });
128
+ }
129
+ }
130
+ async function cmdUploadDsym(argv) {
131
+ const strict = isStrict(argv);
130
132
  let parsed;
131
133
  try {
132
134
  parsed = parseArgs({
133
135
  allowPositionals: true,
134
- args: argv,
136
+ args: stripStrict(argv),
135
137
  options: {
136
- 'api-url': { type: 'string' },
137
- 'dry-run': { type: 'boolean' },
138
- help: { short: 'h', type: 'boolean' },
139
- 'ingest-url': { type: 'string' },
140
- release: { type: 'string' },
141
- token: { type: 'string' },
138
+ ...UPLOAD_OPTS,
139
+ arch: { type: 'string' },
140
+ 'debug-id': { type: 'string' },
141
+ 'object-name': { type: 'string' },
142
142
  },
143
143
  });
144
144
  }
145
145
  catch (e) {
146
- console.error(`error: ${e.message}\n`);
147
- console.error(HELP);
146
+ console.error(`error: ${e.message}\n${HELP}`);
148
147
  return 2;
149
148
  }
150
149
  if (parsed.values.help) {
@@ -154,47 +153,104 @@ async function cmdUploadSourcemap(argv) {
154
153
  const c = parseCommon(parsed.values);
155
154
  if (!c)
156
155
  return 2;
157
- if (parsed.positionals.length === 0) {
158
- console.error('error: at least one path (file or directory) is required');
156
+ const path = parsed.positionals[0];
157
+ if (!path) {
158
+ console.error('error: a path to a .dSYM bundle or DWARF binary is required');
159
+ return 2;
160
+ }
161
+ const debugId = parsed.values['debug-id'];
162
+ const arch = parsed.values.arch;
163
+ if ((debugId && !arch) || (arch && !debugId)) {
164
+ console.error('error: --debug-id and --arch must be passed together (or both omitted)');
159
165
  return 2;
160
166
  }
161
167
  try {
162
- const result = await uploadSourcemaps({
168
+ const r = await uploadDsym({
163
169
  apiUrl: c.apiUrl,
164
- dryRun: c.dryRun,
165
- paths: parsed.positionals,
170
+ arch: typeof arch === 'string' ? arch : undefined,
171
+ debugId: typeof debugId === 'string' ? debugId : undefined,
172
+ objectName: typeof parsed.values['object-name'] === 'string'
173
+ ? parsed.values['object-name']
174
+ : undefined,
175
+ path,
166
176
  release: c.release,
167
177
  token: c.token,
168
178
  });
169
- reportUpload(result, c);
179
+ console.log(`uploaded ${r.slices.length} dSYM slice(s):`);
180
+ for (const s of r.slices)
181
+ console.log(` ${s.debugId} (${s.arch})`);
170
182
  return 0;
171
183
  }
172
184
  catch (e) {
173
- console.error(`upload failed: ${e.message}`);
174
- return 1;
185
+ return lenientFail(strict, {
186
+ failure: `dSYM upload failed (${e.message})`,
187
+ impact: `native iOS stacks from ${c.release} stay unsymbolicated until the dSYM lands.`,
188
+ retry: `sentori-cli upload dsym --release "${c.release}" --token <t> ${path}`,
189
+ });
190
+ }
191
+ }
192
+ async function cmdUploadMapping(argv) {
193
+ const strict = isStrict(argv);
194
+ let parsed;
195
+ try {
196
+ parsed = parseArgs({
197
+ allowPositionals: true,
198
+ args: stripStrict(argv),
199
+ options: { ...UPLOAD_OPTS, 'debug-id': { type: 'string' } },
200
+ });
201
+ }
202
+ catch (e) {
203
+ console.error(`error: ${e.message}\n${HELP}`);
204
+ return 2;
205
+ }
206
+ if (parsed.values.help) {
207
+ console.log(HELP);
208
+ return 0;
209
+ }
210
+ const c = parseCommon(parsed.values);
211
+ if (!c)
212
+ return 2;
213
+ const path = parsed.positionals[0];
214
+ if (!path) {
215
+ console.error('error: a path to mapping.txt is required');
216
+ return 2;
217
+ }
218
+ try {
219
+ await uploadMapping({
220
+ apiUrl: c.apiUrl,
221
+ debugId: typeof parsed.values['debug-id'] === 'string' ? parsed.values['debug-id'] : undefined,
222
+ path,
223
+ release: c.release,
224
+ token: c.token,
225
+ });
226
+ console.log(`uploaded mapping for "${c.release}" — R8 names on this release now demangle.`);
227
+ return 0;
228
+ }
229
+ catch (e) {
230
+ return lenientFail(strict, {
231
+ failure: `mapping upload failed (${e.message})`,
232
+ impact: `Android stacks from ${c.release} stay R8-obfuscated until the mapping lands.`,
233
+ retry: `sentori-cli upload mapping --release "${c.release}" --token <t> ${path}`,
234
+ });
175
235
  }
176
236
  }
177
237
  async function cmdReactNativeUpload(argv) {
238
+ const strict = isStrict(argv);
178
239
  let parsed;
179
240
  try {
180
241
  parsed = parseArgs({
181
- args: argv,
242
+ args: stripStrict(argv),
182
243
  options: {
183
- 'api-url': { type: 'string' },
244
+ ...UPLOAD_OPTS,
184
245
  bundle: { type: 'string' },
185
246
  'dry-run': { type: 'boolean' },
186
- help: { short: 'h', type: 'boolean' },
187
247
  'hermes-map': { type: 'string' },
188
- 'ingest-url': { type: 'string' },
189
248
  'metro-map': { type: 'string' },
190
- release: { type: 'string' },
191
- token: { type: 'string' },
192
249
  },
193
250
  });
194
251
  }
195
252
  catch (e) {
196
- console.error(`error: ${e.message}\n`);
197
- console.error(HELP);
253
+ console.error(`error: ${e.message}\n${HELP}`);
198
254
  return 2;
199
255
  }
200
256
  if (parsed.values.help) {
@@ -214,65 +270,31 @@ async function cmdReactNativeUpload(argv) {
214
270
  const result = await reactNativeUpload({
215
271
  apiUrl: c.apiUrl,
216
272
  bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
217
- dryRun: c.dryRun,
273
+ dryRun: parsed.values['dry-run'] === true,
218
274
  hermesMap,
219
275
  metroMap,
220
276
  release: c.release,
221
277
  token: c.token,
222
278
  });
223
- reportUpload(result, c);
279
+ console.log(`uploaded ${result.uploaded ?? result.files.length} file(s) for "${c.release}".`);
224
280
  return 0;
225
281
  }
226
282
  catch (e) {
227
- console.error(`react-native upload failed: ${e.message}`);
228
- return 1;
229
- }
230
- }
231
- function reportUpload(result, c) {
232
- if (c.dryRun) {
233
- console.log(`would upload ${result.files.length} file(s) to ${c.apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(c.release)}/sourcemaps:`);
234
- for (const f of result.files)
235
- console.log(` ${f}`);
236
- }
237
- else {
238
- console.log(`uploaded ${result.uploaded ?? result.files.length} file(s) for release "${c.release}" — minified stacks on this release will now resolve to source.`);
239
- }
240
- }
241
- function parseAdminCfg(values) {
242
- const projectId = (typeof values.project === 'string' ? values.project : undefined) ??
243
- process.env.SENTORI_PROJECT_ID;
244
- if (!projectId) {
245
- console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required');
246
- return null;
247
- }
248
- const token = (typeof values.token === 'string' ? values.token : undefined) ??
249
- process.env.SENTORI_ADMIN_TOKEN ??
250
- process.env.SENTORI_TOKEN;
251
- if (!token) {
252
- console.error('error: --token (or $SENTORI_ADMIN_TOKEN / $SENTORI_TOKEN) is required for issue commands');
253
- return null;
283
+ return lenientFail(strict, {
284
+ failure: `react-native upload failed (${e.message})`,
285
+ impact: `Hermes stacks from ${c.release} stay unsymbolicated until the composed map lands.`,
286
+ retry: `sentori-cli react-native upload --release "${c.release}" --token <t> --metro-map ${metroMap} --hermes-map ${hermesMap}`,
287
+ });
254
288
  }
255
- const apiUrl = (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
256
- (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
257
- process.env.SENTORI_API_URL ??
258
- 'https://sentori.golia.jp';
259
- return { apiUrl, projectId, token };
260
289
  }
261
- async function cmdIssueList(argv) {
290
+ // ── probes sync ───────────────────────────────────────────────────
291
+ async function cmdProbesSync(argv) {
292
+ const strict = isStrict(argv);
262
293
  let parsed;
263
294
  try {
264
295
  parsed = parseArgs({
265
- args: argv,
266
- options: {
267
- 'api-url': { type: 'string' },
268
- 'error-type': { type: 'string' },
269
- help: { short: 'h', type: 'boolean' },
270
- 'ingest-url': { type: 'string' },
271
- limit: { type: 'string' },
272
- project: { type: 'string' },
273
- status: { type: 'string' },
274
- token: { type: 'string' },
275
- },
296
+ args: stripStrict(argv),
297
+ options: { ...UPLOAD_OPTS, dir: { type: 'string' } },
276
298
  });
277
299
  }
278
300
  catch (e) {
@@ -283,50 +305,41 @@ async function cmdIssueList(argv) {
283
305
  console.log(HELP);
284
306
  return 0;
285
307
  }
286
- const cfg = parseAdminCfg(parsed.values);
287
- if (!cfg)
288
- return 2;
289
- const status = parsed.values.status;
290
- if (status && !['active', 'closed', 'resolved', 'silenced'].includes(status)) {
291
- console.error(`error: --status must be one of: active, silenced, resolved, closed`);
308
+ const c = parseCommon(parsed.values);
309
+ if (!c)
292
310
  return 2;
311
+ const dir = typeof parsed.values.dir === 'string' ? parsed.values.dir : '.';
312
+ const refs = scanProbes(dir);
313
+ if (refs.length === 0) {
314
+ console.log(`no sentori.probe() call sites found under ${dir} — nothing to register.`);
315
+ return 0;
293
316
  }
294
- const limitStr = parsed.values.limit;
295
- const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
296
317
  try {
297
- const rows = await issueList({
298
- config: cfg,
299
- errorType: parsed.values['error-type'],
300
- limit,
301
- status: status,
302
- });
303
- if (rows.length === 0) {
304
- console.log('(no matching issues)');
305
- return 0;
306
- }
307
- for (const r of rows)
308
- console.log(formatIssueLine(r));
318
+ const r = await syncProbes({ apiUrl: c.apiUrl, token: c.token, release: c.release, refs });
319
+ console.log(`registered ${r.registered} probe(s) for "${c.release}": ${refs.join(', ')}`);
309
320
  return 0;
310
321
  }
311
322
  catch (e) {
312
- console.error(`issue list failed: ${e.message}`);
313
- return 1;
323
+ return lenientFail(strict, {
324
+ failure: `probes sync failed (${e.message})`,
325
+ impact: `silent probes on ${c.release} can't be told apart from deleted code until registered.`,
326
+ retry: `sentori-cli probes sync --release "${c.release}" --token <t> --dir ${dir}`,
327
+ });
314
328
  }
315
329
  }
316
- async function cmdIssuePatch(argv, body, verb) {
330
+ // ── issue commands (the /api surface) ─────────────────────────────
331
+ const ISSUE_OPTS = {
332
+ 'api-url': { type: 'string' },
333
+ help: { short: 'h', type: 'boolean' },
334
+ 'ingest-url': { type: 'string' },
335
+ token: { type: 'string' },
336
+ };
337
+ async function cmdIssueList(argv) {
317
338
  let parsed;
318
339
  try {
319
340
  parsed = parseArgs({
320
- allowPositionals: true,
321
341
  args: argv,
322
- options: {
323
- 'api-url': { type: 'string' },
324
- help: { short: 'h', type: 'boolean' },
325
- 'in-release': { type: 'string' },
326
- 'ingest-url': { type: 'string' },
327
- project: { type: 'string' },
328
- token: { type: 'string' },
329
- },
342
+ options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' } },
330
343
  });
331
344
  }
332
345
  catch (e) {
@@ -337,45 +350,34 @@ async function cmdIssuePatch(argv, body, verb) {
337
350
  console.log(HELP);
338
351
  return 0;
339
352
  }
340
- const cfg = parseAdminCfg(parsed.values);
353
+ const cfg = parseApiCfg(parsed.values);
341
354
  if (!cfg)
342
355
  return 2;
343
- const issueId = parsed.positionals[0];
344
- if (!issueId) {
345
- console.error('error: <issue-uuid> is required');
346
- return 2;
347
- }
348
- if (verb === 'resolved' && typeof parsed.values['in-release'] === 'string') {
349
- body.resolvedInRelease = parsed.values['in-release'];
350
- }
351
356
  try {
352
- const updated = await issuePatch(cfg, issueId, body);
353
- console.log(`${issueId} → ${verb}${body.resolvedInRelease ? ` (in ${body.resolvedInRelease})` : ''}: ${updated.errorType}`);
357
+ const rows = await listIssues(cfg, {
358
+ kind: parsed.values.kind,
359
+ status: parsed.values.status ?? 'open',
360
+ });
361
+ if (rows.length === 0) {
362
+ console.log('(no matching issues)');
363
+ return 0;
364
+ }
365
+ for (const r of rows)
366
+ console.log(formatIssueLine(r));
354
367
  return 0;
355
368
  }
356
369
  catch (e) {
357
- console.error(`issue ${verb} failed: ${e.message}`);
370
+ console.error(`issue list failed: ${e.message}`);
358
371
  return 1;
359
372
  }
360
373
  }
361
- // ── native artifact upload ────────────────────────────────────────
362
- async function cmdUploadDsym(argv) {
374
+ async function cmdIssueResolve(argv) {
363
375
  let parsed;
364
376
  try {
365
377
  parsed = parseArgs({
366
378
  allowPositionals: true,
367
379
  args: argv,
368
- options: {
369
- 'api-url': { type: 'string' },
370
- arch: { type: 'string' },
371
- 'debug-id': { type: 'string' },
372
- help: { short: 'h', type: 'boolean' },
373
- 'ingest-url': { type: 'string' },
374
- 'object-name': { type: 'string' },
375
- project: { type: 'string' },
376
- release: { type: 'string' },
377
- token: { type: 'string' },
378
- },
380
+ options: { ...ISSUE_OPTS, 'in-release': { type: 'string' } },
379
381
  });
380
382
  }
381
383
  catch (e) {
@@ -386,56 +388,31 @@ async function cmdUploadDsym(argv) {
386
388
  console.log(HELP);
387
389
  return 0;
388
390
  }
389
- const cfg = parseAdminCfg(parsed.values);
391
+ const cfg = parseApiCfg(parsed.values);
390
392
  if (!cfg)
391
393
  return 2;
392
- const path = parsed.positionals[0];
393
- if (!path) {
394
- console.error('error: a path to a .dSYM bundle or DWARF binary is required');
395
- return 2;
396
- }
397
- const debugId = parsed.values['debug-id'];
398
- const arch = parsed.values.arch;
399
- if ((debugId && !arch) || (arch && !debugId)) {
400
- console.error('error: --debug-id and --arch must be passed together (or both omitted)');
394
+ const issueId = parsed.positionals[0];
395
+ if (!issueId) {
396
+ console.error('error: <issue-id> is required');
401
397
  return 2;
402
398
  }
403
399
  try {
404
- const r = await uploadDsym({
405
- apiUrl: cfg.apiUrl,
406
- arch: typeof arch === 'string' ? arch : undefined,
407
- debugId: typeof debugId === 'string' ? debugId : undefined,
408
- objectName: typeof parsed.values['object-name'] === 'string' ? parsed.values['object-name'] : undefined,
409
- path,
410
- projectId: cfg.projectId,
411
- release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
412
- token: cfg.token,
413
- });
414
- console.log(`uploaded ${r.slices.length} dSYM slice(s):`);
415
- for (const s of r.slices)
416
- console.log(` ${s.debugId} (${s.arch})`);
400
+ await resolveIssue(cfg, issueId, parsed.values['in-release']);
401
+ console.log(`${issueId} → resolved${parsed.values['in-release'] ? ` (in ${parsed.values['in-release']})` : ''}`);
417
402
  return 0;
418
403
  }
419
404
  catch (e) {
420
- console.error(`dsym upload failed: ${e.message}`);
405
+ console.error(`issue resolve failed: ${e.message}`);
421
406
  return 1;
422
407
  }
423
408
  }
424
- async function cmdUploadMapping(argv) {
409
+ async function cmdIssueNote(argv) {
425
410
  let parsed;
426
411
  try {
427
412
  parsed = parseArgs({
428
413
  allowPositionals: true,
429
414
  args: argv,
430
- options: {
431
- 'api-url': { type: 'string' },
432
- 'debug-id': { type: 'string' },
433
- help: { short: 'h', type: 'boolean' },
434
- 'ingest-url': { type: 'string' },
435
- project: { type: 'string' },
436
- release: { type: 'string' },
437
- token: { type: 'string' },
438
- },
415
+ options: { ...ISSUE_OPTS, body: { type: 'string' } },
439
416
  });
440
417
  }
441
418
  catch (e) {
@@ -446,51 +423,29 @@ async function cmdUploadMapping(argv) {
446
423
  console.log(HELP);
447
424
  return 0;
448
425
  }
449
- const cfg = parseAdminCfg(parsed.values);
426
+ const cfg = parseApiCfg(parsed.values);
450
427
  if (!cfg)
451
428
  return 2;
452
- const path = parsed.positionals[0];
453
- if (!path) {
454
- console.error('error: a path to mapping.txt is required');
429
+ const issueId = parsed.positionals[0];
430
+ const body = parsed.values.body;
431
+ if (!issueId || !body) {
432
+ console.error('error: <issue-id> and --body are required');
455
433
  return 2;
456
434
  }
457
435
  try {
458
- await uploadMapping({
459
- apiUrl: cfg.apiUrl,
460
- debugId: typeof parsed.values['debug-id'] === 'string' ? parsed.values['debug-id'] : undefined,
461
- path,
462
- projectId: cfg.projectId,
463
- release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
464
- token: cfg.token,
465
- });
466
- console.log(`uploaded mapping for project ${cfg.projectId}${parsed.values.release ? ` / ${parsed.values.release}` : ''}`);
436
+ await noteIssue(cfg, issueId, body);
437
+ console.log(`${issueId} ← note added`);
467
438
  return 0;
468
439
  }
469
440
  catch (e) {
470
- console.error(`mapping upload failed: ${e.message}`);
441
+ console.error(`issue note failed: ${e.message}`);
471
442
  return 1;
472
443
  }
473
444
  }
474
- async function cmdUploadSourceBundle(argv) {
445
+ async function cmdIssueBundle(argv) {
475
446
  let parsed;
476
447
  try {
477
- parsed = parseArgs({
478
- allowPositionals: true,
479
- args: argv,
480
- options: {
481
- 'api-url': { type: 'string' },
482
- help: { short: 'h', type: 'boolean' },
483
- 'ingest-url': { type: 'string' },
484
- // v1.4 W26 — optional module label so polyrepo apps can
485
- // upload multiple bundles per (release, platform) without
486
- // clobbering each other (main vs watch-ext vs share-ext…).
487
- module: { type: 'string' },
488
- platform: { type: 'string' },
489
- project: { type: 'string' },
490
- release: { type: 'string' },
491
- token: { type: 'string' },
492
- },
493
- });
448
+ parsed = parseArgs({ allowPositionals: true, args: argv, options: ISSUE_OPTS });
494
449
  }
495
450
  catch (e) {
496
451
  console.error(`error: ${e.message}\n${HELP}`);
@@ -500,55 +455,28 @@ async function cmdUploadSourceBundle(argv) {
500
455
  console.log(HELP);
501
456
  return 0;
502
457
  }
503
- const cfg = parseAdminCfg(parsed.values);
458
+ const cfg = parseApiCfg(parsed.values);
504
459
  if (!cfg)
505
460
  return 2;
506
- const path = parsed.positionals[0];
507
- if (!path) {
508
- console.error('error: a path to a tar.gz archive is required');
509
- return 2;
510
- }
511
- const platform = parsed.values.platform;
512
- if (platform !== 'ios' && platform !== 'android') {
513
- console.error('error: --platform must be ios or android');
514
- return 2;
515
- }
516
- const release = typeof parsed.values.release === 'string' ? parsed.values.release : undefined;
517
- if (!release) {
518
- console.error('error: --release is required for source-bundle uploads');
461
+ const issueId = parsed.positionals[0];
462
+ if (!issueId) {
463
+ console.error('error: <issue-id> is required');
519
464
  return 2;
520
465
  }
521
466
  try {
522
- const r = await uploadSourceBundle({
523
- apiUrl: cfg.apiUrl,
524
- module: typeof parsed.values.module === 'string' ? parsed.values.module : undefined,
525
- path,
526
- platform,
527
- projectId: cfg.projectId,
528
- release,
529
- token: cfg.token,
530
- });
531
- console.log(`uploaded ${r.kind} (${r.sizeBytes} bytes, sha256:${r.contentHash.slice(0, 12)}…)`);
467
+ console.log(await fetchBundle(cfg, issueId));
532
468
  return 0;
533
469
  }
534
470
  catch (e) {
535
- console.error(`source-bundle upload failed: ${e.message}`);
471
+ console.error(`issue bundle failed: ${e.message}`);
536
472
  return 1;
537
473
  }
538
474
  }
475
+ // ── mcp ───────────────────────────────────────────────────────────
539
476
  async function cmdMcpServe(argv) {
540
477
  let parsed;
541
478
  try {
542
- parsed = parseArgs({
543
- args: argv,
544
- options: {
545
- 'api-url': { type: 'string' },
546
- help: { short: 'h', type: 'boolean' },
547
- 'ingest-url': { type: 'string' },
548
- project: { type: 'string' },
549
- token: { type: 'string' },
550
- },
551
- });
479
+ parsed = parseArgs({ args: argv, options: ISSUE_OPTS });
552
480
  }
553
481
  catch (e) {
554
482
  console.error(`error: ${e.message}\n${HELP}`);
@@ -558,11 +486,11 @@ async function cmdMcpServe(argv) {
558
486
  console.log(HELP);
559
487
  return 0;
560
488
  }
561
- const cfg = parseAdminCfg(parsed.values);
489
+ const cfg = parseApiCfg(parsed.values);
562
490
  if (!cfg)
563
491
  return 2;
564
492
  try {
565
- await runMcpServer({ apiUrl: cfg.apiUrl, projectId: cfg.projectId, token: cfg.token });
493
+ await runMcpServer({ apiUrl: cfg.apiUrl, token: cfg.token });
566
494
  return 0;
567
495
  }
568
496
  catch (e) {
@@ -582,20 +510,20 @@ async function main(argv) {
582
510
  return cmdUploadDsym(rest);
583
511
  if (a === 'upload' && b === 'mapping')
584
512
  return cmdUploadMapping(rest);
585
- if (a === 'upload' && b === 'source-bundle')
586
- return cmdUploadSourceBundle(rest);
587
- if (a === 'mcp' && b === 'serve')
588
- return cmdMcpServe(rest);
589
513
  if (a === 'react-native' && b === 'upload')
590
514
  return cmdReactNativeUpload(rest);
515
+ if (a === 'probes' && b === 'sync')
516
+ return cmdProbesSync(rest);
517
+ if (a === 'mcp' && b === 'serve')
518
+ return cmdMcpServe(rest);
591
519
  if (a === 'issue' && b === 'list')
592
520
  return cmdIssueList(rest);
593
521
  if (a === 'issue' && b === 'resolve')
594
- return cmdIssuePatch(rest, { status: 'resolved' }, 'resolved');
595
- if (a === 'issue' && b === 'silence')
596
- return cmdIssuePatch(rest, { status: 'silenced' }, 'silenced');
597
- if (a === 'issue' && b === 'close')
598
- return cmdIssuePatch(rest, { status: 'closed' }, 'closed');
522
+ return cmdIssueResolve(rest);
523
+ if (a === 'issue' && b === 'note')
524
+ return cmdIssueNote(rest);
525
+ if (a === 'issue' && b === 'bundle')
526
+ return cmdIssueBundle(rest);
599
527
  if (a === 'push' && b === 'send')
600
528
  return cmdPushSend(rest);
601
529
  if (a === 'push' && b === 'receipt')