@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.
Files changed (51) hide show
  1. package/README.md +297 -26
  2. package/lib/apps.d.ts +24 -0
  3. package/lib/apps.d.ts.map +1 -0
  4. package/lib/apps.js +17 -0
  5. package/lib/apps.js.map +1 -0
  6. package/lib/artifacts.d.ts +7 -0
  7. package/lib/artifacts.d.ts.map +1 -1
  8. package/lib/artifacts.js +5 -1
  9. package/lib/artifacts.js.map +1 -1
  10. package/lib/compatibility.d.ts +6 -0
  11. package/lib/compatibility.d.ts.map +1 -0
  12. package/lib/compatibility.js +21 -0
  13. package/lib/compatibility.js.map +1 -0
  14. package/lib/index.js +150 -68
  15. package/lib/index.js.map +1 -1
  16. package/lib/issue.d.ts +4 -0
  17. package/lib/issue.d.ts.map +1 -1
  18. package/lib/issue.js +8 -0
  19. package/lib/issue.js.map +1 -1
  20. package/lib/srcbundle.d.ts +37 -2
  21. package/lib/srcbundle.d.ts.map +1 -1
  22. package/lib/srcbundle.js +181 -58
  23. package/lib/srcbundle.js.map +1 -1
  24. package/lib/upload.d.ts +25 -0
  25. package/lib/upload.d.ts.map +1 -1
  26. package/lib/upload.js +52 -1
  27. package/lib/upload.js.map +1 -1
  28. package/lib/workflow.d.ts +47 -0
  29. package/lib/workflow.d.ts.map +1 -0
  30. package/lib/workflow.js +289 -0
  31. package/lib/workflow.js.map +1 -0
  32. package/package.json +10 -6
  33. package/src/__tests__/apps.test.ts +48 -0
  34. package/src/__tests__/artifacts.test.ts +1 -1
  35. package/src/__tests__/compatibility.test.ts +80 -0
  36. package/src/__tests__/expand-sourcemaps.test.ts +56 -0
  37. package/src/__tests__/rn-upload-hook.test.ts +35 -0
  38. package/src/__tests__/srcbundle.test.ts +66 -0
  39. package/src/__tests__/workflow.test.ts +102 -0
  40. package/src/apps.ts +31 -0
  41. package/src/artifacts.ts +12 -1
  42. package/src/compatibility.ts +21 -0
  43. package/src/index.ts +123 -69
  44. package/src/issue.ts +5 -1
  45. package/src/srcbundle.ts +150 -61
  46. package/src/upload.ts +51 -1
  47. package/src/workflow.ts +240 -0
  48. package/lib/source-bundle.d.ts +0 -28
  49. package/lib/source-bundle.d.ts.map +0 -1
  50. package/lib/source-bundle.js +0 -193
  51. 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
- return (await resp.json()) as ArtifactsResponse
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 = `sentori-cli — Sentori command-line interface
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-cli upload sourcemap --release <r> --token <t> <path...>
32
- sentori-cli upload dsym --release <r> --token <t> <path.dSYM>
33
- sentori-cli upload mapping --release <r> --token <t> mapping.txt
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-cli upload srcbundle --release <r> --token <t> <src-dir...>
36
- (native source files so dSYM/proguard frames
37
- show the failing line; nothing touches git)
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
- Release gate (the ONE command here that is allowed to fail):
42
- sentori-cli artifacts check --release <r> --token <t> [--expect dsym,sourcemap]
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-cli probes sync --release <r> --token <t> [--dir .]
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-cli issue list [--status open] [--kind error]
56
- sentori-cli issue resolve <issue-id> [--in-release <r>]
57
- sentori-cli issue note <issue-id> --body "fixed in abc123"
58
- sentori-cli issue bundle <issue-id>
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-cli mcp serve --token <api-token> [--api-url <url>]
66
+ sentori mcp serve --token <api-token> [--api-url <url>]
62
67
 
63
68
  Push (carried):
64
- sentori-cli push send / receipt / creds ...
69
+ sentori push send / receipt / creds ...
65
70
 
66
71
  Common options:
67
- --token api-scope token (or $SENTORI_TOKEN)
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: UPLOAD_OPTS })
145
- } catch (e) {
146
- console.error(`error: ${(e as Error).message}\n${HELP}`)
147
- return 2
148
- }
149
- if (parsed.values.help) {
150
- console.log(HELP)
151
- return 0
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
- const tmp = join(mkdtempSync(join(tmpdir(), 'sentori-srcbundle-')), 'srcbundle.json')
170
- writeFileSync(tmp, JSON.stringify(bundle))
171
- await uploadArtifact({ ...c, kind: 'srcbundle', path: tmp, name: 'srcbundle.json' })
172
- console.log(
173
- `uploaded a source bundle for "${c.release}" — ${stats.files} file(s)` +
174
- `${stats.skipped ? ` (${stats.skipped} skipped as oversized/unreadable)` : ''}; ` +
175
- `native frames on this release now show their source window.`,
176
- )
177
- return 0
178
- } catch (e) {
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: `srcbundle upload failed (${(e as Error).message})`,
181
- impact: `native frames from ${c.release} will show file:line without the code window until uploaded.`,
182
- retry: `sentori-cli upload srcbundle --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
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
- for (const p of parsed.positionals) {
208
- await uploadArtifact({ ...c, kind: 'sourcemap', path: p })
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 ${parsed.positionals.length} sourcemap(s) for "${c.release}" — minified stacks on this release now resolve to source.`,
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-cli upload sourcemap --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
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-cli upload dsym --release "${c.release}" --token <t> ${path}`,
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-cli upload mapping --release "${c.release}" --token <t> ${path}`,
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-cli react-native upload --release "${c.release}" --token <t> --metro-map ${metroMap} --hermes-map ${hermesMap}`,
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-cli probes sync --release "${c.release}" --token <t> --dir ${dir}`,
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-cli upload dsym` ' +
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-cli react-native upload --metro-map <m> --hermes-map <h>` — not the bundle.',
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