@goliapkg/sentori-cli 0.5.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +27 -32
  2. package/lib/index.d.ts +3 -0
  3. package/lib/index.d.ts.map +1 -0
  4. package/lib/index.js +723 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/issue.d.ts +26 -0
  7. package/lib/issue.d.ts.map +1 -0
  8. package/lib/issue.js +50 -0
  9. package/lib/issue.js.map +1 -0
  10. package/lib/lenient.d.ts +13 -0
  11. package/lib/lenient.d.ts.map +1 -0
  12. package/lib/lenient.js +25 -0
  13. package/lib/lenient.js.map +1 -0
  14. package/lib/mcp.d.ts +16 -0
  15. package/lib/mcp.d.ts.map +1 -0
  16. package/lib/mcp.js +194 -0
  17. package/lib/mcp.js.map +1 -0
  18. package/lib/native-artifacts.d.ts +42 -0
  19. package/lib/native-artifacts.d.ts.map +1 -0
  20. package/lib/native-artifacts.js +137 -0
  21. package/lib/native-artifacts.js.map +1 -0
  22. package/lib/probes.d.ts +10 -0
  23. package/lib/probes.d.ts.map +1 -0
  24. package/lib/probes.js +75 -0
  25. package/lib/probes.js.map +1 -0
  26. package/lib/push.d.ts +42 -0
  27. package/lib/push.d.ts.map +1 -0
  28. package/lib/push.js +92 -0
  29. package/lib/push.js.map +1 -0
  30. package/lib/react-native.d.ts +25 -0
  31. package/lib/react-native.d.ts.map +1 -0
  32. package/lib/react-native.js +82 -0
  33. package/lib/react-native.js.map +1 -0
  34. package/lib/source-bundle.d.ts +28 -0
  35. package/lib/source-bundle.d.ts.map +1 -0
  36. package/lib/source-bundle.js +193 -0
  37. package/lib/source-bundle.js.map +1 -0
  38. package/lib/upload.d.ts +13 -0
  39. package/lib/upload.d.ts.map +1 -0
  40. package/lib/upload.js +28 -0
  41. package/lib/upload.js.map +1 -0
  42. package/package.json +23 -13
  43. package/src/__tests__/lenient.test.ts +32 -0
  44. package/src/__tests__/mcp.test.ts +32 -0
  45. package/src/__tests__/native-artifacts.test.ts +125 -0
  46. package/src/__tests__/probes.test.ts +41 -0
  47. package/src/__tests__/react-native.test.ts +37 -0
  48. package/src/index.ts +727 -0
  49. package/src/issue.ts +82 -0
  50. package/src/lenient.ts +39 -0
  51. package/src/mcp.ts +230 -0
  52. package/src/native-artifacts.ts +175 -0
  53. package/src/probes.ts +75 -0
  54. package/src/push.ts +174 -0
  55. package/src/react-native.ts +94 -0
  56. package/src/upload.ts +44 -0
  57. package/bin/sentori-cli.js +0 -32
  58. package/scripts/postinstall.js +0 -90
package/src/issue.ts ADDED
@@ -0,0 +1,82 @@
1
+ // `sentori-cli issue list / resolve` — CI triage over the AI
2
+ // surface (`/api/*`, api-scope token; the same loop an agent runs).
3
+
4
+ type Issue = {
5
+ id: string
6
+ kind: string
7
+ title: string
8
+ messageSample: string
9
+ status: string
10
+ eventCount: number
11
+ usersCount: number
12
+ maxPerUser: number
13
+ lastSeen: string
14
+ regressed: boolean
15
+ }
16
+
17
+ export type ApiConfig = {
18
+ apiUrl: string
19
+ token: string
20
+ }
21
+
22
+ async function apiFetch<T>(c: ApiConfig, path: string, init?: RequestInit): Promise<T> {
23
+ const resp = await fetch(`${c.apiUrl.replace(/\/+$/, '')}${path}`, {
24
+ ...init,
25
+ headers: {
26
+ Authorization: `Bearer ${c.token}`,
27
+ 'Content-Type': 'application/json',
28
+ ...(init?.headers ?? {}),
29
+ },
30
+ })
31
+ if (!resp.ok) {
32
+ const detail = await resp.text().catch(() => '')
33
+ throw new Error(
34
+ `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
35
+ )
36
+ }
37
+ return (await resp.json()) as T
38
+ }
39
+
40
+ export async function listIssues(
41
+ c: ApiConfig,
42
+ opts: { status?: string; kind?: string } = {},
43
+ ): Promise<Issue[]> {
44
+ const q = new URLSearchParams()
45
+ if (opts.status) q.set('status', opts.status)
46
+ if (opts.kind) q.set('kind', opts.kind)
47
+ const qs = q.toString()
48
+ const body = await apiFetch<{ issues: Issue[] }>(c, `/api/issues${qs ? `?${qs}` : ''}`)
49
+ return body.issues
50
+ }
51
+
52
+ export async function resolveIssue(
53
+ c: ApiConfig,
54
+ issueId: string,
55
+ release?: string,
56
+ ): Promise<void> {
57
+ await apiFetch(c, `/api/issues/${encodeURIComponent(issueId)}/resolve`, {
58
+ method: 'POST',
59
+ body: JSON.stringify(release ? { release } : {}),
60
+ })
61
+ }
62
+
63
+ export async function noteIssue(c: ApiConfig, issueId: string, body: string): Promise<void> {
64
+ await apiFetch(c, `/api/issues/${encodeURIComponent(issueId)}/notes`, {
65
+ method: 'POST',
66
+ body: JSON.stringify({ body }),
67
+ })
68
+ }
69
+
70
+ export async function fetchBundle(c: ApiConfig, issueId: string): Promise<string> {
71
+ const resp = await fetch(
72
+ `${c.apiUrl.replace(/\/+$/, '')}/api/issues/${encodeURIComponent(issueId)}/bundle`,
73
+ { headers: { Authorization: `Bearer ${c.token}` } },
74
+ )
75
+ if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
76
+ return resp.text()
77
+ }
78
+
79
+ export function formatIssueLine(i: Issue): string {
80
+ const flag = i.regressed ? ' REGRESSED' : ''
81
+ return `${i.id} [${i.kind}] ${i.title} ${i.usersCount}u×${i.maxPerUser} ${i.eventCount}ev${flag}`
82
+ }
package/src/lenient.ts ADDED
@@ -0,0 +1,39 @@
1
+ // The iron rule reaches build-time (design.md §6 dim 3): an upload
2
+ // failure must never break the customer's build or release. Every
3
+ // upload-family command runs through this wrapper — on failure it
4
+ // prints a friendly, actionable note and exits 0. `--strict` opts
5
+ // into a real non-zero exit for pipelines that want the hard
6
+ // guarantee.
7
+
8
+ export type LenientOutcome = {
9
+ /** What failed, one line. */
10
+ failure: string
11
+ /** What it means for the customer, one or two lines. */
12
+ impact: string
13
+ /** A copy-pasteable retry command. */
14
+ retry: string
15
+ }
16
+
17
+ export const isStrict = (argv: string[]): boolean => argv.includes('--strict')
18
+
19
+ /** Strip --strict before handing argv to parseArgs. */
20
+ export const stripStrict = (argv: string[]): string[] =>
21
+ argv.filter((a) => a !== '--strict')
22
+
23
+ export const lenientFail = (strict: boolean, o: LenientOutcome): number => {
24
+ console.error(`[sentori] ⚠ ${o.failure}`)
25
+ if (!strict) {
26
+ console.error('[sentori] Your build is NOT blocked by this.')
27
+ }
28
+ console.error(`[sentori] Impact: ${o.impact}`)
29
+ console.error('[sentori] Retry anytime:')
30
+ console.error(`[sentori] ${o.retry}`)
31
+ console.error(
32
+ '[sentori] Events received in the meantime are re-symbolicated automatically after upload.',
33
+ )
34
+ if (strict) {
35
+ console.error('[sentori] (--strict: exiting non-zero)')
36
+ return 1
37
+ }
38
+ return 0
39
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,230 @@
1
+ // v1.2 W9 — Sentori MCP server.
2
+ //
3
+ // Stdio JSON-RPC 2.0 transport per the Model Context Protocol spec
4
+ // (https://modelcontextprotocol.io/). LLM clients (Claude Code,
5
+ // custom agents) spawn `sentori-cli mcp serve` as a subprocess and
6
+ // pipe MCP messages over stdin/stdout. Each tool call translates
7
+ // 1:1 to the existing admin API; the MCP layer is pure protocol
8
+ // glue + auth-passthrough.
9
+ //
10
+ // Why CLI-hosted instead of server-hosted MCP:
11
+ // - The Sentori server exposes admin endpoints over HTTPS already;
12
+ // spinning up a parallel MCP endpoint would duplicate auth +
13
+ // route boilerplate.
14
+ // - LLM clients expect MCP servers to be local stdio subprocesses
15
+ // (Claude Code config, gptme, etc.). The CLI is the natural
16
+ // binary to embed it in — the operator already has it installed
17
+ // and a token configured.
18
+ // - Easier to ship + version with the rest of the CLI.
19
+
20
+ import { createInterface } from 'node:readline'
21
+
22
+ type JsonRpcRequest = {
23
+ id?: number | string | null
24
+ jsonrpc: '2.0'
25
+ method: string
26
+ params?: Record<string, unknown>
27
+ }
28
+
29
+ type JsonRpcResponse = {
30
+ id?: number | string | null
31
+ jsonrpc: '2.0'
32
+ result?: unknown
33
+ error?: { code: number; message: string; data?: unknown }
34
+ }
35
+
36
+ type ToolHandler = (args: Record<string, unknown>, ctx: McpCtx) => Promise<unknown>
37
+
38
+ type ToolDef = {
39
+ name: string
40
+ description: string
41
+ inputSchema: Record<string, unknown>
42
+ handler: ToolHandler
43
+ }
44
+
45
+ type McpCtx = { apiUrl: string; token: string }
46
+
47
+ /** Run the MCP server over stdio. Returns when stdin closes. */
48
+ export async function runMcpServer(ctx: McpCtx): Promise<void> {
49
+ const tools = buildTools()
50
+ const toolMap = new Map(tools.map((t) => [t.name, t]))
51
+
52
+ const rl = createInterface({ input: process.stdin })
53
+ for await (const line of rl) {
54
+ const trimmed = line.trim()
55
+ if (!trimmed) continue
56
+ let req: JsonRpcRequest
57
+ try {
58
+ req = JSON.parse(trimmed)
59
+ } catch {
60
+ // Per spec, malformed requests get a parse-error response with
61
+ // null id.
62
+ send({
63
+ error: { code: -32700, message: 'Parse error' },
64
+ id: null,
65
+ jsonrpc: '2.0',
66
+ })
67
+ continue
68
+ }
69
+ // Notifications (no `id`) get no response.
70
+ const isNotification = req.id === undefined || req.id === null
71
+ try {
72
+ const result = await dispatch(req, toolMap, ctx, tools)
73
+ if (!isNotification) {
74
+ send({ id: req.id ?? null, jsonrpc: '2.0', result })
75
+ }
76
+ } catch (e) {
77
+ if (!isNotification) {
78
+ send({
79
+ error: { code: -32603, message: (e as Error).message },
80
+ id: req.id ?? null,
81
+ jsonrpc: '2.0',
82
+ })
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ function send(resp: JsonRpcResponse): void {
89
+ process.stdout.write(JSON.stringify(resp) + '\n')
90
+ }
91
+
92
+ async function dispatch(
93
+ req: JsonRpcRequest,
94
+ toolMap: Map<string, ToolDef>,
95
+ ctx: McpCtx,
96
+ tools: ToolDef[],
97
+ ): Promise<unknown> {
98
+ switch (req.method) {
99
+ case 'initialize':
100
+ return {
101
+ capabilities: { tools: {} },
102
+ protocolVersion: '2024-11-05',
103
+ serverInfo: { name: 'sentori', version: '1.0' },
104
+ }
105
+ case 'notifications/initialized':
106
+ return {}
107
+ case 'tools/list':
108
+ return {
109
+ tools: tools.map((t) => ({
110
+ description: t.description,
111
+ inputSchema: t.inputSchema,
112
+ name: t.name,
113
+ })),
114
+ }
115
+ case 'tools/call': {
116
+ const params = (req.params ?? {}) as { arguments?: Record<string, unknown>; name?: string }
117
+ const name = params.name
118
+ if (typeof name !== 'string') throw new Error('missing tools/call.name')
119
+ const tool = toolMap.get(name)
120
+ if (!tool) throw new Error(`unknown tool: ${name}`)
121
+ const result = await tool.handler(params.arguments ?? {}, ctx)
122
+ return {
123
+ content: [
124
+ {
125
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
126
+ type: 'text',
127
+ },
128
+ ],
129
+ }
130
+ }
131
+ default:
132
+ throw new Error(`method not found: ${req.method}`)
133
+ }
134
+ }
135
+
136
+ // ── Tool implementations — the /api closed loop ──────────────────
137
+ //
138
+ // Four tools, mirroring exactly what an agent needs (design.md §9):
139
+ // pick work, pull the evidence, write back, resolve. The bundle is
140
+ // the product; everything else is triage plumbing.
141
+
142
+ async function apiGet(ctx: McpCtx, path: string, raw = false): Promise<unknown> {
143
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}${path}`
144
+ const resp = await fetch(url, { headers: { Authorization: `Bearer ${ctx.token}` } })
145
+ if (!resp.ok) throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`)
146
+ return raw ? resp.text() : resp.json()
147
+ }
148
+
149
+ async function apiPost(ctx: McpCtx, path: string, body: unknown): Promise<unknown> {
150
+ const url = `${ctx.apiUrl.replace(/\/+$/, '')}${path}`
151
+ const resp = await fetch(url, {
152
+ body: JSON.stringify(body),
153
+ headers: {
154
+ Authorization: `Bearer ${ctx.token}`,
155
+ 'Content-Type': 'application/json',
156
+ },
157
+ method: 'POST',
158
+ })
159
+ if (!resp.ok) throw new Error(`POST ${path} → ${resp.status} ${resp.statusText}`)
160
+ return resp.json()
161
+ }
162
+
163
+ function asString(v: unknown, name: string): string {
164
+ if (typeof v !== 'string' || v.length === 0) throw new Error(`${name} must be a non-empty string`)
165
+ return v
166
+ }
167
+
168
+ export function buildTools(): ToolDef[] {
169
+ return [
170
+ {
171
+ name: 'sentori_issue_list',
172
+ description:
173
+ 'List issues (default: open, ordered by objective importance — regressed first, then breadth × depth). Filter with status (open|resolved|ignored) and kind (error|warn|trace|assert|probe).',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ status: { type: 'string', enum: ['open', 'resolved', 'ignored'] },
178
+ kind: { type: 'string', enum: ['assert', 'error', 'probe', 'trace', 'warn'] },
179
+ },
180
+ },
181
+ handler: async (args, ctx) => {
182
+ const q = new URLSearchParams()
183
+ if (typeof args.status === 'string') q.set('status', args.status)
184
+ if (typeof args.kind === 'string') q.set('kind', args.kind)
185
+ const qs = q.toString()
186
+ return apiGet(ctx, `/api/issues${qs ? `?${qs}` : ''}`)
187
+ },
188
+ },
189
+ {
190
+ name: 'sentori_issue_bundle',
191
+ description:
192
+ 'Fetch the full evidence bundle for one issue as markdown — read it and you have everything needed to fix: stack, user timeline, environment, distribution, guard-probe status.',
193
+ inputSchema: {
194
+ type: 'object',
195
+ properties: { issueId: { type: 'string' } },
196
+ required: ['issueId'],
197
+ },
198
+ handler: async (args, ctx) =>
199
+ apiGet(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/bundle`, true),
200
+ },
201
+ {
202
+ name: 'sentori_issue_note',
203
+ description:
204
+ 'Append a note to an issue — write back what you did ("fixed in abc123, probe SENT-42 planted").',
205
+ inputSchema: {
206
+ type: 'object',
207
+ properties: { issueId: { type: 'string' }, body: { type: 'string' } },
208
+ required: ['issueId', 'body'],
209
+ },
210
+ handler: async (args, ctx) =>
211
+ apiPost(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/notes`, {
212
+ body: asString(args.body, 'body'),
213
+ }),
214
+ },
215
+ {
216
+ name: 'sentori_issue_resolve',
217
+ description:
218
+ 'Resolve an issue, anchored on the release that carries the fix — only a recurrence in that release or newer counts as a regression.',
219
+ inputSchema: {
220
+ type: 'object',
221
+ properties: { issueId: { type: 'string' }, release: { type: 'string' } },
222
+ required: ['issueId'],
223
+ },
224
+ handler: async (args, ctx) =>
225
+ apiPost(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/resolve`, {
226
+ release: typeof args.release === 'string' ? args.release : undefined,
227
+ }),
228
+ },
229
+ ]
230
+ }
@@ -0,0 +1,175 @@
1
+ // `sentori-cli upload dsym` (iOS) + `sentori-cli upload mapping`
2
+ // (Android). dwarfdump slice discovery stays local; the bytes land
3
+ // on the unified artifacts endpoint (multipart kind + file).
4
+
5
+ import { spawnSync } from 'node:child_process'
6
+ import { readFile } from 'node:fs/promises'
7
+ import { basename, extname, join } from 'node:path'
8
+ import { statSync, readdirSync } from 'node:fs'
9
+
10
+ export type AdminUpload = {
11
+ apiUrl: string
12
+ release?: string
13
+ token: string
14
+ }
15
+
16
+ async function uploadBytes(opts: {
17
+ apiUrl: string
18
+ token: string
19
+ release: string
20
+ kind: 'dsym' | 'proguard'
21
+ name: string
22
+ bytes: Buffer
23
+ }): Promise<void> {
24
+ const form = new FormData()
25
+ form.append('kind', opts.kind)
26
+ form.append('file', new Blob([new Uint8Array(opts.bytes)]), opts.name)
27
+ const url = `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/${encodeURIComponent(opts.release)}/artifacts`
28
+ const resp = await fetch(url, {
29
+ body: form,
30
+ headers: { Authorization: `Bearer ${opts.token}` },
31
+ method: 'POST',
32
+ })
33
+ if (!resp.ok) {
34
+ const detail = await resp.text().catch(() => '')
35
+ throw new Error(
36
+ `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
37
+ )
38
+ }
39
+ }
40
+
41
+ // ── dSYM ──────────────────────────────────────────────────────────
42
+
43
+ export type DsymSlice = { arch: string; debugId: string; file: string }
44
+
45
+ const ARCHES = new Set([
46
+ 'arm64',
47
+ 'arm64_32',
48
+ 'arm64e',
49
+ 'armv7',
50
+ 'armv7k',
51
+ 'armv7s',
52
+ 'i386',
53
+ 'x86_64',
54
+ 'x86_64h',
55
+ ])
56
+
57
+ /**
58
+ * Use `dwarfdump --uuid <path>` to enumerate `(arch, debug_id, file)`
59
+ * for each Mach-O slice. Returns [] if dwarfdump isn't installed or the
60
+ * output couldn't be parsed; callers should fall back to explicit
61
+ * `--debug-id` / `--arch` flags in that case.
62
+ */
63
+ export function dsymSlicesFromDwarfdump(path: string): DsymSlice[] {
64
+ const r = spawnSync('dwarfdump', ['--uuid', path])
65
+ if (r.status !== 0 || !r.stdout) return []
66
+ const out: DsymSlice[] = []
67
+ // Output lines look like:
68
+ // UUID: 1234ABCD-... (arm64) /path/to/Foo.dSYM/Contents/Resources/DWARF/Foo
69
+ const re = /^UUID:\s+([0-9A-Fa-f-]{32,36})\s+\(([^)]+)\)\s+(.+)\s*$/m
70
+ for (const line of r.stdout.toString().split('\n')) {
71
+ const m = re.exec(line)
72
+ if (!m) continue
73
+ const [, debugId, arch, file] = m as unknown as [string, string, string, string]
74
+ if (!ARCHES.has(arch)) continue
75
+ out.push({ arch, debugId: debugId.toUpperCase(), file: file.trim() })
76
+ }
77
+ return out
78
+ }
79
+
80
+ /** Walk a `.dSYM` bundle and return the DWARF binary files inside
81
+ * `Contents/Resources/DWARF/`. If `path` already points at a binary
82
+ * (not a bundle), returns `[path]`. */
83
+ export function dwarfBinariesIn(path: string): string[] {
84
+ let st
85
+ try {
86
+ st = statSync(path)
87
+ } catch {
88
+ return []
89
+ }
90
+ if (st.isFile()) return [path]
91
+ const dwarfDir = join(path, 'Contents/Resources/DWARF')
92
+ try {
93
+ return readdirSync(dwarfDir)
94
+ .filter((n) => !n.startsWith('.'))
95
+ .map((n) => join(dwarfDir, n))
96
+ } catch {
97
+ // not a .dSYM bundle layout — try the top-level path
98
+ return [path]
99
+ }
100
+ }
101
+
102
+ export type DsymUploadOptions = AdminUpload & {
103
+ /** Explicit overrides when dwarfdump isn't available. */
104
+ arch?: string
105
+ debugId?: string
106
+ /** A `Foo.dSYM` bundle or a raw DWARF binary. */
107
+ path: string
108
+ objectName?: string
109
+ }
110
+
111
+ export type DsymUploadResult = { slices: { arch: string; debugId: string }[] }
112
+
113
+ export async function uploadDsym(opts: DsymUploadOptions): Promise<DsymUploadResult> {
114
+ const slices: DsymSlice[] = []
115
+ if (opts.debugId && opts.arch) {
116
+ // Explicit single-slice upload — no parsing needed.
117
+ const binaries = dwarfBinariesIn(opts.path)
118
+ if (binaries.length === 0) throw new Error(`no DWARF binary at: ${opts.path}`)
119
+ slices.push({ arch: opts.arch, debugId: opts.debugId.toUpperCase(), file: binaries[0]! })
120
+ } else {
121
+ // Auto-discover via dwarfdump.
122
+ const found = dsymSlicesFromDwarfdump(opts.path)
123
+ if (found.length === 0) {
124
+ throw new Error(
125
+ 'couldn’t enumerate dSYM slices — install Xcode command-line tools ' +
126
+ '(for `dwarfdump`), or pass --debug-id and --arch explicitly',
127
+ )
128
+ }
129
+ slices.push(...found)
130
+ }
131
+
132
+ const objectName = opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, '')
133
+ const uploaded: { arch: string; debugId: string }[] = []
134
+ for (const s of slices) {
135
+ const bytes = await readFile(s.file)
136
+ // One artifact per slice; the name carries the lookup identity
137
+ // (object-arch-debugId) until the dwarf wiring lands server-side.
138
+ await uploadBytes({
139
+ apiUrl: opts.apiUrl,
140
+ token: opts.token,
141
+ release: opts.release ?? '',
142
+ kind: 'dsym',
143
+ name: `${objectName}-${s.arch}-${s.debugId}`,
144
+ bytes,
145
+ })
146
+ uploaded.push({ arch: s.arch, debugId: s.debugId })
147
+ }
148
+ return { slices: uploaded }
149
+ }
150
+
151
+ // ── ProGuard / R8 mapping ─────────────────────────────────────────
152
+
153
+ export type MappingUploadOptions = AdminUpload & {
154
+ debugId?: string
155
+ path: string
156
+ }
157
+
158
+ export async function uploadMapping(opts: MappingUploadOptions): Promise<void> {
159
+ const ext = extname(opts.path).toLowerCase()
160
+ if (ext && ext !== '.txt' && ext !== '.map') {
161
+ // R8 emits `mapping.txt` by default; accept anything but warn.
162
+ console.warn(`[sentori-cli] upload mapping: unexpected extension ${ext} — uploading anyway`)
163
+ }
164
+ const body = await readFile(opts.path)
165
+ if (body.length === 0) throw new Error(`empty mapping file: ${opts.path}`)
166
+
167
+ await uploadBytes({
168
+ apiUrl: opts.apiUrl,
169
+ token: opts.token,
170
+ release: opts.release ?? '',
171
+ kind: 'proguard',
172
+ name: opts.debugId ? `mapping-${opts.debugId}.txt` : basename(opts.path),
173
+ bytes: body,
174
+ })
175
+ }
package/src/probes.ts ADDED
@@ -0,0 +1,75 @@
1
+ // `sentori-cli probes sync` — the tripwire registry scan
2
+ // (design.md §2). Statically scans source for `sentori.probe('REF')`
3
+ // / `probe("REF")` call sites and registers the refs against a
4
+ // release, so the server can tell a silent probe (fix holding) from
5
+ // deleted code.
6
+
7
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ const PROBE_RE = /\bprobe\(\s*['"`]([^'"`]{1,200})['"`]/g
11
+ const SCAN_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
12
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'lib', 'dist', 'build', '.expo'])
13
+ const MAX_FILES = 20_000
14
+
15
+ export function scanProbes(root: string): string[] {
16
+ const refs = new Set<string>()
17
+ let seen = 0
18
+ const walk = (dir: string): void => {
19
+ if (seen > MAX_FILES) return
20
+ let entries: string[]
21
+ try {
22
+ entries = readdirSync(dir)
23
+ } catch {
24
+ return
25
+ }
26
+ for (const name of entries) {
27
+ if (SKIP_DIRS.has(name)) continue
28
+ const p = join(dir, name)
29
+ let st
30
+ try {
31
+ st = statSync(p)
32
+ } catch {
33
+ continue
34
+ }
35
+ if (st.isDirectory()) {
36
+ walk(p)
37
+ } else if (st.isFile()) {
38
+ const dot = name.lastIndexOf('.')
39
+ if (dot === -1 || !SCAN_EXTS.has(name.slice(dot))) continue
40
+ seen += 1
41
+ try {
42
+ const text = readFileSync(p, 'utf8')
43
+ for (const m of text.matchAll(PROBE_RE)) {
44
+ const ref = m[1]
45
+ if (ref) refs.add(ref)
46
+ }
47
+ } catch {
48
+ // unreadable file — skip
49
+ }
50
+ }
51
+ }
52
+ }
53
+ walk(root)
54
+ return [...refs].sort()
55
+ }
56
+
57
+ export async function syncProbes(opts: {
58
+ apiUrl: string
59
+ token: string
60
+ release: string
61
+ refs: string[]
62
+ }): Promise<{ registered: number }> {
63
+ const resp = await fetch(`${opts.apiUrl.replace(/\/+$/, '')}/api/probes:sync`, {
64
+ method: 'POST',
65
+ headers: {
66
+ Authorization: `Bearer ${opts.token}`,
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ body: JSON.stringify({ release: opts.release, refs: opts.refs }),
70
+ })
71
+ if (!resp.ok) {
72
+ throw new Error(`probes:sync ${resp.status} ${await resp.text().catch(() => '')}`)
73
+ }
74
+ return (await resp.json()) as { registered: number }
75
+ }