@goliapkg/sentori-cli 1.5.0 → 1.7.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 +297 -26
- package/lib/apps.d.ts +24 -0
- package/lib/apps.d.ts.map +1 -0
- package/lib/apps.js +17 -0
- package/lib/apps.js.map +1 -0
- 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 +150 -68
- package/lib/index.js.map +1 -1
- package/lib/issue.d.ts +4 -0
- package/lib/issue.d.ts.map +1 -1
- package/lib/issue.js +8 -0
- package/lib/issue.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 +25 -0
- package/lib/upload.d.ts.map +1 -1
- package/lib/upload.js +52 -1
- 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 +10 -6
- package/src/__tests__/apps.test.ts +48 -0
- 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__/workflow.test.ts +102 -0
- package/src/apps.ts +31 -0
- package/src/artifacts.ts +12 -1
- package/src/compatibility.ts +21 -0
- package/src/index.ts +123 -69
- package/src/issue.ts +5 -1
- package/src/srcbundle.ts +150 -61
- package/src/upload.ts +51 -1
- 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,66 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { unzipSync, zipSync } from 'fflate'
|
|
7
|
+
import { collectSources, inspectSourceArchive, packSources, SOURCE_MANIFEST } from '../srcbundle.js'
|
|
8
|
+
|
|
9
|
+
let root: string
|
|
10
|
+
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'sentori-sources-test-')) })
|
|
11
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
|
12
|
+
function file(path: string, body: string) { const full = join(root, path); mkdirSync(join(full, '..'), { recursive: true }); writeFileSync(full, body) }
|
|
13
|
+
function git(...args: string[]) { return execFileSync('git', ['-C', root, '-c', 'user.name=Test', '-c', 'user.email=test@example.test', '-c', 'commit.gpgsign=false', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() }
|
|
14
|
+
|
|
15
|
+
describe('source archive collection', () => {
|
|
16
|
+
test('works without Git, preserves selected roots and excludes dependencies and secrets', () => {
|
|
17
|
+
file('ios/Pay.swift', 'ios code'); file('shared/Pay.swift', 'shared code')
|
|
18
|
+
file('ios/.env', 'secret'); file('ios/Pods/P.swift', 'dependency')
|
|
19
|
+
file('shared/tests/Test.swift', 'test code'); file('shared/index.ts', 'export {}')
|
|
20
|
+
const result = packSources(['ios', 'shared'], 'app@1+7', { root, exclude: ['shared/tests'] })
|
|
21
|
+
expect(result.manifest.files.map(x => x.path)).toEqual(['ios/Pay.swift', 'shared/Pay.swift', 'shared/index.ts'])
|
|
22
|
+
expect(inspectSourceArchive(result.bytes, 'app@1+7').files).toEqual(result.manifest.files.map(x => x.path))
|
|
23
|
+
expect(unzipSync(result.bytes)['shared/Pay.swift']).toEqual(Buffer.from('shared code'))
|
|
24
|
+
expect(packSources(['shared', 'ios'], 'app@1+7', { root, exclude: ['shared/tests'] }).bytes).toEqual(result.bytes)
|
|
25
|
+
})
|
|
26
|
+
test('explicitly selected generated sources are included without sweeping build output', () => {
|
|
27
|
+
file('src/a.kt', 'app'); file('build/generated/Bindings.kt', 'generated'); file('build/cache/Other.kt', 'cache')
|
|
28
|
+
expect(Object.keys(collectSources(['.'], { root }).bundle)).toEqual(['src/a.kt'])
|
|
29
|
+
expect(Object.keys(collectSources(['src', 'build/generated'], { root }).bundle)).toEqual(['src/a.kt', 'build/generated/Bindings.kt'])
|
|
30
|
+
})
|
|
31
|
+
test('an exact annotated tag ignores later commits and uncommitted files without checkout', () => {
|
|
32
|
+
git('init'); file('src/a.kt', 'tagged code'); git('add', '.'); git('commit', '-m', 'tagged')
|
|
33
|
+
git('-c', 'tag.gpgsign=false', 'tag', '-a', 'v1', '-m', 'release')
|
|
34
|
+
const commit = git('rev-parse', 'HEAD')
|
|
35
|
+
file('src/a.kt', 'later code'); git('add', '.'); git('commit', '-m', 'later')
|
|
36
|
+
file('src/a.kt', 'uncommitted'); file('src/new.kt', 'untracked')
|
|
37
|
+
const head = git('rev-parse', 'HEAD')
|
|
38
|
+
const packed = packSources(['src'], 'app@1+7', { root, gitTag: 'v1' })
|
|
39
|
+
expect(packed.manifest.source).toEqual({ type: 'git-tag', tag: 'v1', commit })
|
|
40
|
+
expect(unzipSync(packed.bytes)['src/a.kt']).toEqual(Buffer.from('tagged code'))
|
|
41
|
+
expect(packed.manifest.files).toHaveLength(1)
|
|
42
|
+
expect(readFileSync(join(root, 'src/a.kt'), 'utf8')).toBe('uncommitted')
|
|
43
|
+
expect(git('rev-parse', 'HEAD')).toBe(head)
|
|
44
|
+
expect(() => packSources(['src'], 'r', { root, gitTag: 'master' })).toThrow()
|
|
45
|
+
})
|
|
46
|
+
test('skips symbolic links and rejects selections escaping the declared root', () => {
|
|
47
|
+
file('src/a.kt', 'ok'); symlinkSync('/etc/passwd', join(root, 'src/secret.kt'))
|
|
48
|
+
expect(Object.keys(collectSources(['src'], { root }).bundle)).toEqual(['src/a.kt'])
|
|
49
|
+
expect(() => collectSources(['../'], { root })).toThrow('outside')
|
|
50
|
+
})
|
|
51
|
+
test('does not silently truncate oversized selected sources', () => {
|
|
52
|
+
file('src/huge.kt', 'x'.repeat(512 * 1024 + 1))
|
|
53
|
+
expect(() => packSources(['src'], 'r', { root })).toThrow('512 KiB')
|
|
54
|
+
})
|
|
55
|
+
test('validates external ZIP paths, release and manifest hashes', () => {
|
|
56
|
+
file('src/a.kt', 'code')
|
|
57
|
+
const packed = packSources(['src'], 'r1', { root })
|
|
58
|
+
expect(() => inspectSourceArchive(packed.bytes, 'r2')).toThrow('does not match')
|
|
59
|
+
const files = unzipSync(packed.bytes); files['src/a.kt'] = Buffer.from('tampered')
|
|
60
|
+
expect(() => inspectSourceArchive(zipSync(files), 'r1')).toThrow('checksum')
|
|
61
|
+
expect(() => inspectSourceArchive(zipSync({ '../bad.kt': Buffer.from('bad') }))).toThrow('unsafe')
|
|
62
|
+
expect(() => inspectSourceArchive(zipSync({}))).toThrow('empty')
|
|
63
|
+
expect(inspectSourceArchive(zipSync({ 'Sources/App.swift': Buffer.from('plain zip') })).files).toEqual(['Sources/App.swift'])
|
|
64
|
+
expect(unzipSync(packed.bytes)[SOURCE_MANIFEST]).toBeDefined()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -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/apps.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ApiConfig } from './issue.js'
|
|
2
|
+
|
|
3
|
+
export type AppSummary = {
|
|
4
|
+
id: string
|
|
5
|
+
projectId: string
|
|
6
|
+
name: string | null
|
|
7
|
+
identifier: string
|
|
8
|
+
platform: string
|
|
9
|
+
kind: string
|
|
10
|
+
events24h: number
|
|
11
|
+
errors24h: number
|
|
12
|
+
releaseCount: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function read<T>(config: ApiConfig, path: string): Promise<T> {
|
|
16
|
+
const response = await fetch(`${config.apiUrl.replace(/\/+$/, '')}${path}`, {
|
|
17
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
18
|
+
redirect: 'error',
|
|
19
|
+
signal: AbortSignal.timeout(30_000),
|
|
20
|
+
})
|
|
21
|
+
if (!response.ok) throw new Error(`Sentori API returned ${response.status}`)
|
|
22
|
+
return await response.json() as T
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function listApps(config: ApiConfig): Promise<{ projectId: string; apps: AppSummary[] }> {
|
|
26
|
+
return read(config, '/api/apps')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function showProject(config: ApiConfig): Promise<{ project: { id: string; name: string; createdAt: string } }> {
|
|
30
|
+
return read(config, '/api/project')
|
|
31
|
+
}
|
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,8 @@
|
|
|
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'
|
|
5
|
+
import { listApps, showProject } from './apps.js'
|
|
3
6
|
|
|
4
7
|
import {
|
|
5
8
|
fetchBundle,
|
|
@@ -22,24 +25,23 @@ import {
|
|
|
22
25
|
} from './push.js'
|
|
23
26
|
import { reactNativeUpload } from './react-native.js'
|
|
24
27
|
import { fetchArtifacts } from './artifacts.js'
|
|
25
|
-
import { uploadArtifact } from './upload.js'
|
|
28
|
+
import { expandSourcemapPaths, uploadArtifact } from './upload.js'
|
|
26
29
|
|
|
27
|
-
const HELP =
|
|
30
|
+
const HELP = `${WORKFLOW_HELP}\n\nsentori — Sentori command-line interface
|
|
28
31
|
|
|
29
32
|
Symbolication artifacts (api-scope token; failures NEVER block your
|
|
30
33
|
build — exit 0 with a friendly note unless --strict):
|
|
31
|
-
sentori
|
|
32
|
-
sentori
|
|
33
|
-
sentori
|
|
34
|
+
sentori upload sourcemap --release <r> --token <t> <path...>
|
|
35
|
+
sentori upload dsym --release <r> --token <t> <path.dSYM>
|
|
36
|
+
sentori upload mapping --release <r> --token <t> mapping.txt
|
|
34
37
|
(mapping = the R8/proguard map; stored as kind "proguard")
|
|
35
|
-
sentori
|
|
36
|
-
(
|
|
37
|
-
|
|
38
|
-
sentori-cli react-native upload --release <r> --token <t> \\
|
|
38
|
+
sentori upload srcbundle --release <r> --token <t> <src-dir...>
|
|
39
|
+
(ZIP or selected source directories; optional --git-tag pins a tag)
|
|
40
|
+
sentori react-native upload --release <r> --token <t> \\
|
|
39
41
|
--metro-map <m> --hermes-map <h> [--bundle <b>]
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
sentori
|
|
43
|
+
Legacy kind-presence gate (use verify for exact planned files):
|
|
44
|
+
sentori artifacts check --release <r> --token <t> [--expect dsym,sourcemap]
|
|
43
45
|
Asks the server what actually landed for this release. With
|
|
44
46
|
--expect, exits 1 when one of those kinds is missing. Uploads
|
|
45
47
|
never block your build; this is the step that does, and it
|
|
@@ -47,24 +49,28 @@ Release gate (the ONE command here that is allowed to fail):
|
|
|
47
49
|
the upload step that stopped being called at all.
|
|
48
50
|
|
|
49
51
|
Regression tripwires (design: probes):
|
|
50
|
-
sentori
|
|
52
|
+
sentori probes sync --release <r> --token <t> [--dir .]
|
|
51
53
|
Statically scans source for sentori.probe('REF') call sites and
|
|
52
54
|
registers them, so a silent probe is visibly alive.
|
|
53
55
|
|
|
54
56
|
CI triage (the same /api surface an AI agent uses):
|
|
55
|
-
sentori
|
|
56
|
-
sentori
|
|
57
|
-
sentori
|
|
58
|
-
|
|
57
|
+
sentori project show [--json] project selected by the API token
|
|
58
|
+
sentori app list [--json] apps within that project
|
|
59
|
+
sentori issue list [--status open] [--kind error] [--app <uuid>]
|
|
60
|
+
[--platform <platform>] [--environment <env>] [--release <release>] [--json]
|
|
61
|
+
sentori issue resolve <issue-id> [--in-release <r>]
|
|
62
|
+
sentori issue note <issue-id> --body "fixed in abc123"
|
|
63
|
+
sentori issue bundle <issue-id>
|
|
59
64
|
|
|
60
65
|
MCP (for Claude Code and friends):
|
|
61
|
-
sentori
|
|
66
|
+
sentori mcp serve --token <api-token> [--api-url <url>]
|
|
62
67
|
|
|
63
68
|
Push (carried):
|
|
64
|
-
sentori
|
|
69
|
+
sentori push send / receipt / creds ...
|
|
65
70
|
|
|
66
71
|
Common options:
|
|
67
|
-
--
|
|
72
|
+
--version installed CLI package version
|
|
73
|
+
--token api-scope token (or $SENTORI_API_TOKEN; legacy $SENTORI_TOKEN)
|
|
68
74
|
--api-url instance URL (or $SENTORI_API_URL; default https://sentori.golia.jp)
|
|
69
75
|
--strict upload commands: exit non-zero on failure
|
|
70
76
|
`
|
|
@@ -78,7 +84,7 @@ function parseCommon(values: Record<string, unknown>): Common | null {
|
|
|
78
84
|
return null
|
|
79
85
|
}
|
|
80
86
|
const token =
|
|
81
|
-
(typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
|
|
87
|
+
(typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_API_TOKEN ?? process.env.SENTORI_TOKEN
|
|
82
88
|
if (!token) {
|
|
83
89
|
console.error('error: --token (or $SENTORI_TOKEN) is required')
|
|
84
90
|
return null
|
|
@@ -96,6 +102,7 @@ type ApiOnly = { apiUrl: string; token: string }
|
|
|
96
102
|
function parseApiCfg(values: Record<string, unknown>): ApiOnly | null {
|
|
97
103
|
const token =
|
|
98
104
|
(typeof values.token === 'string' ? values.token : undefined) ??
|
|
105
|
+
process.env.SENTORI_API_TOKEN ??
|
|
99
106
|
process.env.SENTORI_ADMIN_TOKEN ??
|
|
100
107
|
process.env.SENTORI_TOKEN
|
|
101
108
|
if (!token) {
|
|
@@ -139,47 +146,42 @@ const UPLOAD_OPTS = {
|
|
|
139
146
|
|
|
140
147
|
async function cmdUploadSrcbundle(argv: string[]): Promise<number> {
|
|
141
148
|
const strict = isStrict(argv)
|
|
142
|
-
let parsed
|
|
143
149
|
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')
|
|
150
|
+
const parsed = parseArgs({ allowPositionals: true, args: stripStrict(argv), options: {
|
|
151
|
+
...UPLOAD_OPTS, root: { type: 'string' }, 'git-tag': { type: 'string' }, exclude: { type: 'string', multiple: true },
|
|
152
|
+
} })
|
|
153
|
+
if (parsed.values.help) { console.log(HELP); return 0 }
|
|
154
|
+
const c = parseCommon({ ...parsed.values, release: parsed.values.release ?? process.env.SENTORI_RELEASE })
|
|
155
|
+
if (!c) throw new Error('upload configuration is incomplete')
|
|
156
|
+
const { inspectSourceArchive, packSources } = await import('./srcbundle.js')
|
|
157
|
+
const { readFileSync, writeFileSync, mkdtempSync, rmSync } = await import('node:fs')
|
|
167
158
|
const { tmpdir } = await import('node:os')
|
|
168
159
|
const { join } = await import('node:path')
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
160
|
+
let temporary: string | undefined
|
|
161
|
+
try {
|
|
162
|
+
let path = parsed.positionals[0]
|
|
163
|
+
if (parsed.positionals.length === 1 && path?.toLowerCase().endsWith('.zip')) {
|
|
164
|
+
if (parsed.values['git-tag']) throw new Error('--git-tag applies to source directories, not an existing ZIP')
|
|
165
|
+
const archive = inspectSourceArchive(readFileSync(path), c.release)
|
|
166
|
+
console.log(`Checked ${archive.files.length} source files in ${path}`)
|
|
167
|
+
} else {
|
|
168
|
+
const packed = packSources(parsed.positionals, c.release, { root: parsed.values.root, exclude: parsed.values.exclude, gitTag: parsed.values['git-tag'] })
|
|
169
|
+
temporary = mkdtempSync(join(tmpdir(), 'sentori-sources-'))
|
|
170
|
+
path = join(temporary, 'sources.zip')
|
|
171
|
+
writeFileSync(path, packed.bytes)
|
|
172
|
+
console.log(JSON.stringify(packed.manifest, null, 2))
|
|
173
|
+
}
|
|
174
|
+
console.log(checkCompatibility(await fetchArtifacts(c), true))
|
|
175
|
+
const verdict = await uploadArtifact({ ...c, kind: 'srcbundle', path: path!, name: 'sources.zip' })
|
|
176
|
+
if (verdict.usable !== true) throw new Error('source bundle was stored but not verified by the server')
|
|
177
|
+
console.log(`Source bundle stored and validated for ${c.release}; source context requires matching file paths and line numbers.`)
|
|
178
|
+
return 0
|
|
179
|
+
} finally { if (temporary) rmSync(temporary, { recursive: true, force: true }) }
|
|
180
|
+
} catch (error) {
|
|
179
181
|
return lenientFail(strict, {
|
|
180
|
-
failure: `
|
|
181
|
-
impact:
|
|
182
|
-
retry:
|
|
182
|
+
failure: `source upload failed (${error instanceof Error ? error.message : String(error)})`,
|
|
183
|
+
impact: 'source context may be unavailable for this release',
|
|
184
|
+
retry: 'sentori upload srcbundle --release <exact-sdk-release> sources.zip',
|
|
183
185
|
})
|
|
184
186
|
}
|
|
185
187
|
}
|
|
@@ -204,18 +206,28 @@ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
|
|
|
204
206
|
return 2
|
|
205
207
|
}
|
|
206
208
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
// A directory expands to the maps under it, each keeping its path
|
|
210
|
+
// relative to that directory. Passing one used to throw EISDIR
|
|
211
|
+
// while the docs told people to pass `./dist`.
|
|
212
|
+
const targets = parsed.positionals.flatMap((p) => expandSourcemapPaths(p))
|
|
213
|
+
if (targets.length === 0) {
|
|
214
|
+
console.error(
|
|
215
|
+
`error: no .map files under ${parsed.positionals.join(', ')}`,
|
|
216
|
+
)
|
|
217
|
+
return 2
|
|
218
|
+
}
|
|
219
|
+
for (const t of targets) {
|
|
220
|
+
await uploadArtifact({ ...c, kind: 'sourcemap', path: t.path, name: t.name })
|
|
209
221
|
}
|
|
210
222
|
console.log(
|
|
211
|
-
`uploaded ${
|
|
223
|
+
`uploaded ${targets.length} sourcemap(s) for "${c.release}" — minified stacks on this release now resolve to source.`,
|
|
212
224
|
)
|
|
213
225
|
return 0
|
|
214
226
|
} catch (e) {
|
|
215
227
|
return lenientFail(strict, {
|
|
216
228
|
failure: `sourcemap upload failed (${(e as Error).message})`,
|
|
217
229
|
impact: `crashes from ${c.release} will show minified stacks until the map is uploaded.`,
|
|
218
|
-
retry: `sentori
|
|
230
|
+
retry: `sentori upload sourcemap --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
|
|
219
231
|
})
|
|
220
232
|
}
|
|
221
233
|
}
|
|
@@ -275,7 +287,7 @@ async function cmdUploadDsym(argv: string[]): Promise<number> {
|
|
|
275
287
|
return lenientFail(strict, {
|
|
276
288
|
failure: `dSYM upload failed (${(e as Error).message})`,
|
|
277
289
|
impact: `native iOS stacks from ${c.release} stay unsymbolicated until the dSYM lands.`,
|
|
278
|
-
retry: `sentori
|
|
290
|
+
retry: `sentori upload dsym --release "${c.release}" --token <t> ${path}`,
|
|
279
291
|
})
|
|
280
292
|
}
|
|
281
293
|
}
|
|
@@ -319,7 +331,7 @@ async function cmdUploadMapping(argv: string[]): Promise<number> {
|
|
|
319
331
|
return lenientFail(strict, {
|
|
320
332
|
failure: `mapping upload failed (${(e as Error).message})`,
|
|
321
333
|
impact: `Android stacks from ${c.release} stay R8-obfuscated until the mapping lands.`,
|
|
322
|
-
retry: `sentori
|
|
334
|
+
retry: `sentori upload mapping --release "${c.release}" --token <t> ${path}`,
|
|
323
335
|
})
|
|
324
336
|
}
|
|
325
337
|
}
|
|
@@ -370,7 +382,7 @@ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
|
|
|
370
382
|
return lenientFail(strict, {
|
|
371
383
|
failure: `react-native upload failed (${(e as Error).message})`,
|
|
372
384
|
impact: `Hermes stacks from ${c.release} stay unsymbolicated until the composed map lands.`,
|
|
373
|
-
retry: `sentori
|
|
385
|
+
retry: `sentori react-native upload --release "${c.release}" --token <t> --metro-map ${metroMap} --hermes-map ${hermesMap}`,
|
|
374
386
|
})
|
|
375
387
|
}
|
|
376
388
|
}
|
|
@@ -409,7 +421,7 @@ async function cmdProbesSync(argv: string[]): Promise<number> {
|
|
|
409
421
|
return lenientFail(strict, {
|
|
410
422
|
failure: `probes sync failed (${(e as Error).message})`,
|
|
411
423
|
impact: `silent probes on ${c.release} can't be told apart from deleted code until registered.`,
|
|
412
|
-
retry: `sentori
|
|
424
|
+
retry: `sentori probes sync --release "${c.release}" --token <t> --dir ${dir}`,
|
|
413
425
|
})
|
|
414
426
|
}
|
|
415
427
|
}
|
|
@@ -423,12 +435,37 @@ const ISSUE_OPTS = {
|
|
|
423
435
|
token: { type: 'string' },
|
|
424
436
|
} as const
|
|
425
437
|
|
|
438
|
+
async function cmdProjectApps(argv: string[], apps: boolean): Promise<number> {
|
|
439
|
+
try {
|
|
440
|
+
const { values } = parseArgs({ args: argv, options: { ...ISSUE_OPTS, json: { type: 'boolean' } } })
|
|
441
|
+
if (values.help) { console.log(HELP); return 0 }
|
|
442
|
+
const config = parseApiCfg(values)
|
|
443
|
+
if (!config) return 2
|
|
444
|
+
if (apps) {
|
|
445
|
+
const body = await listApps(config)
|
|
446
|
+
if (values.json) console.log(JSON.stringify(body, null, 2))
|
|
447
|
+
else {
|
|
448
|
+
console.log(`Project: ${body.projectId}`)
|
|
449
|
+
for (const app of body.apps) console.log(`${app.id} ${app.kind} ${(app.name ?? app.identifier) || '(unidentified)'} platform=${app.platform} identifier=${app.identifier || '(unknown)'}`)
|
|
450
|
+
if (!body.apps.length) console.log('(no apps)')
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
const body = await showProject(config)
|
|
454
|
+
console.log(values.json ? JSON.stringify(body, null, 2) : `${body.project.id} ${body.project.name}`)
|
|
455
|
+
}
|
|
456
|
+
return 0
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error(`${apps ? 'app list' : 'project show'} failed: ${(error as Error).message}`)
|
|
459
|
+
return 1
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
426
463
|
async function cmdIssueList(argv: string[]): Promise<number> {
|
|
427
464
|
let parsed
|
|
428
465
|
try {
|
|
429
466
|
parsed = parseArgs({
|
|
430
467
|
args: argv,
|
|
431
|
-
options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' } },
|
|
468
|
+
options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' }, app: { type: 'string' }, platform: { type: 'string' }, environment: { type: 'string' }, release: { type: 'string' }, json: { type: 'boolean' } },
|
|
432
469
|
})
|
|
433
470
|
} catch (e) {
|
|
434
471
|
console.error(`error: ${(e as Error).message}\n${HELP}`)
|
|
@@ -444,7 +481,12 @@ async function cmdIssueList(argv: string[]): Promise<number> {
|
|
|
444
481
|
const rows = await listIssues(cfg, {
|
|
445
482
|
kind: parsed.values.kind as string | undefined,
|
|
446
483
|
status: (parsed.values.status as string | undefined) ?? 'open',
|
|
484
|
+
app: parsed.values.app,
|
|
485
|
+
platform: parsed.values.platform,
|
|
486
|
+
environment: parsed.values.environment,
|
|
487
|
+
release: parsed.values.release,
|
|
447
488
|
})
|
|
489
|
+
if (parsed.values.json) { console.log(JSON.stringify({ issues: rows }, null, 2)); return 0 }
|
|
448
490
|
if (rows.length === 0) {
|
|
449
491
|
console.log('(no matching issues)')
|
|
450
492
|
return 0
|
|
@@ -585,12 +627,12 @@ async function cmdMcpServe(argv: string[]): Promise<number> {
|
|
|
585
627
|
const UNREADABLE_HINT: Record<string, string> = {
|
|
586
628
|
dsym:
|
|
587
629
|
'Upload the binary inside the .dSYM bundle ' +
|
|
588
|
-
'(Contents/Resources/DWARF/<name>), or point `sentori
|
|
630
|
+
'(Contents/Resources/DWARF/<name>), or point `sentori upload dsym` ' +
|
|
589
631
|
'at the .dSYM and let it find the slices.',
|
|
590
632
|
proguard: 'Upload build/outputs/mapping/<variant>/mapping.txt.',
|
|
591
633
|
sourcemap:
|
|
592
634
|
'For React Native upload the composed map — ' +
|
|
593
|
-
'`sentori
|
|
635
|
+
'`sentori react-native upload --metro-map <m> --hermes-map <h>` — not the bundle.',
|
|
594
636
|
}
|
|
595
637
|
|
|
596
638
|
async function cmdArtifactsCheck(argv: string[]): Promise<number> {
|
|
@@ -670,15 +712,27 @@ async function cmdArtifactsCheck(argv: string[]): Promise<number> {
|
|
|
670
712
|
}
|
|
671
713
|
|
|
672
714
|
async function main(argv: string[]): Promise<number> {
|
|
715
|
+
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-V')) {
|
|
716
|
+
console.log(`sentori ${CLI_VERSION}`)
|
|
717
|
+
return 0
|
|
718
|
+
}
|
|
673
719
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
674
720
|
console.log(HELP)
|
|
675
721
|
return 0
|
|
676
722
|
}
|
|
677
723
|
const [a, b, ...rest] = argv
|
|
724
|
+
if (a === 'project' && b === 'show') return cmdProjectApps(rest, false)
|
|
725
|
+
if (a === 'app' && b === 'list') return cmdProjectApps(rest, true)
|
|
726
|
+
if (a === 'init') return runWorkflow('init', argv.slice(1))
|
|
727
|
+
if (a === 'doctor') return runWorkflow('doctor', argv.slice(1))
|
|
728
|
+
if (a === 'verify') return runWorkflow('verify', argv.slice(1))
|
|
729
|
+
if (a === 'config' && b === 'validate') return runWorkflow('validate', rest)
|
|
730
|
+
if (a === 'upload' && b === 'all') return runWorkflow('upload', rest)
|
|
731
|
+
if (a === 'sources' && b === 'pack') return runSourcesPack(rest)
|
|
678
732
|
if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
|
|
679
733
|
if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
|
|
680
734
|
if (a === 'upload' && b === 'mapping') return cmdUploadMapping(rest)
|
|
681
|
-
if (a === 'upload' && b === 'srcbundle') return cmdUploadSrcbundle(rest)
|
|
735
|
+
if (a === 'upload' && (b === 'srcbundle' || b === 'source-bundle')) return cmdUploadSrcbundle(rest)
|
|
682
736
|
if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
|
|
683
737
|
if (a === 'artifacts' && b === 'check') return cmdArtifactsCheck(rest)
|
|
684
738
|
if (a === 'probes' && b === 'sync') return cmdProbesSync(rest)
|
package/src/issue.ts
CHANGED
|
@@ -39,11 +39,15 @@ async function apiFetch<T>(c: ApiConfig, path: string, init?: RequestInit): Prom
|
|
|
39
39
|
|
|
40
40
|
export async function listIssues(
|
|
41
41
|
c: ApiConfig,
|
|
42
|
-
opts: { status?: string; kind?: string } = {},
|
|
42
|
+
opts: { status?: string; kind?: string; app?: string; platform?: string; environment?: string; release?: string } = {},
|
|
43
43
|
): Promise<Issue[]> {
|
|
44
44
|
const q = new URLSearchParams()
|
|
45
45
|
if (opts.status) q.set('status', opts.status)
|
|
46
46
|
if (opts.kind) q.set('kind', opts.kind)
|
|
47
|
+
if (opts.app) q.set('app_id', opts.app)
|
|
48
|
+
if (opts.platform) q.set('platform', opts.platform)
|
|
49
|
+
if (opts.environment) q.set('environment', opts.environment)
|
|
50
|
+
if (opts.release) q.set('release', opts.release)
|
|
47
51
|
const qs = q.toString()
|
|
48
52
|
const body = await apiFetch<{ issues: Issue[] }>(c, `/api/issues${qs ? `?${qs}` : ''}`)
|
|
49
53
|
return body.issues
|