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