@goliapkg/sentori-cli 1.4.1 → 1.6.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 +268 -26
- package/lib/artifacts.d.ts +7 -0
- package/lib/artifacts.d.ts.map +1 -1
- package/lib/artifacts.js +5 -1
- package/lib/artifacts.js.map +1 -1
- package/lib/compatibility.d.ts +6 -0
- package/lib/compatibility.d.ts.map +1 -0
- package/lib/compatibility.js +21 -0
- package/lib/compatibility.js.map +1 -0
- package/lib/index.js +97 -65
- package/lib/index.js.map +1 -1
- package/lib/lenient.js +1 -1
- package/lib/lenient.js.map +1 -1
- package/lib/mcp.js +1 -1
- package/lib/mcp.js.map +1 -1
- package/lib/native-artifacts.d.ts.map +1 -1
- package/lib/native-artifacts.js +5 -0
- package/lib/native-artifacts.js.map +1 -1
- package/lib/probes.js +1 -1
- package/lib/probes.js.map +1 -1
- package/lib/srcbundle.d.ts +37 -2
- package/lib/srcbundle.d.ts.map +1 -1
- package/lib/srcbundle.js +181 -58
- package/lib/srcbundle.js.map +1 -1
- package/lib/upload.d.ts +47 -2
- package/lib/upload.d.ts.map +1 -1
- package/lib/upload.js +65 -2
- package/lib/upload.js.map +1 -1
- package/lib/workflow.d.ts +47 -0
- package/lib/workflow.d.ts.map +1 -0
- package/lib/workflow.js +289 -0
- package/lib/workflow.js.map +1 -0
- package/package.json +11 -7
- package/src/__tests__/artifacts.test.ts +1 -1
- package/src/__tests__/compatibility.test.ts +80 -0
- package/src/__tests__/expand-sourcemaps.test.ts +56 -0
- package/src/__tests__/rn-upload-hook.test.ts +35 -0
- package/src/__tests__/srcbundle.test.ts +66 -0
- package/src/__tests__/upload-verdict.test.ts +77 -0
- package/src/__tests__/workflow.test.ts +102 -0
- package/src/artifacts.ts +12 -1
- package/src/compatibility.ts +21 -0
- package/src/index.ts +83 -66
- package/src/lenient.ts +1 -1
- package/src/mcp.ts +1 -1
- package/src/native-artifacts.ts +5 -0
- package/src/probes.ts +1 -1
- package/src/srcbundle.ts +150 -61
- package/src/upload.ts +88 -3
- package/src/workflow.ts +240 -0
- package/lib/source-bundle.d.ts +0 -28
- package/lib/source-bundle.d.ts.map +0 -1
- package/lib/source-bundle.js +0 -193
- package/lib/source-bundle.js.map +0 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The server has answered `usable: false` since 2.15.0, and its own
|
|
2
|
+
// comment in `artifacts_upload.rs` says "the response says so". It
|
|
3
|
+
// did. Nothing read it — both upload paths threw the body away and
|
|
4
|
+
// returned `{ id }` — so a Hermes bundle uploaded as a source map
|
|
5
|
+
// stored, printed nothing, and sat under a green light on the release
|
|
6
|
+
// page until somebody expanded the row months later.
|
|
7
|
+
//
|
|
8
|
+
// These pin the two halves that were missing: it warns, and it does
|
|
9
|
+
// not throw.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
12
|
+
|
|
13
|
+
import { warnIfUnusable } from '../upload.js'
|
|
14
|
+
|
|
15
|
+
/** Capture what the CLI prints, without printing it. */
|
|
16
|
+
function captured(fn: () => void): { warn: string[] } {
|
|
17
|
+
const warn: string[] = []
|
|
18
|
+
const orig = console.warn
|
|
19
|
+
console.warn = mock((...a: unknown[]) => void warn.push(a.join(' ')))
|
|
20
|
+
try {
|
|
21
|
+
fn()
|
|
22
|
+
} finally {
|
|
23
|
+
console.warn = orig
|
|
24
|
+
}
|
|
25
|
+
return { warn }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('what the server said about the file it stored', () => {
|
|
29
|
+
test('an unreadable artifact is named, with what to upload instead', () => {
|
|
30
|
+
const { warn } = captured(() => {
|
|
31
|
+
const said = warnIfUnusable('index.android.bundle', {
|
|
32
|
+
id: 'a',
|
|
33
|
+
usable: false,
|
|
34
|
+
hint: 'React Native: upload the generated .map, not the bundle.',
|
|
35
|
+
})
|
|
36
|
+
expect(said).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
expect(warn).toHaveLength(1)
|
|
39
|
+
// The file, so somebody with eight artifacts knows which one.
|
|
40
|
+
expect(warn[0]).toContain('index.android.bundle')
|
|
41
|
+
// And the server's own instruction, rather than our paraphrase.
|
|
42
|
+
expect(warn[0]).toContain('upload the generated .map')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('it warns rather than throwing — an upload may not fail a build', () => {
|
|
46
|
+
// The zero-cost rule: sentori cannot break a customer's release.
|
|
47
|
+
// The file did store; only its usefulness is in question.
|
|
48
|
+
expect(() =>
|
|
49
|
+
captured(() => warnIfUnusable('main.jsbundle', { id: 'a', usable: false })),
|
|
50
|
+
).not.toThrow()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a readable artifact says nothing', () => {
|
|
54
|
+
const { warn } = captured(() => {
|
|
55
|
+
expect(warnIfUnusable('app.map', { id: 'a', usable: true })).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
expect(warn).toHaveLength(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('"we did not look" is not "we looked and it is broken"', () => {
|
|
61
|
+
// `srcbundle` and anything uploaded before the check existed come
|
|
62
|
+
// back `null`. Warning on those would train people to ignore the
|
|
63
|
+
// warning that matters.
|
|
64
|
+
for (const usable of [null, undefined]) {
|
|
65
|
+
const { warn } = captured(() => {
|
|
66
|
+
expect(warnIfUnusable('srcbundle.json', { id: 'a', usable })).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
expect(warn).toHaveLength(0)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('a hint-less refusal still names the file', () => {
|
|
73
|
+
const { warn } = captured(() => warnIfUnusable('Insight.app-arm64', { id: 'a', usable: false }))
|
|
74
|
+
expect(warn[0]).toContain('Insight.app-arm64')
|
|
75
|
+
expect(warn[0]).toContain('cannot read')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { gunzipSync } from 'node:zlib'
|
|
7
|
+
import { loadUploadConfig, prepareUploadPlan, runWorkflow, verifyUploadPlan } from '../workflow.js'
|
|
8
|
+
import { inspectSourceArchive } from '../srcbundle.js'
|
|
9
|
+
|
|
10
|
+
let root: string
|
|
11
|
+
const realFetch = globalThis.fetch
|
|
12
|
+
const oldToken = process.env.SENTORI_TEST_UPLOAD_TOKEN
|
|
13
|
+
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'sentori-upload-test-')); process.env.SENTORI_TEST_UPLOAD_TOKEN = 'api-test' })
|
|
14
|
+
afterEach(() => { globalThis.fetch = realFetch; rmSync(root, { recursive: true, force: true }); if (oldToken === undefined) delete process.env.SENTORI_TEST_UPLOAD_TOKEN; else process.env.SENTORI_TEST_UPLOAD_TOKEN = oldToken })
|
|
15
|
+
function config(artifacts: object = {}, sources?: object) {
|
|
16
|
+
const path = join(root, 'sentori.config.json')
|
|
17
|
+
writeFileSync(path, JSON.stringify({ version: 1, apiUrl: 'https://upload.test', tokenEnv: 'SENTORI_TEST_UPLOAD_TOKEN', release: 'app@1+7', artifacts, ...(sources ? { sources } : {}) }))
|
|
18
|
+
return path
|
|
19
|
+
}
|
|
20
|
+
function reply(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) }
|
|
21
|
+
|
|
22
|
+
describe('unified release upload', () => {
|
|
23
|
+
test('configuration paths are relative to config, and init never overwrites a config', async () => {
|
|
24
|
+
writeFileSync(join(root, 'mapping.txt'), 'com.example.A -> a:\n void run() -> a\n')
|
|
25
|
+
const path = config({ mapping: ['mapping.txt'] })
|
|
26
|
+
expect(prepareUploadPlan(path).files[0]?.path).toBe(join(root, 'mapping.txt'))
|
|
27
|
+
expect(await runWorkflow('init', ['--config', path])).toBe(1)
|
|
28
|
+
expect(loadUploadConfig(path).tokenEnv).toBe('SENTORI_TEST_UPLOAD_TOKEN')
|
|
29
|
+
})
|
|
30
|
+
test('checks every source map, DWARF binary, mapping and source archive after upload', async () => {
|
|
31
|
+
writeFileSync(join(root, 'index.map'), JSON.stringify({ version: 3, sources: ['src/a.ts'], names: [], mappings: 'AAAA' }))
|
|
32
|
+
writeFileSync(join(root, 'mapping.txt'), 'com.example.A -> a:\n void run() -> a\n')
|
|
33
|
+
writeFileSync(join(root, 'dwarf'), Buffer.from('cffaedfe00000000', 'hex'))
|
|
34
|
+
mkdirSync(join(root, 'src')); writeFileSync(join(root, 'src/a.ts'), 'export const a = 1')
|
|
35
|
+
const path = config({ sourcemap: ['index.map'], dsym: ['dwarf'], mapping: ['mapping.txt'] }, { include: ['src'] })
|
|
36
|
+
const uploaded: any[] = []
|
|
37
|
+
globalThis.fetch = (async (url, init) => {
|
|
38
|
+
expect(String(url)).toContain('/v1/releases/app%401%2B7/artifacts')
|
|
39
|
+
expect((init?.headers as any).Authorization).toBe('Bearer api-test')
|
|
40
|
+
if (init?.method === 'POST') {
|
|
41
|
+
const form = init.body as FormData; const file = form.get('file') as File
|
|
42
|
+
const bytes = gunzipSync(Buffer.from(await file.arrayBuffer()))
|
|
43
|
+
const kind = String(form.get('kind'))
|
|
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') })
|
|
46
|
+
return reply({ id: String(uploaded.length), usable: true }, 201)
|
|
47
|
+
}
|
|
48
|
+
return reply({ release: 'app@1+7', known: uploaded.length > 0, artifacts: uploaded,
|
|
49
|
+
compatibility: { serverVersion: '5.1.12', artifactProtocol: 1, sourceBundleFormats: ['zip', 'json'], verification: ['sha256', 'format'] } })
|
|
50
|
+
}) as typeof fetch
|
|
51
|
+
expect(await runWorkflow('upload', ['--config', path, '--strict'])).toBe(0)
|
|
52
|
+
expect(uploaded.map(x => x.kind)).toEqual(['sourcemap', 'dsym', 'proguard', 'srcbundle'])
|
|
53
|
+
expect(await runWorkflow('verify', ['--config', path])).toBe(0)
|
|
54
|
+
})
|
|
55
|
+
test('a readable old artifact of the same kind cannot satisfy verification', async () => {
|
|
56
|
+
writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
|
|
57
|
+
const plan = prepareUploadPlan(config({ mapping: ['mapping.txt'] }))
|
|
58
|
+
globalThis.fetch = (async () => reply({ known: true, release: plan.release, artifacts: [{ kind: 'proguard', usable: true, contentHash: 'old' }] })) as typeof fetch
|
|
59
|
+
expect(await verifyUploadPlan(plan, 't')).toEqual(['missing exact bytes: proguard mapping.txt'])
|
|
60
|
+
globalThis.fetch = (async () => reply({ known: true, release: plan.release, artifacts: [{ kind: 'proguard', name: 'mapping.txt', usable: null, contentHash: plan.files[0]!.hash }] })) as typeof fetch
|
|
61
|
+
expect((await verifyUploadPlan(plan, 't'))[0]).toContain('not verified')
|
|
62
|
+
globalThis.fetch = (async () => reply({ known: true, release: plan.release, artifacts: [{ kind: 'proguard', name: 'another.txt', usable: true, contentHash: plan.files[0]!.hash }] })) as typeof fetch
|
|
63
|
+
expect((await verifyUploadPlan(plan, 't'))[0]).toContain('missing exact bytes')
|
|
64
|
+
})
|
|
65
|
+
test('dry-run needs no token or network; failures are lenient only for uploads', async () => {
|
|
66
|
+
writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
|
|
67
|
+
const path = config({ mapping: ['mapping.txt'] })
|
|
68
|
+
globalThis.fetch = (async () => { throw new Error('offline') }) as typeof fetch
|
|
69
|
+
delete process.env.SENTORI_TEST_UPLOAD_TOKEN
|
|
70
|
+
expect(await runWorkflow('upload', ['--config', path, '--dry-run'])).toBe(0)
|
|
71
|
+
expect(await runWorkflow('upload', ['--config', path])).toBe(0)
|
|
72
|
+
expect(await runWorkflow('upload', ['--config', path, '--strict'])).toBe(1)
|
|
73
|
+
expect(await runWorkflow('verify', ['--config', path])).toBe(1)
|
|
74
|
+
})
|
|
75
|
+
test('invalid local files prevent any upload', async () => {
|
|
76
|
+
writeFileSync(join(root, 'a.map'), 'not a map')
|
|
77
|
+
const path = config({ sourcemap: ['a.map'] })
|
|
78
|
+
globalThis.fetch = (async () => { throw new Error('network must not be called') }) as typeof fetch
|
|
79
|
+
expect(await runWorkflow('validate', ['--config', path])).toBe(1)
|
|
80
|
+
})
|
|
81
|
+
test('partial remote failure still uploads remaining files and fails strict verification', async () => {
|
|
82
|
+
writeFileSync(join(root, 'a.map'), JSON.stringify({ version: 3, sources: ['a.ts'], names: [], mappings: 'AAAA' }))
|
|
83
|
+
writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
|
|
84
|
+
const path = config({ sourcemap: ['a.map'], mapping: ['mapping.txt'] })
|
|
85
|
+
const attempted: string[] = [], uploaded: any[] = []
|
|
86
|
+
globalThis.fetch = (async (_url, init) => {
|
|
87
|
+
if (init?.method === 'POST') {
|
|
88
|
+
const form = init.body as FormData, kind = String(form.get('kind'))
|
|
89
|
+
attempted.push(kind)
|
|
90
|
+
if (kind === 'sourcemap') return reply({ error: 'injected rejection' }, 400)
|
|
91
|
+
const file = form.get('file') as File
|
|
92
|
+
uploaded.push({ kind, usable: true, contentHash: createHash('sha256').update(gunzipSync(Buffer.from(await file.arrayBuffer()))).digest('hex') })
|
|
93
|
+
return reply({ id: '1', usable: true }, 201)
|
|
94
|
+
}
|
|
95
|
+
return reply({ known: uploaded.length > 0, release: 'app@1+7', artifacts: uploaded })
|
|
96
|
+
}) as typeof fetch
|
|
97
|
+
expect(await runWorkflow('upload', ['--config', path, '--strict'])).toBe(1)
|
|
98
|
+
expect(attempted).toEqual(['sourcemap', 'proguard'])
|
|
99
|
+
expect(uploaded).toHaveLength(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
})
|
package/src/artifacts.ts
CHANGED
|
@@ -34,6 +34,13 @@ export type ArtifactRow = {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export type ArtifactsResponse = {
|
|
37
|
+
/** Additive capability advertisement. Absent on older servers. */
|
|
38
|
+
compatibility?: {
|
|
39
|
+
serverVersion: string
|
|
40
|
+
artifactProtocol: number
|
|
41
|
+
sourceBundleFormats: string[]
|
|
42
|
+
verification: string[]
|
|
43
|
+
}
|
|
37
44
|
release: string
|
|
38
45
|
/** False when the server has never heard this release name at all
|
|
39
46
|
* — a typo in `--release` and an un-uploaded release look the
|
|
@@ -61,5 +68,9 @@ export async function fetchArtifacts(opts: {
|
|
|
61
68
|
`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ''}`,
|
|
62
69
|
)
|
|
63
70
|
}
|
|
64
|
-
|
|
71
|
+
const result = await resp.json() as ArtifactsResponse
|
|
72
|
+
if (!result || result.release !== opts.release || typeof result.known !== 'boolean' || !Array.isArray(result.artifacts)) {
|
|
73
|
+
throw new Error('Unsupported artifact inventory response; check --api-url and upgrade the Sentori server or CLI together.')
|
|
74
|
+
}
|
|
75
|
+
return result
|
|
65
76
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import type { ArtifactsResponse } from './artifacts.js'
|
|
3
|
+
|
|
4
|
+
/** Read the installed package, so a release bump cannot leave a stale constant. */
|
|
5
|
+
export const CLI_VERSION: string = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
6
|
+
|
|
7
|
+
/** Capabilities, not a server version comparison: backports may share versions. */
|
|
8
|
+
export function checkCompatibility(result: ArtifactsResponse, sourceZip: boolean): string {
|
|
9
|
+
const info = result.compatibility
|
|
10
|
+
if (info === undefined) {
|
|
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.'
|
|
13
|
+
}
|
|
14
|
+
if (!info || typeof info.serverVersion !== 'string' || info.artifactProtocol !== 1
|
|
15
|
+
|| !Array.isArray(info.sourceBundleFormats) || !Array.isArray(info.verification)
|
|
16
|
+
|| !info.verification.includes('sha256') || !info.verification.includes('format')) {
|
|
17
|
+
throw new Error('Unsupported server artifact protocol or verification capabilities. No files were uploaded. Install a compatible CLI/server pair; use sentori --version and sentori doctor to inspect it.')
|
|
18
|
+
}
|
|
19
|
+
if (sourceZip && !info.sourceBundleFormats.includes('zip')) throw new Error(`Sentori server ${info.serverVersion} does not support source ZIPs. No files were uploaded. Upgrade the server before uploading sources.`)
|
|
20
|
+
return `Sentori CLI ${CLI_VERSION}; server ${info.serverVersion}; artifact protocol 1; source ZIP ${info.sourceBundleFormats.includes('zip') ? 'supported' : 'unavailable'}.`
|
|
21
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util'
|
|
3
|
+
import { runWorkflow, runSourcesPack, WORKFLOW_HELP } from './workflow.js'
|
|
4
|
+
import { checkCompatibility, CLI_VERSION } from './compatibility.js'
|
|
3
5
|
|
|
4
6
|
import {
|
|
5
7
|
fetchBundle,
|
|
@@ -22,24 +24,23 @@ import {
|
|
|
22
24
|
} from './push.js'
|
|
23
25
|
import { reactNativeUpload } from './react-native.js'
|
|
24
26
|
import { fetchArtifacts } from './artifacts.js'
|
|
25
|
-
import { uploadArtifact } from './upload.js'
|
|
27
|
+
import { expandSourcemapPaths, uploadArtifact } from './upload.js'
|
|
26
28
|
|
|
27
|
-
const HELP =
|
|
29
|
+
const HELP = `${WORKFLOW_HELP}\n\nsentori — Sentori command-line interface
|
|
28
30
|
|
|
29
31
|
Symbolication artifacts (api-scope token; failures NEVER block your
|
|
30
32
|
build — exit 0 with a friendly note unless --strict):
|
|
31
|
-
sentori
|
|
32
|
-
sentori
|
|
33
|
-
sentori
|
|
33
|
+
sentori upload sourcemap --release <r> --token <t> <path...>
|
|
34
|
+
sentori upload dsym --release <r> --token <t> <path.dSYM>
|
|
35
|
+
sentori upload mapping --release <r> --token <t> mapping.txt
|
|
34
36
|
(mapping = the R8/proguard map; stored as kind "proguard")
|
|
35
|
-
sentori
|
|
36
|
-
(
|
|
37
|
-
|
|
38
|
-
sentori-cli react-native upload --release <r> --token <t> \\
|
|
37
|
+
sentori upload srcbundle --release <r> --token <t> <src-dir...>
|
|
38
|
+
(ZIP or selected source directories; optional --git-tag pins a tag)
|
|
39
|
+
sentori react-native upload --release <r> --token <t> \\
|
|
39
40
|
--metro-map <m> --hermes-map <h> [--bundle <b>]
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
sentori
|
|
42
|
+
Legacy kind-presence gate (use verify for exact planned files):
|
|
43
|
+
sentori artifacts check --release <r> --token <t> [--expect dsym,sourcemap]
|
|
43
44
|
Asks the server what actually landed for this release. With
|
|
44
45
|
--expect, exits 1 when one of those kinds is missing. Uploads
|
|
45
46
|
never block your build; this is the step that does, and it
|
|
@@ -47,23 +48,24 @@ Release gate (the ONE command here that is allowed to fail):
|
|
|
47
48
|
the upload step that stopped being called at all.
|
|
48
49
|
|
|
49
50
|
Regression tripwires (design: probes):
|
|
50
|
-
sentori
|
|
51
|
+
sentori probes sync --release <r> --token <t> [--dir .]
|
|
51
52
|
Statically scans source for sentori.probe('REF') call sites and
|
|
52
53
|
registers them, so a silent probe is visibly alive.
|
|
53
54
|
|
|
54
55
|
CI triage (the same /api surface an AI agent uses):
|
|
55
|
-
sentori
|
|
56
|
-
sentori
|
|
57
|
-
sentori
|
|
58
|
-
sentori
|
|
56
|
+
sentori issue list [--status open] [--kind error]
|
|
57
|
+
sentori issue resolve <issue-id> [--in-release <r>]
|
|
58
|
+
sentori issue note <issue-id> --body "fixed in abc123"
|
|
59
|
+
sentori issue bundle <issue-id>
|
|
59
60
|
|
|
60
61
|
MCP (for Claude Code and friends):
|
|
61
|
-
sentori
|
|
62
|
+
sentori mcp serve --token <api-token> [--api-url <url>]
|
|
62
63
|
|
|
63
64
|
Push (carried):
|
|
64
|
-
sentori
|
|
65
|
+
sentori push send / receipt / creds ...
|
|
65
66
|
|
|
66
67
|
Common options:
|
|
68
|
+
--version installed CLI package version
|
|
67
69
|
--token api-scope token (or $SENTORI_TOKEN)
|
|
68
70
|
--api-url instance URL (or $SENTORI_API_URL; default https://sentori.golia.jp)
|
|
69
71
|
--strict upload commands: exit non-zero on failure
|
|
@@ -139,47 +141,42 @@ const UPLOAD_OPTS = {
|
|
|
139
141
|
|
|
140
142
|
async function cmdUploadSrcbundle(argv: string[]): Promise<number> {
|
|
141
143
|
const strict = isStrict(argv)
|
|
142
|
-
let parsed
|
|
143
144
|
try {
|
|
144
|
-
parsed = parseArgs({ allowPositionals: true, args: stripStrict(argv), options:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
const c = parseCommon(parsed.values)
|
|
154
|
-
if (!c) return 2
|
|
155
|
-
if (parsed.positionals.length === 0) {
|
|
156
|
-
console.error('error: at least one source directory is required')
|
|
157
|
-
return 2
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
const { collectSources } = await import('./srcbundle.js')
|
|
161
|
-
const { bundle, stats } = collectSources(parsed.positionals)
|
|
162
|
-
if (stats.files === 0) {
|
|
163
|
-
console.error('error: no native source files found under the given directories')
|
|
164
|
-
return 2
|
|
165
|
-
}
|
|
166
|
-
const { writeFileSync, mkdtempSync } = await import('node:fs')
|
|
145
|
+
const parsed = parseArgs({ allowPositionals: true, args: stripStrict(argv), options: {
|
|
146
|
+
...UPLOAD_OPTS, root: { type: 'string' }, 'git-tag': { type: 'string' }, exclude: { type: 'string', multiple: true },
|
|
147
|
+
} })
|
|
148
|
+
if (parsed.values.help) { console.log(HELP); return 0 }
|
|
149
|
+
const c = parseCommon({ ...parsed.values, release: parsed.values.release ?? process.env.SENTORI_RELEASE })
|
|
150
|
+
if (!c) throw new Error('upload configuration is incomplete')
|
|
151
|
+
const { inspectSourceArchive, packSources } = await import('./srcbundle.js')
|
|
152
|
+
const { readFileSync, writeFileSync, mkdtempSync, rmSync } = await import('node:fs')
|
|
167
153
|
const { tmpdir } = await import('node:os')
|
|
168
154
|
const { join } = await import('node:path')
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
155
|
+
let temporary: string | undefined
|
|
156
|
+
try {
|
|
157
|
+
let path = parsed.positionals[0]
|
|
158
|
+
if (parsed.positionals.length === 1 && path?.toLowerCase().endsWith('.zip')) {
|
|
159
|
+
if (parsed.values['git-tag']) throw new Error('--git-tag applies to source directories, not an existing ZIP')
|
|
160
|
+
const archive = inspectSourceArchive(readFileSync(path), c.release)
|
|
161
|
+
console.log(`Checked ${archive.files.length} source files in ${path}`)
|
|
162
|
+
} else {
|
|
163
|
+
const packed = packSources(parsed.positionals, c.release, { root: parsed.values.root, exclude: parsed.values.exclude, gitTag: parsed.values['git-tag'] })
|
|
164
|
+
temporary = mkdtempSync(join(tmpdir(), 'sentori-sources-'))
|
|
165
|
+
path = join(temporary, 'sources.zip')
|
|
166
|
+
writeFileSync(path, packed.bytes)
|
|
167
|
+
console.log(JSON.stringify(packed.manifest, null, 2))
|
|
168
|
+
}
|
|
169
|
+
console.log(checkCompatibility(await fetchArtifacts(c), true))
|
|
170
|
+
const verdict = await uploadArtifact({ ...c, kind: 'srcbundle', path: path!, name: 'sources.zip' })
|
|
171
|
+
if (verdict.usable !== true) throw new Error('source bundle was stored but not verified by the server')
|
|
172
|
+
console.log(`Source bundle stored and validated for ${c.release}; source context requires matching file paths and line numbers.`)
|
|
173
|
+
return 0
|
|
174
|
+
} finally { if (temporary) rmSync(temporary, { recursive: true, force: true }) }
|
|
175
|
+
} catch (error) {
|
|
179
176
|
return lenientFail(strict, {
|
|
180
|
-
failure: `
|
|
181
|
-
impact:
|
|
182
|
-
retry:
|
|
177
|
+
failure: `source upload failed (${error instanceof Error ? error.message : String(error)})`,
|
|
178
|
+
impact: 'source context may be unavailable for this release',
|
|
179
|
+
retry: 'sentori upload srcbundle --release <exact-sdk-release> sources.zip',
|
|
183
180
|
})
|
|
184
181
|
}
|
|
185
182
|
}
|
|
@@ -204,18 +201,28 @@ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
|
|
|
204
201
|
return 2
|
|
205
202
|
}
|
|
206
203
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
204
|
+
// A directory expands to the maps under it, each keeping its path
|
|
205
|
+
// relative to that directory. Passing one used to throw EISDIR
|
|
206
|
+
// while the docs told people to pass `./dist`.
|
|
207
|
+
const targets = parsed.positionals.flatMap((p) => expandSourcemapPaths(p))
|
|
208
|
+
if (targets.length === 0) {
|
|
209
|
+
console.error(
|
|
210
|
+
`error: no .map files under ${parsed.positionals.join(', ')}`,
|
|
211
|
+
)
|
|
212
|
+
return 2
|
|
213
|
+
}
|
|
214
|
+
for (const t of targets) {
|
|
215
|
+
await uploadArtifact({ ...c, kind: 'sourcemap', path: t.path, name: t.name })
|
|
209
216
|
}
|
|
210
217
|
console.log(
|
|
211
|
-
`uploaded ${
|
|
218
|
+
`uploaded ${targets.length} sourcemap(s) for "${c.release}" — minified stacks on this release now resolve to source.`,
|
|
212
219
|
)
|
|
213
220
|
return 0
|
|
214
221
|
} catch (e) {
|
|
215
222
|
return lenientFail(strict, {
|
|
216
223
|
failure: `sourcemap upload failed (${(e as Error).message})`,
|
|
217
224
|
impact: `crashes from ${c.release} will show minified stacks until the map is uploaded.`,
|
|
218
|
-
retry: `sentori
|
|
225
|
+
retry: `sentori upload sourcemap --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
|
|
219
226
|
})
|
|
220
227
|
}
|
|
221
228
|
}
|
|
@@ -275,7 +282,7 @@ async function cmdUploadDsym(argv: string[]): Promise<number> {
|
|
|
275
282
|
return lenientFail(strict, {
|
|
276
283
|
failure: `dSYM upload failed (${(e as Error).message})`,
|
|
277
284
|
impact: `native iOS stacks from ${c.release} stay unsymbolicated until the dSYM lands.`,
|
|
278
|
-
retry: `sentori
|
|
285
|
+
retry: `sentori upload dsym --release "${c.release}" --token <t> ${path}`,
|
|
279
286
|
})
|
|
280
287
|
}
|
|
281
288
|
}
|
|
@@ -319,7 +326,7 @@ async function cmdUploadMapping(argv: string[]): Promise<number> {
|
|
|
319
326
|
return lenientFail(strict, {
|
|
320
327
|
failure: `mapping upload failed (${(e as Error).message})`,
|
|
321
328
|
impact: `Android stacks from ${c.release} stay R8-obfuscated until the mapping lands.`,
|
|
322
|
-
retry: `sentori
|
|
329
|
+
retry: `sentori upload mapping --release "${c.release}" --token <t> ${path}`,
|
|
323
330
|
})
|
|
324
331
|
}
|
|
325
332
|
}
|
|
@@ -370,7 +377,7 @@ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
|
|
|
370
377
|
return lenientFail(strict, {
|
|
371
378
|
failure: `react-native upload failed (${(e as Error).message})`,
|
|
372
379
|
impact: `Hermes stacks from ${c.release} stay unsymbolicated until the composed map lands.`,
|
|
373
|
-
retry: `sentori
|
|
380
|
+
retry: `sentori react-native upload --release "${c.release}" --token <t> --metro-map ${metroMap} --hermes-map ${hermesMap}`,
|
|
374
381
|
})
|
|
375
382
|
}
|
|
376
383
|
}
|
|
@@ -409,7 +416,7 @@ async function cmdProbesSync(argv: string[]): Promise<number> {
|
|
|
409
416
|
return lenientFail(strict, {
|
|
410
417
|
failure: `probes sync failed (${(e as Error).message})`,
|
|
411
418
|
impact: `silent probes on ${c.release} can't be told apart from deleted code until registered.`,
|
|
412
|
-
retry: `sentori
|
|
419
|
+
retry: `sentori probes sync --release "${c.release}" --token <t> --dir ${dir}`,
|
|
413
420
|
})
|
|
414
421
|
}
|
|
415
422
|
}
|
|
@@ -585,12 +592,12 @@ async function cmdMcpServe(argv: string[]): Promise<number> {
|
|
|
585
592
|
const UNREADABLE_HINT: Record<string, string> = {
|
|
586
593
|
dsym:
|
|
587
594
|
'Upload the binary inside the .dSYM bundle ' +
|
|
588
|
-
'(Contents/Resources/DWARF/<name>), or point `sentori
|
|
595
|
+
'(Contents/Resources/DWARF/<name>), or point `sentori upload dsym` ' +
|
|
589
596
|
'at the .dSYM and let it find the slices.',
|
|
590
597
|
proguard: 'Upload build/outputs/mapping/<variant>/mapping.txt.',
|
|
591
598
|
sourcemap:
|
|
592
599
|
'For React Native upload the composed map — ' +
|
|
593
|
-
'`sentori
|
|
600
|
+
'`sentori react-native upload --metro-map <m> --hermes-map <h>` — not the bundle.',
|
|
594
601
|
}
|
|
595
602
|
|
|
596
603
|
async function cmdArtifactsCheck(argv: string[]): Promise<number> {
|
|
@@ -670,15 +677,25 @@ async function cmdArtifactsCheck(argv: string[]): Promise<number> {
|
|
|
670
677
|
}
|
|
671
678
|
|
|
672
679
|
async function main(argv: string[]): Promise<number> {
|
|
680
|
+
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-V')) {
|
|
681
|
+
console.log(`sentori ${CLI_VERSION}`)
|
|
682
|
+
return 0
|
|
683
|
+
}
|
|
673
684
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
674
685
|
console.log(HELP)
|
|
675
686
|
return 0
|
|
676
687
|
}
|
|
677
688
|
const [a, b, ...rest] = argv
|
|
689
|
+
if (a === 'init') return runWorkflow('init', argv.slice(1))
|
|
690
|
+
if (a === 'doctor') return runWorkflow('doctor', argv.slice(1))
|
|
691
|
+
if (a === 'verify') return runWorkflow('verify', argv.slice(1))
|
|
692
|
+
if (a === 'config' && b === 'validate') return runWorkflow('validate', rest)
|
|
693
|
+
if (a === 'upload' && b === 'all') return runWorkflow('upload', rest)
|
|
694
|
+
if (a === 'sources' && b === 'pack') return runSourcesPack(rest)
|
|
678
695
|
if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
|
|
679
696
|
if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
|
|
680
697
|
if (a === 'upload' && b === 'mapping') return cmdUploadMapping(rest)
|
|
681
|
-
if (a === 'upload' && b === 'srcbundle') return cmdUploadSrcbundle(rest)
|
|
698
|
+
if (a === 'upload' && (b === 'srcbundle' || b === 'source-bundle')) return cmdUploadSrcbundle(rest)
|
|
682
699
|
if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
|
|
683
700
|
if (a === 'artifacts' && b === 'check') return cmdArtifactsCheck(rest)
|
|
684
701
|
if (a === 'probes' && b === 'sync') return cmdProbesSync(rest)
|
package/src/lenient.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The iron rule reaches build-time
|
|
1
|
+
// The iron rule reaches build-time: an upload
|
|
2
2
|
// failure must never break the customer's build or release. Every
|
|
3
3
|
// upload-family command runs through this wrapper — on failure it
|
|
4
4
|
// prints a friendly, actionable note and exits 0. `--strict` opts
|
package/src/mcp.ts
CHANGED
|
@@ -135,7 +135,7 @@ async function dispatch(
|
|
|
135
135
|
|
|
136
136
|
// ── Tool implementations — the /api closed loop ──────────────────
|
|
137
137
|
//
|
|
138
|
-
// Four tools, mirroring exactly what an agent needs
|
|
138
|
+
// Four tools, mirroring exactly what an agent needs:
|
|
139
139
|
// pick work, pull the evidence, write back, resolve. The bundle is
|
|
140
140
|
// the product; everything else is triage plumbing.
|
|
141
141
|
|
package/src/native-artifacts.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { basename, extname, join } from 'node:path'
|
|
|
8
8
|
import { statSync, readdirSync } from 'node:fs'
|
|
9
9
|
|
|
10
10
|
import { gzipForWire, wireArrayBuffer } from './upload.js'
|
|
11
|
+
import { type UploadVerdict, warnIfUnusable } from './upload.js'
|
|
11
12
|
|
|
12
13
|
export type AdminUpload = {
|
|
13
14
|
apiUrl: string
|
|
@@ -41,6 +42,10 @@ async function uploadBytes(opts: {
|
|
|
41
42
|
`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
|
|
42
43
|
)
|
|
43
44
|
}
|
|
45
|
+
// The dSYM path posts its own request rather than going through
|
|
46
|
+
// `uploadArtifact`, so it needs the same read — this is the half
|
|
47
|
+
// that would have gone quiet again.
|
|
48
|
+
warnIfUnusable(opts.name, (await resp.json().catch(() => ({}))) as UploadVerdict)
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
// ── dSYM ──────────────────────────────────────────────────────────
|
package/src/probes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `sentori-cli probes sync` — the tripwire registry scan
|
|
2
|
-
//
|
|
2
|
+
// Statically scans source for `sentori.probe('REF')`
|
|
3
3
|
// / `probe("REF")` call sites and registers the refs against a
|
|
4
4
|
// release, so the server can tell a silent probe (fix holding) from
|
|
5
5
|
// deleted code.
|