@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.
- package/README.md +27 -32
- package/lib/index.d.ts +3 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +723 -0
- package/lib/index.js.map +1 -0
- package/lib/issue.d.ts +26 -0
- package/lib/issue.d.ts.map +1 -0
- package/lib/issue.js +50 -0
- package/lib/issue.js.map +1 -0
- package/lib/lenient.d.ts +13 -0
- package/lib/lenient.d.ts.map +1 -0
- package/lib/lenient.js +25 -0
- package/lib/lenient.js.map +1 -0
- package/lib/mcp.d.ts +16 -0
- package/lib/mcp.d.ts.map +1 -0
- package/lib/mcp.js +194 -0
- package/lib/mcp.js.map +1 -0
- package/lib/native-artifacts.d.ts +42 -0
- package/lib/native-artifacts.d.ts.map +1 -0
- package/lib/native-artifacts.js +137 -0
- package/lib/native-artifacts.js.map +1 -0
- package/lib/probes.d.ts +10 -0
- package/lib/probes.d.ts.map +1 -0
- package/lib/probes.js +75 -0
- package/lib/probes.js.map +1 -0
- package/lib/push.d.ts +42 -0
- package/lib/push.d.ts.map +1 -0
- package/lib/push.js +92 -0
- package/lib/push.js.map +1 -0
- package/lib/react-native.d.ts +25 -0
- package/lib/react-native.d.ts.map +1 -0
- package/lib/react-native.js +82 -0
- package/lib/react-native.js.map +1 -0
- package/lib/source-bundle.d.ts +28 -0
- package/lib/source-bundle.d.ts.map +1 -0
- package/lib/source-bundle.js +193 -0
- package/lib/source-bundle.js.map +1 -0
- package/lib/upload.d.ts +13 -0
- package/lib/upload.d.ts.map +1 -0
- package/lib/upload.js +28 -0
- package/lib/upload.js.map +1 -0
- package/package.json +23 -13
- package/src/__tests__/lenient.test.ts +32 -0
- package/src/__tests__/mcp.test.ts +32 -0
- package/src/__tests__/native-artifacts.test.ts +125 -0
- package/src/__tests__/probes.test.ts +41 -0
- package/src/__tests__/react-native.test.ts +37 -0
- package/src/index.ts +727 -0
- package/src/issue.ts +82 -0
- package/src/lenient.ts +39 -0
- package/src/mcp.ts +230 -0
- package/src/native-artifacts.ts +175 -0
- package/src/probes.ts +75 -0
- package/src/push.ts +174 -0
- package/src/react-native.ts +94 -0
- package/src/upload.ts +44 -0
- package/bin/sentori-cli.js +0 -32
- 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
|
+
}
|