@goliapkg/sentori-cli 1.8.0 → 1.9.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.
@@ -3,16 +3,20 @@
3
3
  import { describe, expect, test } from 'bun:test'
4
4
 
5
5
  import { buildTools } from '../mcp.js'
6
+ import { fileURLToPath } from 'node:url'
6
7
 
7
8
  describe('mcp tools', () => {
8
9
  const tools = buildTools()
9
10
 
10
- test('exactly the four closed-loop tools exist', () => {
11
+ test('the closed loop includes durable watch', () => {
11
12
  expect(tools.map((t) => t.name).sort()).toEqual([
13
+ 'sentori_issue_attachment',
12
14
  'sentori_issue_bundle',
13
15
  'sentori_issue_list',
14
16
  'sentori_issue_note',
17
+ 'sentori_issue_read',
15
18
  'sentori_issue_resolve',
19
+ 'sentori_issue_watch',
16
20
  ])
17
21
  })
18
22
 
@@ -30,3 +34,126 @@ describe('mcp tools', () => {
30
34
  }
31
35
  })
32
36
  })
37
+
38
+ test('watch and evidence use the project API with credentials and lossless cursors', async () => {
39
+ const requests: { url: URL; auth: string | null }[] = []
40
+ const server = Bun.serve({
41
+ port: 0,
42
+ fetch(req) {
43
+ const url = new URL(req.url)
44
+ requests.push({ url, auth: req.headers.get('authorization') })
45
+ if (url.searchParams.get('format') === 'json') return Response.json({ schemaVersion: 1, evidence: { missing: ['stack'] } })
46
+ if (url.pathname.endsWith('/bundle')) return new Response('# Evidence')
47
+ return Response.json({ changes: [], nextCursor: 'v1:test:9223372036854775807', hasMore: false })
48
+ },
49
+ })
50
+ try {
51
+ const tools = buildTools()
52
+ const ctx = { apiUrl: server.url.toString(), token: 'test-only' }
53
+ const watch = tools.find((t) => t.name === 'sentori_issue_watch')!
54
+ const bundle = tools.find((t) => t.name === 'sentori_issue_bundle')!
55
+ const cursor = 'v1:test:9223372036854775807'
56
+ const result = await watch.handler({ cursor, limit: 17, waitSeconds: 25 }, ctx)
57
+ expect(result).toEqual({ changes: [], nextCursor: cursor, hasMore: false })
58
+ expect(requests[0].url.pathname).toBe('/api/issues/watch')
59
+ expect(requests[0].url.searchParams.get('cursor')).toBe(cursor)
60
+ expect(requests[0].url.searchParams.get('waitSeconds')).toBe('25')
61
+ expect(requests[0].auth).toBe('Bearer test-only')
62
+ await bundle.handler({ issueId: 'a/b', format: 'json' }, ctx)
63
+ expect(requests[1].url.pathname).toBe('/api/issues/a%2Fb/bundle')
64
+ expect(requests[1].url.searchParams.get('format')).toBe('json')
65
+ expect(await bundle.handler({ issueId: 'test' }, ctx)).toBe('# Evidence')
66
+ for (const args of [{ waitSeconds: 26 }, { limit: 0 }, { limit: 1.5 }]) {
67
+ await expect(watch.handler(args, ctx)).rejects.toThrow()
68
+ }
69
+ expect(requests).toHaveLength(3)
70
+ } finally { server.stop(true) }
71
+ })
72
+
73
+ test('stdio negotiates, stays responsive during watch, and separates protocol and tool errors', async () => {
74
+ const server = Bun.serve({
75
+ port: 0,
76
+ async fetch(req) {
77
+ if (new URL(req.url).pathname.endsWith('/attachments/test')) {
78
+ return new Response(Buffer.from('R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', 'base64'), { headers: { 'Content-Type': 'image/gif' } })
79
+ }
80
+ if (new URL(req.url).pathname.endsWith('/watch')) {
81
+ await Bun.sleep(200)
82
+ return Response.json({ schemaVersion: 1, changes: [], nextCursor: 'v1:test:0', hasMore: false })
83
+ }
84
+ return Response.json({ error: 'database_unavailable' }, { status: 503 })
85
+ },
86
+ })
87
+ const source = fileURLToPath(new URL('../mcp.ts', import.meta.url))
88
+ const requests = [
89
+ { id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25' } },
90
+ { id: 1, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 1 } } },
91
+ { id: 2, method: 'ping' },
92
+ { id: 3, method: 'tools/call', params: { name: 'sentori_issue_list' } },
93
+ { id: 4, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 26 } } },
94
+ { id: 5, method: 'unknown' },
95
+ { id: 6, method: 'tools/call', params: { name: 'missing' } },
96
+ { id: 7, method: 'tools/list' },
97
+ { id: 8, method: 'tools/call', params: { name: 'sentori_issue_attachment', arguments: { issueId: 'issue', attachmentRef: 'test' } } },
98
+ ].map((r) => JSON.stringify({ jsonrpc: '2.0', ...r })).join('\n') + '\nnull\n{broken\n'
99
+ try {
100
+ const child = Bun.spawn([process.execPath, '-e', `import {runMcpServer} from ${JSON.stringify(source)}; await runMcpServer({apiUrl: ${JSON.stringify(server.url.toString())}, token: 'test-only'})`], {
101
+ stdin: new Blob([requests]), stdout: 'pipe', stderr: 'pipe',
102
+ })
103
+ const output = await new Response(child.stdout).text()
104
+ expect(await child.exited).toBe(0)
105
+ const responses = output.trim().split('\n').map((line) => JSON.parse(line))
106
+ const byId = (id: number) => responses.find((r) => r.id === id)
107
+ expect(byId(0).result.protocolVersion).toBe('2025-11-25')
108
+ expect(byId(1).result.structuredContent.nextCursor).toBe('v1:test:0')
109
+ expect(responses.indexOf(byId(2))).toBeLessThan(responses.indexOf(byId(1)))
110
+ expect(byId(3).result.isError).toBe(true)
111
+ expect(byId(3).result.structuredContent.error).toEqual({ code: 'database_unavailable', status: 503, retryable: true })
112
+ expect(byId(4).result.isError).toBe(true)
113
+ expect(byId(5).error.code).toBe(-32601)
114
+ expect(byId(6).error.code).toBe(-32602)
115
+ expect(byId(7).result.tools.find((t: { name: string }) => t.name === 'sentori_issue_watch').annotations.readOnlyHint).toBe(true)
116
+ expect(byId(8).result.content[0]).toEqual({ type: 'image', mimeType: 'image/gif', data: 'R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' })
117
+ expect(responses.filter((r) => r.id === null).map((r) => r.error.code)).toEqual([-32600, -32700])
118
+ } finally { server.stop(true) }
119
+ })
120
+
121
+ test('attachment reads are bounded even without Content-Length', async () => {
122
+ const server = Bun.serve({
123
+ port: 0,
124
+ fetch() {
125
+ return new Response(new ReadableStream({
126
+ start(controller) {
127
+ controller.enqueue(new Uint8Array(1024 * 1024))
128
+ controller.enqueue(new Uint8Array(1))
129
+ controller.close()
130
+ },
131
+ }), { headers: { 'Content-Type': 'text/plain' } })
132
+ },
133
+ })
134
+ try {
135
+ const tool = buildTools().find((t) => t.name === 'sentori_issue_attachment')!
136
+ await expect(tool.handler({ issueId: 'i', attachmentRef: 'r' }, { apiUrl: server.url.toString(), token: 'test-only' })).rejects.toThrow('1 MiB')
137
+ } finally { server.stop(true) }
138
+ })
139
+
140
+ test('a cancelled watch releases the request without blocking subsequent calls', async () => {
141
+ const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => {}) })
142
+ const source = fileURLToPath(new URL('../mcp.ts', import.meta.url))
143
+ const input = [
144
+ { id: 1, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 25 } } },
145
+ { method: 'notifications/cancelled', params: { requestId: 1, reason: 'test cancellation' } },
146
+ { id: 2, method: 'ping' },
147
+ ].map((r) => JSON.stringify({ jsonrpc: '2.0', ...r })).join('\n') + '\n'
148
+ try {
149
+ const child = Bun.spawn([process.execPath, '-e', `import {runMcpServer} from ${JSON.stringify(source)}; await runMcpServer({apiUrl: ${JSON.stringify(server.url.toString())}, token: 'test-only'})`], {
150
+ stdin: new Blob([input]), stdout: 'pipe', stderr: 'pipe',
151
+ })
152
+ const output = await new Response(child.stdout).text()
153
+ expect(await child.exited).toBe(0)
154
+ const responses = output.trim().split('\n').map((line) => JSON.parse(line))
155
+ expect(responses).toHaveLength(2)
156
+ expect(responses.find((r) => r.id === 1).result.isError).toBe(true)
157
+ expect(responses.find((r) => r.id === 2).result).toEqual({})
158
+ } finally { server.stop(true) }
159
+ })
@@ -42,7 +42,8 @@ describe('unified release upload', () => {
42
42
  const bytes = gunzipSync(Buffer.from(await file.arrayBuffer()))
43
43
  const kind = String(form.get('kind'))
44
44
  if (kind === 'srcbundle') expect(inspectSourceArchive(bytes, 'app@1+7').files).toEqual(['src/a.ts'])
45
- uploaded.push({ kind, name: file.name.replace(/\.gz$/, ''), usable: true, contentHash: createHash('sha256').update(bytes).digest('hex') })
45
+ const contentHash = createHash('sha256').update(bytes).digest('hex')
46
+ uploaded.push({ kind, name: file.name.replace(/\.gz$/, ''), usable: true, contentHash, storageVerification: { integrity: 'verified', actualContentHash: contentHash, actualSizeBytes: bytes.length, usable: true } })
46
47
  return reply({ id: String(uploaded.length), usable: true }, 201)
47
48
  }
48
49
  return reply({ release: 'app@1+7', known: uploaded.length > 0, artifacts: uploaded,
@@ -135,3 +136,42 @@ test('matching project is verified before artifact compatibility and upload', as
135
136
  expect(calls[0]).toBe('https://upload.test/api/project')
136
137
  expect(calls).toHaveLength(2)
137
138
  })
139
+
140
+
141
+ test('verification fails closed for stale metadata and corrupted or missing stored bytes', async () => {
142
+ writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
143
+ const plan = prepareUploadPlan(config({ mapping: ['mapping.txt'] }))
144
+ const file = plan.files[0]!
145
+ const row = { kind: file.kind, name: file.name, contentHash: file.hash, usable: true }
146
+ for (const storageVerification of [undefined,
147
+ { integrity: 'mismatch', actualContentHash: file.hash, actualSizeBytes: 0, usable: true },
148
+ { integrity: 'unavailable' },
149
+ { integrity: 'verified', actualContentHash: 'wrong', actualSizeBytes: file.bytes, usable: true },
150
+ ]) {
151
+ globalThis.fetch = (async url => {
152
+ expect(String(url)).toEndWith('?verify=true')
153
+ return reply({ known: true, release: plan.release, artifacts: [{ ...row, storageVerification }] })
154
+ }) as typeof fetch
155
+ expect((await verifyUploadPlan(plan, 't'))[0]).toContain('stored bytes not verified')
156
+ }
157
+ })
158
+
159
+
160
+ test('strict upload preserves HTTP failure details even when verification also fails', async () => {
161
+ writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
162
+ const path = config({ mapping: ['mapping.txt'] })
163
+ let posted = false
164
+ globalThis.fetch = (async (_url, init) => {
165
+ if (init?.method === 'POST') { posted = true; return reply({ error: 'upstream unavailable' }, 502) }
166
+ if (posted) return reply({ error: 'verification unavailable' }, 503)
167
+ return reply({ known: false, release: 'app@1+7', artifacts: [] })
168
+ }) as typeof fetch
169
+ const originalError = console.error, messages: string[] = []
170
+ console.error = (...args) => { messages.push(args.join(' ')) }
171
+ try { expect(await runWorkflow('upload', ['--config', path, '--strict'])).toBe(1) }
172
+ finally { console.error = originalError }
173
+ expect(messages.join('\n')).toContain('mapping.txt: 502')
174
+ expect(messages.join('\n')).toContain('upstream unavailable')
175
+ expect(messages.join('\n')).toContain('verification request failed: 503')
176
+ expect(messages.join('\n')).not.toContain('api-test')
177
+ })
package/src/artifacts.ts CHANGED
@@ -31,6 +31,7 @@ export type ArtifactRow = {
31
31
  * the check existed — "never looked at", not "looked at and
32
32
  * fine". `false` means it stored and will symbolicate nothing. */
33
33
  usable?: boolean | null
34
+ storageVerification?: { integrity: string; actualContentHash?: string; actualSizeBytes?: number; usable?: boolean | null }
34
35
  }
35
36
 
36
37
  export type ArtifactsResponse = {
@@ -55,10 +56,11 @@ export async function fetchArtifacts(opts: {
55
56
  apiUrl: string
56
57
  release: string
57
58
  token: string
59
+ verify?: boolean
58
60
  }): Promise<ArtifactsResponse> {
59
61
  const url =
60
62
  `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/` +
61
- `${encodeURIComponent(opts.release)}/artifacts`
63
+ `${encodeURIComponent(opts.release)}/artifacts${opts.verify ? "?verify=true" : ""}`
62
64
  const resp = await fetch(url, {
63
65
  headers: { Authorization: `Bearer ${opts.token}` },
64
66
  })
@@ -9,7 +9,7 @@ export function checkCompatibility(result: ArtifactsResponse, sourceZip: boolean
9
9
  const info = result.compatibility
10
10
  if (info === undefined) {
11
11
  if (sourceZip) throw new Error('This server does not advertise source ZIP support. No files were uploaded. Upgrade the Sentori server to a build advertising artifact protocol 1 and sourceBundleFormats: ["zip"], then run sentori doctor. Existing CLI 1.5 pipelines may continue using legacy JSON sources.')
12
- return 'Legacy artifact API: server capabilities are not advertised. Source maps, dSYM and mapping uploads remain available; verification still requires matching SHA-256 bytes and usable: true.'
12
+ return 'Legacy artifact API: server capabilities are not advertised. Source maps, dSYM and mapping uploads remain available; verification requires a server that supplies fresh storageVerification proof; inventory metadata alone cannot pass.'
13
13
  }
14
14
  if (!info || typeof info.serverVersion !== 'string' || info.artifactProtocol !== 1
15
15
  || !Array.isArray(info.sourceBundleFormats) || !Array.isArray(info.verification)
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import { listApps, showProject } from './apps.js'
8
8
  import {
9
9
  fetchBundle,
10
10
  formatIssueLine,
11
- listIssues,
11
+ listIssuesPage,
12
12
  noteIssue,
13
13
  resolveIssue,
14
14
  } from './issue.js'
@@ -58,7 +58,7 @@ CI triage (the same /api surface an AI agent uses):
58
58
  sentori project show [--json] project selected by the API token
59
59
  sentori app list [--json] apps within that project
60
60
  sentori issue list [--status open] [--kind error] [--app <uuid>]
61
- [--platform <platform>] [--environment <env>] [--release <release>] [--json]
61
+ [--platform <platform>] [--environment <env>] [--release <release>] [--limit 50] [--offset 0] [--json]
62
62
  sentori issue resolve <issue-id> [--in-release <r>]
63
63
  sentori issue note <issue-id> --body "fixed in abc123"
64
64
  sentori issue bundle <issue-id>
@@ -466,7 +466,7 @@ async function cmdIssueList(argv: string[]): Promise<number> {
466
466
  try {
467
467
  parsed = parseArgs({
468
468
  args: argv,
469
- options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' }, app: { type: 'string' }, platform: { type: 'string' }, environment: { type: 'string' }, release: { type: 'string' }, json: { type: 'boolean' } },
469
+ options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' }, app: { type: 'string' }, platform: { type: 'string' }, environment: { type: 'string' }, release: { type: 'string' }, limit: { type: 'string' }, offset: { type: 'string' }, json: { type: 'boolean' } },
470
470
  })
471
471
  } catch (e) {
472
472
  console.error(`error: ${(e as Error).message}\n${HELP}`)
@@ -479,15 +479,18 @@ async function cmdIssueList(argv: string[]): Promise<number> {
479
479
  const cfg = parseApiCfg(parsed.values)
480
480
  if (!cfg) return 2
481
481
  try {
482
- const rows = await listIssues(cfg, {
482
+ const page = await listIssuesPage(cfg, {
483
483
  kind: parsed.values.kind as string | undefined,
484
484
  status: (parsed.values.status as string | undefined) ?? 'open',
485
485
  app: parsed.values.app,
486
486
  platform: parsed.values.platform,
487
487
  environment: parsed.values.environment,
488
488
  release: parsed.values.release,
489
+ limit: parsed.values.limit === undefined ? undefined : Number(parsed.values.limit),
490
+ offset: parsed.values.offset === undefined ? undefined : Number(parsed.values.offset),
489
491
  })
490
- if (parsed.values.json) { console.log(JSON.stringify({ issues: rows }, null, 2)); return 0 }
492
+ if (parsed.values.json) { console.log(JSON.stringify(page, null, 2)); return 0 }
493
+ const rows = page.issues
491
494
  if (rows.length === 0) {
492
495
  console.log('(no matching issues)')
493
496
  return 0
package/src/issue.ts CHANGED
@@ -37,10 +37,19 @@ async function apiFetch<T>(c: ApiConfig, path: string, init?: RequestInit): Prom
37
37
  return (await resp.json()) as T
38
38
  }
39
39
 
40
- export async function listIssues(
40
+ export type IssueListOptions = { status?: string; kind?: string; app?: string; platform?: string; environment?: string; release?: string; limit?: number; offset?: number }
41
+ export type IssuePage = { issues: Issue[]; total?: number; limit?: number; offset?: number; nextOffset?: number | null }
42
+
43
+ export async function listIssues(c: ApiConfig, opts: IssueListOptions = {}): Promise<Issue[]> {
44
+ return (await listIssuesPage(c, opts)).issues
45
+ }
46
+
47
+ export async function listIssuesPage(
41
48
  c: ApiConfig,
42
- opts: { status?: string; kind?: string; app?: string; platform?: string; environment?: string; release?: string } = {},
43
- ): Promise<Issue[]> {
49
+ opts: IssueListOptions = {},
50
+ ): Promise<IssuePage> {
51
+ if (opts.limit !== undefined && (!Number.isSafeInteger(opts.limit) || opts.limit < 1 || opts.limit > 200)) throw new Error('--limit must be an integer from 1 to 200')
52
+ if (opts.offset !== undefined && (!Number.isSafeInteger(opts.offset) || opts.offset < 0)) throw new Error('--offset must be a non-negative safe integer')
44
53
  const q = new URLSearchParams()
45
54
  if (opts.status) q.set('status', opts.status)
46
55
  if (opts.kind) q.set('kind', opts.kind)
@@ -48,9 +57,11 @@ export async function listIssues(
48
57
  if (opts.platform) q.set('platform', opts.platform)
49
58
  if (opts.environment) q.set('environment', opts.environment)
50
59
  if (opts.release) q.set('release', opts.release)
60
+ if (opts.limit !== undefined) q.set('limit', String(opts.limit))
61
+ if (opts.offset !== undefined) q.set('offset', String(opts.offset))
51
62
  const qs = q.toString()
52
- const body = await apiFetch<{ issues: Issue[] }>(c, `/api/issues${qs ? `?${qs}` : ''}`)
53
- return body.issues
63
+ const body = await apiFetch<IssuePage>(c, `/api/issues${qs ? `?${qs}` : ''}`)
64
+ return body
54
65
  }
55
66
 
56
67
  export async function resolveIssue(