@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.
Files changed (54) hide show
  1. package/README.md +268 -26
  2. package/lib/artifacts.d.ts +7 -0
  3. package/lib/artifacts.d.ts.map +1 -1
  4. package/lib/artifacts.js +5 -1
  5. package/lib/artifacts.js.map +1 -1
  6. package/lib/compatibility.d.ts +6 -0
  7. package/lib/compatibility.d.ts.map +1 -0
  8. package/lib/compatibility.js +21 -0
  9. package/lib/compatibility.js.map +1 -0
  10. package/lib/index.js +97 -65
  11. package/lib/index.js.map +1 -1
  12. package/lib/lenient.js +1 -1
  13. package/lib/lenient.js.map +1 -1
  14. package/lib/mcp.js +1 -1
  15. package/lib/mcp.js.map +1 -1
  16. package/lib/native-artifacts.d.ts.map +1 -1
  17. package/lib/native-artifacts.js +5 -0
  18. package/lib/native-artifacts.js.map +1 -1
  19. package/lib/probes.js +1 -1
  20. package/lib/probes.js.map +1 -1
  21. package/lib/srcbundle.d.ts +37 -2
  22. package/lib/srcbundle.d.ts.map +1 -1
  23. package/lib/srcbundle.js +181 -58
  24. package/lib/srcbundle.js.map +1 -1
  25. package/lib/upload.d.ts +47 -2
  26. package/lib/upload.d.ts.map +1 -1
  27. package/lib/upload.js +65 -2
  28. package/lib/upload.js.map +1 -1
  29. package/lib/workflow.d.ts +47 -0
  30. package/lib/workflow.d.ts.map +1 -0
  31. package/lib/workflow.js +289 -0
  32. package/lib/workflow.js.map +1 -0
  33. package/package.json +11 -7
  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__/upload-verdict.test.ts +77 -0
  40. package/src/__tests__/workflow.test.ts +102 -0
  41. package/src/artifacts.ts +12 -1
  42. package/src/compatibility.ts +21 -0
  43. package/src/index.ts +83 -66
  44. package/src/lenient.ts +1 -1
  45. package/src/mcp.ts +1 -1
  46. package/src/native-artifacts.ts +5 -0
  47. package/src/probes.ts +1 -1
  48. package/src/srcbundle.ts +150 -61
  49. package/src/upload.ts +88 -3
  50. package/src/workflow.ts +240 -0
  51. package/lib/source-bundle.d.ts +0 -28
  52. package/lib/source-bundle.d.ts.map +0 -1
  53. package/lib/source-bundle.js +0 -193
  54. 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
- 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,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 = `sentori-cli — Sentori command-line interface
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-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
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-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> \\
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
- Release gate (the ONE command here that is allowed to fail):
42
- sentori-cli artifacts check --release <r> --token <t> [--expect dsym,sourcemap]
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-cli probes sync --release <r> --token <t> [--dir .]
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-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>
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-cli mcp serve --token <api-token> [--api-url <url>]
62
+ sentori mcp serve --token <api-token> [--api-url <url>]
62
63
 
63
64
  Push (carried):
64
- sentori-cli push send / receipt / creds ...
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: 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')
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
- 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) {
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: `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(' ')}`,
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
- for (const p of parsed.positionals) {
208
- await uploadArtifact({ ...c, kind: 'sourcemap', path: p })
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 ${parsed.positionals.length} sourcemap(s) for "${c.release}" — minified stacks on this release now resolve to source.`,
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-cli upload sourcemap --release "${c.release}" --token <t> ${parsed.positionals.join(' ')}`,
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-cli upload dsym --release "${c.release}" --token <t> ${path}`,
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-cli upload mapping --release "${c.release}" --token <t> ${path}`,
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-cli react-native upload --release "${c.release}" --token <t> --metro-map ${metroMap} --hermes-map ${hermesMap}`,
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-cli probes sync --release "${c.release}" --token <t> --dir ${dir}`,
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-cli upload dsym` ' +
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-cli react-native upload --metro-map <m> --hermes-map <h>` — not the bundle.',
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 (design.md §6 dim 3): an upload
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 (design.md §9):
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
 
@@ -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
- // (design.md §2). Statically scans source for `sentori.probe('REF')`
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.