@goliapkg/sentori-cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/srcbundle.ts CHANGED
@@ -1,71 +1,160 @@
1
- // `sentori-cli upload srcbundle` — native source context without
2
- // repository access.
3
- //
4
- // Walks the given directories for native source files, packs them
5
- // as one JSON object (project-relative path → content), and lands
6
- // it on the artifacts endpoint as kind `srcbundle`. The server
7
- // reads it when a dSYM/proguard frame resolves to file+line, so
8
- // the dashboard shows the failing native code the same way a
9
- // sourcemap's embedded sourcesContent covers JS. The build owns
10
- // exactly what goes in — nothing is pulled from git, ever.
11
-
12
- import { readFileSync, readdirSync, statSync } from 'node:fs'
13
- import { join, relative } from 'node:path'
14
-
15
- /** Native + shared-code extensions worth carrying. JS/TS ride the
16
- * sourcemap already; including them anyway is harmless but bulky,
17
- * so they stay out by default. */
18
- const SOURCE_EXTS = new Set(['.swift', '.kt', '.kts', '.java', '.m', '.mm', '.h', '.c', '.cc', '.cpp', '.hpp'])
19
-
20
- const SKIP_DIRS = new Set(['node_modules', '.git', 'build', 'Pods', 'DerivedData', '.gradle', 'dist'])
21
-
22
- /** A single source file larger than this is generated, not written. */
23
- const MAX_FILE_BYTES = 512 * 1024
24
- /** Total budget before compression; the wire is gzip anyway. */
25
- const MAX_TOTAL_BYTES = 64 * 1024 * 1024
1
+ // Source archives are build artifacts. Directory mode needs no repository;
2
+ // tag mode reads immutable Git objects without changing the working tree.
3
+ import { execFileSync } from 'node:child_process'
4
+ import { createHash } from 'node:crypto'
5
+ import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
6
+ import { extname, isAbsolute, relative, resolve, sep } from 'node:path'
7
+ import { unzipSync, zipSync } from 'fflate'
26
8
 
9
+ export const SOURCE_MANIFEST = 'sentori-source-manifest.json'
10
+ export const MAX_SOURCE_FILE_BYTES = 512 * 1024
11
+ export const MAX_SOURCE_BYTES = 64 * 1024 * 1024
12
+ const MAX_FILES = 10000
13
+ const SOURCE_EXTS = new Set(['.swift', '.kt', '.kts', '.java', '.m', '.mm', '.h', '.c', '.cc', '.cpp', '.hpp', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.rs', '.go', '.py', '.rb', '.php', '.cs', '.dart'])
14
+ const SKIP_DIRS = new Set(['node_modules', 'build', 'Pods', 'DerivedData', 'dist', 'target', 'vendor', '__pycache__'])
27
15
  export type SrcBundleStats = { files: number; bytes: number; skipped: number }
16
+ export type SourceOptions = { root?: string; exclude?: string[]; gitTag?: string }
17
+ export type SourceManifest = {
18
+ version: 1
19
+ release: string
20
+ source: { type: 'directory' | 'git-tag'; tag?: string; commit?: string }
21
+ files: { path: string; bytes: number; sha256: string }[]
22
+ }
23
+ export type CollectedSources = { bundle: Record<string, string>; stats: SrcBundleStats; source: SourceManifest['source'] }
24
+ const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
28
25
 
29
- /** Collect `{ relPath: content }` from the roots. */
30
- export function collectSources(roots: string[]): { bundle: Record<string, string>; stats: SrcBundleStats } {
31
- const bundle: Record<string, string> = {}
32
- const stats: SrcBundleStats = { files: 0, bytes: 0, skipped: 0 }
26
+ export function safeSourcePath(path: string): boolean {
27
+ return path.length > 0 && path.length <= 1024 && !/[\\\x00-\x1f:]/.test(path)
28
+ && !path.startsWith('/') && path.split('/').every(part => part !== '' && part !== '.' && part !== '..')
29
+ }
30
+ function selected(path: string, includes: string[], excludes: string[]): boolean {
31
+ const under = (parent: string): boolean => parent === '.' || path === parent || path.startsWith(`${parent}/`)
32
+ return includes.some(under) && !excludes.some(under)
33
+ }
34
+ function sourcePath(path: string, includes: string[]): boolean {
35
+ return safeSourcePath(path) && SOURCE_EXTS.has(extname(path).toLowerCase())
36
+ && !path.split('/').some((part, index, parts) => part.startsWith('.') || SKIP_DIRS.has(part)
37
+ && !includes.some(include => include === parts.slice(0, index + 1).join('/') || include.startsWith(parts.slice(0, index + 1).join('/') + '/')))
38
+ }
39
+ function readText(bytes: Uint8Array, path: string): string {
40
+ if (bytes.includes(0)) throw new Error(`source is not UTF-8 text: ${path}`)
41
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes) }
42
+ catch { throw new Error(`source is not UTF-8 text: ${path}`) }
43
+ }
44
+ function selectionPath(root: string, path: string): string {
45
+ const rel = relative(root, resolve(root, path)).split(sep).join('/') || '.'
46
+ if (rel !== '.' && !safeSourcePath(rel)) throw new Error(`source path is outside --root: ${path}`)
47
+ return rel
48
+ }
33
49
 
34
- const walk = (root: string, dir: string): void => {
35
- let entries
36
- try {
37
- entries = readdirSync(dir, { withFileTypes: true })
38
- } catch {
39
- return
50
+ /** Keep every path relative to ONE root, even with several selected directories. */
51
+ export function collectSources(paths: string[], options: SourceOptions = {}): CollectedSources {
52
+ if (!paths.length) throw new Error('choose source directories or files (for example: ios android/app/src shared)')
53
+ const root = realpathSync(resolve(options.root ?? '.'))
54
+ const includes = paths.map(path => selectionPath(root, path))
55
+ const excludes = (options.exclude ?? []).map(path => selectionPath(root, path))
56
+ const bundle: Record<string, string> = Object.create(null)
57
+ const stats: SrcBundleStats = { files: 0, bytes: 0, skipped: 0 }
58
+ const add = (path: string, bytes: Uint8Array): void => {
59
+ if (Object.hasOwn(bundle, path)) return
60
+ if (bytes.length > MAX_SOURCE_FILE_BYTES) throw new Error(`source exceeds 512 KiB: ${path}; explicitly exclude it if not needed`)
61
+ if (stats.bytes + bytes.length > MAX_SOURCE_BYTES || stats.files >= MAX_FILES) throw new Error('source bundle exceeds 64 MiB or 10000 files; narrow the selected directories')
62
+ bundle[path] = readText(bytes, path)
63
+ stats.files++
64
+ stats.bytes += bytes.length
65
+ }
66
+ let source: SourceManifest['source'] = { type: 'directory' }
67
+ if (options.gitTag) {
68
+ const git = (args: string[], limit = MAX_SOURCE_BYTES): Buffer => execFileSync('git', ['-C', root, ...args], { maxBuffer: limit, stdio: ['ignore', 'pipe', 'pipe'] })
69
+ const top = realpathSync(git(['rev-parse', '--show-toplevel']).toString().trim())
70
+ if (root !== top) throw new Error('--root must be the repository root when using --git-tag')
71
+ const tag = options.gitTag
72
+ git(['check-ref-format', `refs/tags/${tag}`])
73
+ const commit = git(['rev-parse', '--verify', `refs/tags/${tag}^{commit}`]).toString().trim()
74
+ if (!/^[0-9a-f]{40,64}$/.test(commit)) throw new Error('tag did not resolve to a commit')
75
+ source = { type: 'git-tag', tag, commit }
76
+ const records = git(['ls-tree', '-rlz', '--full-tree', commit]).toString().split('\0').filter(Boolean)
77
+ const matched = new Set<string>()
78
+ for (const record of records) {
79
+ const tab = record.indexOf('\t')
80
+ const path = record.slice(tab + 1)
81
+ if (!selected(path, includes, excludes) || !sourcePath(path, includes)) continue
82
+ const [mode, type, oid, size] = record.slice(0, tab).trim().split(/\s+/)
83
+ if (type !== 'blob' || !['100644', '100755'].includes(mode!)) { stats.skipped++; continue }
84
+ if (Number(size) > MAX_SOURCE_FILE_BYTES) throw new Error(`source exceeds 512 KiB: ${path}`)
85
+ for (const item of includes) if (selected(path, [item], [])) matched.add(item)
86
+ add(path, git(['cat-file', 'blob', oid!], MAX_SOURCE_FILE_BYTES + 1))
40
87
  }
41
- for (const e of entries) {
42
- if (e.isDirectory()) {
43
- if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(root, join(dir, e.name))
44
- continue
45
- }
46
- const dot = e.name.lastIndexOf('.')
47
- if (dot < 0 || !SOURCE_EXTS.has(e.name.slice(dot))) continue
48
- const full = join(dir, e.name)
49
- let size
50
- try {
51
- size = statSync(full).size
52
- } catch {
53
- continue
54
- }
55
- if (size > MAX_FILE_BYTES || stats.bytes + size > MAX_TOTAL_BYTES) {
56
- stats.skipped += 1
57
- continue
58
- }
59
- try {
60
- bundle[relative(root, full)] = readFileSync(full, 'utf8')
61
- stats.files += 1
62
- stats.bytes += size
63
- } catch {
64
- stats.skipped += 1
88
+ for (const item of includes) if (!matched.has(item)) throw new Error(`no source files at tag ${tag} under ${item}`)
89
+ } else {
90
+ const walk = (path: string): void => {
91
+ const full = resolve(root, path)
92
+ const stat = lstatSync(full)
93
+ if (stat.isSymbolicLink()) { stats.skipped++; return }
94
+ const real = relative(root, realpathSync(full))
95
+ if (real.startsWith(`..${sep}`) || real === '..' || isAbsolute(real)) throw new Error(`source resolves outside --root: ${path}`)
96
+ if (stat.isDirectory()) {
97
+ for (const entry of readdirSync(full).sort()) {
98
+ if (entry.startsWith('.') || SKIP_DIRS.has(entry)) { stats.skipped++; continue }
99
+ const child = path === '.' ? entry : `${path}/${entry}`
100
+ if (excludes.some(x => child === x || child.startsWith(`${x}/`))) continue
101
+ walk(child)
102
+ }
103
+ } else if (stat.isFile() && sourcePath(path, includes) && selected(path, includes, excludes)) {
104
+ if (stat.size > MAX_SOURCE_FILE_BYTES) throw new Error(`source exceeds 512 KiB: ${path}`)
105
+ add(path, readFileSync(full))
65
106
  }
66
107
  }
108
+ for (const path of includes) walk(path)
67
109
  }
110
+ if (!stats.files) throw new Error('no supported source files selected; choose the application source directories')
111
+ return { bundle, stats, source }
112
+ }
68
113
 
69
- for (const root of roots) walk(root, root)
70
- return { bundle, stats }
114
+ export function packSources(paths: string[], release: string, options: SourceOptions = {}): { bytes: Uint8Array; manifest: SourceManifest; stats: SrcBundleStats } {
115
+ if (!release.trim()) throw new Error('release is required and must match the SDK release')
116
+ const { bundle, stats, source } = collectSources(paths, options)
117
+ const entries: Record<string, Uint8Array> = Object.create(null)
118
+ const manifest: SourceManifest = { version: 1, release, source, files: [] }
119
+ for (const path of Object.keys(bundle).sort()) {
120
+ const bytes = Buffer.from(bundle[path]!, 'utf8')
121
+ entries[path] = bytes
122
+ manifest.files.push({ path, bytes: bytes.length, sha256: sha256(bytes) })
123
+ }
124
+ entries[SOURCE_MANIFEST] = Buffer.from(JSON.stringify(manifest, null, 2) + '\n')
125
+ return { bytes: zipSync(entries, { level: 6, mtime: new Date(2000, 0, 1) }), manifest, stats }
126
+ }
127
+
128
+ /** Validate in memory; the server independently repeats validation. */
129
+ export function inspectSourceArchive(bytes: Uint8Array, release?: string): { files: string[]; manifest?: SourceManifest } {
130
+ if (bytes.length > MAX_SOURCE_BYTES + 4 * 1024 * 1024) throw new Error('source archive is too large')
131
+ const names = new Set<string>()
132
+ let total = 0
133
+ const files = unzipSync(bytes, { filter: file => {
134
+ const path = file.name.replace(/\/$/, '')
135
+ if (!safeSourcePath(path) || names.has(path)) throw new Error(`unsafe or duplicate ZIP path: ${file.name}`)
136
+ names.add(path)
137
+ if (names.size > MAX_FILES + 1) throw new Error('ZIP contains too many entries')
138
+ const limit = path === SOURCE_MANIFEST ? 4 * 1024 * 1024 : MAX_SOURCE_FILE_BYTES
139
+ total += file.originalSize
140
+ if (file.originalSize > limit || total > MAX_SOURCE_BYTES + 4 * 1024 * 1024) throw new Error('ZIP exceeds source size limits')
141
+ return !file.name.endsWith('/')
142
+ } })
143
+ const sources = Object.keys(files).filter(path => path !== SOURCE_MANIFEST)
144
+ if (!sources.length) throw new Error('source ZIP is empty')
145
+ for (const path of sources) readText(files[path]!, path)
146
+ let manifest: SourceManifest | undefined
147
+ if (files[SOURCE_MANIFEST]) {
148
+ manifest = JSON.parse(readText(files[SOURCE_MANIFEST]!, SOURCE_MANIFEST)) as SourceManifest
149
+ if (!manifest || manifest.version !== 1 || typeof manifest.release !== 'string' || !manifest.release.trim() || !Array.isArray(manifest.files)
150
+ || !manifest.source || !['directory', 'git-tag'].includes(manifest.source.type)
151
+ || manifest.files.some(file => !file || typeof file.path !== 'string')) throw new Error('invalid source manifest')
152
+ if (release && manifest.release !== release) throw new Error(`ZIP release ${manifest.release} does not match ${release}`)
153
+ if (manifest.files.length !== sources.length || new Set(manifest.files.map(file => file.path)).size !== sources.length) throw new Error('source manifest file list does not match ZIP')
154
+ for (const file of manifest.files) {
155
+ const body = files[file.path]
156
+ if (!body || body.length !== file.bytes || sha256(body) !== file.sha256) throw new Error(`source manifest checksum mismatch: ${file.path}`)
157
+ }
158
+ }
159
+ return { files: sources.sort(), manifest }
71
160
  }
package/src/upload.ts CHANGED
@@ -14,7 +14,8 @@
14
14
  // (a real RN app's main dSYM runs hundreds of MB) fit as gzip.
15
15
 
16
16
  import { readFileSync } from 'node:fs'
17
- import { basename } from 'node:path'
17
+ import { readdirSync, statSync } from 'node:fs'
18
+ import { basename, join, relative, sep } from 'node:path'
18
19
  import { gzipSync } from 'node:zlib'
19
20
 
20
21
  export type UploadOpts = {
@@ -91,6 +92,55 @@ export function warnIfUnusable(name: string, v: UploadVerdict): boolean {
91
92
  return true
92
93
  }
93
94
 
95
+ /**
96
+ * Turn one path into the source maps under it, each with the name the
97
+ * server should store.
98
+ *
99
+ * A file keeps its basename. A **directory** is walked, and every map
100
+ * under it keeps its path *relative to that directory* — so
101
+ * `dist/pages/cart/index.js.map` is stored as
102
+ * `pages/cart/index.js.map` and not as `index.js.map`.
103
+ *
104
+ * That difference is the whole feature. The server matches a frame to
105
+ * a map by how many trailing path segments they share, precisely
106
+ * because a WeChat mini program names every page's entry `index.js`
107
+ * and a web build names every route chunk after its route. Sending
108
+ * only the basename threw that away on the client, so the matching
109
+ * could never see more than a filename however carefully it was
110
+ * written.
111
+ *
112
+ * Passing a directory used to read the directory as a file and throw
113
+ * `EISDIR`, while `docs/getting-started/web.md` told readers to pass
114
+ * `./dist`.
115
+ */
116
+ export function expandSourcemapPaths(path: string): { path: string; name: string }[] {
117
+ let stat
118
+ try {
119
+ stat = statSync(path)
120
+ } catch {
121
+ // Let the caller's read produce the real error, with the real
122
+ // path in it.
123
+ return [{ path, name: basename(path) }]
124
+ }
125
+ if (!stat.isDirectory()) return [{ path, name: basename(path) }]
126
+
127
+ const out: { path: string; name: string }[] = []
128
+ const walk = (dir: string): void => {
129
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
130
+ const full = join(dir, e.name)
131
+ if (e.isDirectory()) walk(full)
132
+ else if (e.name.endsWith('.map') || e.name.endsWith('.map.gz')) {
133
+ // POSIX separators on the wire whatever the build machine is:
134
+ // the server compares these against a frame's `file`, which is
135
+ // a URL or a module path, and neither uses a backslash.
136
+ out.push({ path: full, name: relative(path, full).split(sep).join('/') })
137
+ }
138
+ }
139
+ }
140
+ walk(path)
141
+ return out
142
+ }
143
+
94
144
  export async function uploadArtifact(opts: UploadOpts): Promise<UploadVerdict> {
95
145
  const bytes = readFileSync(opts.path)
96
146
  if (bytes.length === 0) throw new Error(`empty file: ${opts.path}`)
@@ -0,0 +1,240 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, resolve } from 'node:path'
4
+ import { gunzipSync } from 'node:zlib'
5
+ import { parseArgs } from 'node:util'
6
+ import { fetchArtifacts } from './artifacts.js'
7
+ import { checkCompatibility } from './compatibility.js'
8
+ import { dwarfBinariesIn } from './native-artifacts.js'
9
+ import { inspectSourceArchive, packSources, type SourceOptions } from './srcbundle.js'
10
+ import { expandSourcemapPaths, uploadArtifact, type UploadOpts } from './upload.js'
11
+ import { isStrict, lenientFail, stripStrict } from './lenient.js'
12
+
13
+ export type UploadConfig = {
14
+ version: 1
15
+ apiUrl: string
16
+ tokenEnv: string
17
+ release?: string
18
+ artifacts: { sourcemap?: string[]; dsym?: string[]; mapping?: string[] }
19
+ sources?: { root?: string; include: string[]; exclude?: string[]; gitTag?: string }
20
+ sourceArchive?: string
21
+ }
22
+ export type UploadPlan = {
23
+ release: string
24
+ apiUrl: string
25
+ tokenEnv: string
26
+ files: { kind: UploadOpts['kind']; name: string; path: string; hash: string; bytes: number }[]
27
+ source?: { files: string[]; tag?: string; commit?: string }
28
+ }
29
+ const hash = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
30
+ const commonOptions = {
31
+ config: { type: 'string' }, release: { type: 'string' }, 'api-url': { type: 'string' },
32
+ 'git-tag': { type: 'string' }, 'dry-run': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
33
+ } as const
34
+ export const WORKFLOW_HELP = `Release uploads:
35
+ sentori init --api-url <url> [--release <r>] [--sourcemap <path>] [--dsym <path>]
36
+ [--mapping <path>] [--sources <dir>] [--git-tag <tag>] [--config <file>]
37
+ sentori config validate [--config sentori.config.json] [--release <r>]
38
+ sentori doctor [--config sentori.config.json] [--api-url <url>]
39
+ sentori upload all [--config sentori.config.json] [--release <r>] [--git-tag <tag>]
40
+ [--dry-run] [--strict]
41
+ sentori verify [--config sentori.config.json] [--release <r>] [--git-tag <tag>]
42
+ sentori sources pack --release <r> --output sources.zip [--root .] [--git-tag <tag>]
43
+ [--exclude <path>] <source-dir-or-file...>
44
+ sentori upload srcbundle --release <r> [--root .] [--git-tag <tag>] <sources.zip|source-dir...>
45
+
46
+ Paths in config are relative to that config. Sources keep paths relative to sources.root.
47
+ Set the API-scope token in SENTORI_TOKEN (or the configured tokenEnv); init never stores it.
48
+ Release must match the SDK exactly, including its build number. SENTORI_RELEASE is supported.
49
+ Git mode reads the specified tag's commit, never the working tree or a guessed latest tag.
50
+ Verification checks every planned file's bytes and server validation; it does not claim event matching.
51
+ `
52
+ function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === 'object' && !Array.isArray(value) }
53
+ function strings(value: unknown, label: string): string[] {
54
+ if (!Array.isArray(value) || value.some(x => typeof x !== 'string' || !x.trim())) throw new Error(`${label} must be an array of paths`)
55
+ return value as string[]
56
+ }
57
+ export function loadUploadConfig(path: string): UploadConfig {
58
+ const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
59
+ if (!object(value) || value.version !== 1 || typeof value.apiUrl !== 'string' || !object(value.artifacts)) throw new Error('config requires version: 1, apiUrl, and artifacts; run sentori init')
60
+ if (Object.hasOwn(value, 'token')) throw new Error('store the API token in the environment and set tokenEnv, not token in the config')
61
+ const allowed = new Set(['version', 'apiUrl', 'tokenEnv', 'release', 'artifacts', 'sources', 'sourceArchive'])
62
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`unknown config field: ${key}`)
63
+ for (const [kind, paths] of Object.entries(value.artifacts)) {
64
+ if (!['sourcemap', 'dsym', 'mapping'].includes(kind)) throw new Error(`unknown artifact kind ${kind}; use sourcemap, dsym, or mapping`)
65
+ strings(paths, `artifacts.${kind}`)
66
+ }
67
+ if (value.release !== undefined && (typeof value.release !== 'string' || !value.release.trim())) throw new Error('release must be a non-empty string')
68
+ if (value.sourceArchive !== undefined && typeof value.sourceArchive !== 'string') throw new Error('sourceArchive must be a ZIP path')
69
+ if (value.sources !== undefined) {
70
+ if (!object(value.sources)) throw new Error('sources must declare include paths')
71
+ for (const key of Object.keys(value.sources)) if (!['root', 'include', 'exclude', 'gitTag'].includes(key)) throw new Error(`unknown sources field: ${key}`)
72
+ strings(value.sources.include, 'sources.include')
73
+ if (value.sources.exclude !== undefined) strings(value.sources.exclude, 'sources.exclude')
74
+ for (const key of ['root', 'gitTag']) if (value.sources[key] !== undefined && typeof value.sources[key] !== 'string') throw new Error(`sources.${key} must be a string`)
75
+ }
76
+ if (value.sources && value.sourceArchive) throw new Error('choose sources or sourceArchive, not both')
77
+ const tokenEnv = value.tokenEnv ?? 'SENTORI_TOKEN'
78
+ if (typeof tokenEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(tokenEnv)) throw new Error('tokenEnv must name an environment variable')
79
+ return { ...value, tokenEnv } as UploadConfig
80
+ }
81
+ function apiUrl(raw: string): string {
82
+ const url = new URL(raw)
83
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('apiUrl must be an HTTP(S) instance URL without credentials, query, or fragment')
84
+ return url.toString().replace(/\/+$/, '')
85
+ }
86
+ function validateArtifact(kind: UploadOpts['kind'], bytes: Buffer, name: string): void {
87
+ if (!bytes.length) throw new Error(`empty artifact: ${name}`)
88
+ if (kind === 'sourcemap') {
89
+ const map = JSON.parse(bytes.toString('utf8'))
90
+ if (map?.version !== 3 || !(Array.isArray(map.sections) || Array.isArray(map.sources) && typeof map.mappings === 'string')) throw new Error(`not a version 3 source map: ${name}`)
91
+ }
92
+ if (kind === 'proguard' && !/^\S.* -> \S+:\s*$/m.test(bytes.toString('utf8'))) throw new Error(`no ProGuard/R8 class mappings in ${name}`)
93
+ if (kind === 'dsym' && !['feedface', 'cefaedfe', 'feedfacf', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(bytes.subarray(0, 4).toString('hex'))) throw new Error(`not a Mach-O DWARF binary: ${name}`)
94
+ }
95
+ export function prepareUploadPlan(configPath: string, overrides: { release?: string; apiUrl?: string; gitTag?: string } = {}): UploadPlan {
96
+ const config = loadUploadConfig(configPath)
97
+ const base = dirname(resolve(configPath))
98
+ const release = overrides.release ?? process.env.SENTORI_RELEASE ?? config.release
99
+ if (!release?.trim()) throw new Error('set --release, SENTORI_RELEASE, or config.release to the exact SDK release')
100
+ const plan: UploadPlan = { release, apiUrl: apiUrl(overrides.apiUrl ?? process.env.SENTORI_API_URL ?? config.apiUrl), tokenEnv: config.tokenEnv, files: [] }
101
+ const add = (kind: UploadOpts['kind'], path: string, name = basename(path)): void => {
102
+ let bytes = readFileSync(path)
103
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b) { bytes = gunzipSync(bytes, { maxOutputLength: 512 * 1024 * 1024 }); name = name.replace(/\.gz$/, '') }
104
+ validateArtifact(kind, bytes, name)
105
+ const digest = hash(bytes)
106
+ const collision = plan.files.find(file => file.kind === kind && file.name === name)
107
+ if (collision) {
108
+ if (collision.hash !== digest) throw new Error(`two different ${kind} files would overwrite ${name}; choose distinct upload paths`)
109
+ return
110
+ }
111
+ plan.files.push({ kind, path, name, hash: digest, bytes: bytes.length })
112
+ }
113
+ for (const path of config.artifacts.sourcemap ?? []) {
114
+ const maps = expandSourcemapPaths(resolve(base, path))
115
+ if (!maps.length) throw new Error(`no source maps under ${path}`)
116
+ for (const file of maps) add('sourcemap', file.path, file.name)
117
+ }
118
+ for (const path of config.artifacts.dsym ?? []) {
119
+ const full = resolve(base, path)
120
+ const files = dwarfBinariesIn(full)
121
+ if (!files.length) throw new Error(`no dSYM binaries under ${path}`)
122
+ for (const file of files) add('dsym', file, lstatSync(full).isDirectory() ? `${basename(full)}-${basename(file)}` : basename(file))
123
+ }
124
+ for (const path of config.artifacts.mapping ?? []) add('proguard', resolve(base, path))
125
+ if (config.sources) {
126
+ const packed = packSources(config.sources.include, release, {
127
+ root: resolve(base, config.sources.root ?? '.'), exclude: config.sources.exclude,
128
+ gitTag: overrides.gitTag ?? config.sources.gitTag,
129
+ })
130
+ const dir = resolve(base, '.sentori')
131
+ mkdirSync(dir, { recursive: true })
132
+ const archive = resolve(dir, `sources-${hash(packed.bytes)}.zip`)
133
+ writeFileSync(archive, packed.bytes)
134
+ add('srcbundle', archive, 'sources.zip')
135
+ plan.source = { files: packed.manifest.files.map(file => file.path), tag: packed.manifest.source.tag, commit: packed.manifest.source.commit }
136
+ } else if (overrides.gitTag) throw new Error('--git-tag requires sources in the config')
137
+ if (config.sourceArchive) {
138
+ const archive = resolve(base, config.sourceArchive)
139
+ const source = inspectSourceArchive(readFileSync(archive), release)
140
+ add('srcbundle', archive)
141
+ plan.source = { files: source.files, tag: source.manifest?.source.tag, commit: source.manifest?.source.commit }
142
+ }
143
+ if (!plan.files.length) throw new Error('config selects no files; declare artifacts or sources')
144
+ return plan
145
+ }
146
+ export async function verifyUploadPlan(plan: UploadPlan, token: string): Promise<string[]> {
147
+ const result = await fetchArtifacts({ ...plan, token })
148
+ if (!result.known || result.release !== plan.release) return [`release not found: ${plan.release}`]
149
+ const problems: string[] = []
150
+ for (const file of plan.files) {
151
+ const matches = result.artifacts.filter(row => row.kind === file.kind && row.contentHash === file.hash
152
+ && (file.kind === 'dsym' || row.name === file.name))
153
+ if (!matches.length) problems.push(`missing exact bytes: ${file.kind} ${file.name}`)
154
+ else if (!matches.some(row => row.usable === true)) problems.push(`${matches.some(row => row.usable === false) ? 'unreadable' : 'not verified by server'}: ${file.kind} ${file.name}`)
155
+ }
156
+ return problems
157
+ }
158
+ export async function runWorkflow(command: 'init' | 'validate' | 'upload' | 'verify' | 'doctor', argv: string[]): Promise<number> {
159
+ const strict = isStrict(argv)
160
+ try {
161
+ const parsed = parseArgs({ args: stripStrict(argv), options: { ...commonOptions,
162
+ ...(command === 'init' ? { sourcemap: { type: 'string', multiple: true }, dsym: { type: 'string', multiple: true }, mapping: { type: 'string', multiple: true }, sources: { type: 'string', multiple: true } } as const : {}),
163
+ } })
164
+ if (parsed.values.help) { console.log(WORKFLOW_HELP); return 0 }
165
+ if (parsed.values['dry-run'] && command !== 'upload') throw new Error('--dry-run is only supported by upload all; doctor and verify must contact the server')
166
+ const configPath = resolve(parsed.values.config ?? 'sentori.config.json')
167
+ if (command === 'init') {
168
+ const v = parsed.values as Record<string, string | string[] | boolean | undefined>
169
+ const base = dirname(configPath)
170
+ const config: UploadConfig = {
171
+ version: 1, apiUrl: apiUrl(parsed.values['api-url'] ?? process.env.SENTORI_API_URL ?? 'https://sentori.golia.jp'),
172
+ tokenEnv: 'SENTORI_TOKEN', ...(parsed.values.release ? { release: parsed.values.release } : {}),
173
+ artifacts: { sourcemap: v.sourcemap as string[] ?? [], dsym: v.dsym as string[] ?? [], mapping: v.mapping as string[] ?? [] },
174
+ }
175
+ const include = v.sources as string[] ?? ['src', 'Sources', 'ios', 'android/app/src'].filter(path => existsSync(resolve(base, path)))
176
+ if (include.length) config.sources = { root: '.', include, exclude: [], ...(parsed.values['git-tag'] ? { gitTag: parsed.values['git-tag'] } : {}) }
177
+ else if (parsed.values['git-tag']) throw new Error('--git-tag requires --sources paths')
178
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', { flag: 'wx' })
179
+ console.log(`Created ${configPath}. Review source paths and build artifact paths. Keep .sentori/ out of version control.\nSet SENTORI_TOKEN and the exact SDK release, then run sentori config validate and sentori upload all.`)
180
+ return 0
181
+ }
182
+ if (command === 'doctor') {
183
+ const config = loadUploadConfig(configPath)
184
+ const token = process.env[config.tokenEnv]
185
+ if (!token) throw new Error(`set ${config.tokenEnv} to an API-scope token`)
186
+ const result = await fetchArtifacts({
187
+ apiUrl: apiUrl(parsed.values['api-url'] ?? process.env.SENTORI_API_URL ?? config.apiUrl), token,
188
+ release: parsed.values.release ?? process.env.SENTORI_RELEASE ?? config.release ?? '__sentori_compatibility_check__',
189
+ })
190
+ console.log(checkCompatibility(result, !!(config.sources || config.sourceArchive)))
191
+ console.log(`Node ${process.versions.node}; API token accepted. No files uploaded. Build artifacts and event/build matching were not checked.`)
192
+ return 0
193
+ }
194
+ const plan = prepareUploadPlan(configPath, { release: parsed.values.release, apiUrl: parsed.values['api-url'], gitTag: parsed.values['git-tag'] })
195
+ console.log(JSON.stringify(plan, null, 2))
196
+ if (command === 'validate' || parsed.values['dry-run']) { console.log('Local files checked; no upload or server verification performed.'); return 0 }
197
+ const token = process.env[plan.tokenEnv]
198
+ if (!token) throw new Error(`set ${plan.tokenEnv} to an API-scope token`)
199
+ // Check credentials before the first write. An unknown release is allowed.
200
+ console.log(checkCompatibility(await fetchArtifacts({ ...plan, token }), plan.files.some(file => file.kind === 'srcbundle')))
201
+ const failures: string[] = []
202
+ if (command === 'upload') {
203
+ for (const file of plan.files) {
204
+ try {
205
+ // Prevent edits between planning and upload from invalidating the receipt.
206
+ let body = readFileSync(file.path)
207
+ if (body[0] === 0x1f && body[1] === 0x8b) body = gunzipSync(body, { maxOutputLength: 512 * 1024 * 1024 })
208
+ if (hash(body) !== file.hash) throw new Error('file changed after local validation')
209
+ const verdict = await uploadArtifact({ ...plan, token, ...file })
210
+ if (verdict.usable !== true) failures.push(`${file.name}: ${verdict.usable === false ? 'unreadable' : 'not verified by server'}`)
211
+ } catch (error) { failures.push(`${file.name}: ${error instanceof Error ? error.message : String(error)}`) }
212
+ }
213
+ }
214
+ failures.push(...await verifyUploadPlan(plan, token))
215
+ if (failures.length) throw new Error(failures.join('\n'))
216
+ console.log(`Verified ${plan.files.length} exact file(s) for ${plan.release}: stored bytes match and format checks passed. Event/build matching is not asserted.`)
217
+ return 0
218
+ } catch (error) {
219
+ const message = error instanceof Error ? error.message : String(error)
220
+ if (command === 'upload') return lenientFail(strict, { failure: message, impact: 'some planned debug files are missing or unverified', retry: 'sentori upload all --config sentori.config.json; sentori verify --config sentori.config.json' })
221
+ console.error(`[sentori] ${message}`)
222
+ return 1
223
+ }
224
+ }
225
+ export async function runSourcesPack(argv: string[]): Promise<number> {
226
+ try {
227
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: {
228
+ release: { type: 'string' }, root: { type: 'string' }, output: { type: 'string' },
229
+ 'git-tag': { type: 'string' }, exclude: { type: 'string', multiple: true }, help: { type: 'boolean', short: 'h' },
230
+ } })
231
+ if (parsed.values.help) { console.log(WORKFLOW_HELP); return 0 }
232
+ const release = parsed.values.release ?? process.env.SENTORI_RELEASE ?? ''
233
+ if (!parsed.values.output) throw new Error('--output <sources.zip> is required')
234
+ const options: SourceOptions = { root: parsed.values.root, gitTag: parsed.values['git-tag'], exclude: parsed.values.exclude }
235
+ const packed = packSources(parsed.positionals, release, options)
236
+ writeFileSync(parsed.values.output, packed.bytes, { flag: 'wx' })
237
+ console.log(JSON.stringify({ output: parsed.values.output, ...packed.manifest, stats: packed.stats }, null, 2))
238
+ return 0
239
+ } catch (error) { console.error(`[sentori] ${error instanceof Error ? error.message : String(error)}`); return 1 }
240
+ }
@@ -1,28 +0,0 @@
1
- import type { AdminUpload } from './native-artifacts.js';
2
- export type SourceBundleUploadOptions = AdminUpload & {
3
- /** v1.4 W26 — optional module label so polyrepo apps (main +
4
- * watch ext + share ext etc.) can upload multiple bundles per
5
- * (release, platform). Empty/undefined → unlabelled single
6
- * bundle (v1.3 W15 behaviour). */
7
- module?: string;
8
- /** Pre-built tar.gz archive of the project's source tree. */
9
- path: string;
10
- platform: 'android' | 'ios';
11
- };
12
- export type SourceBundleUploadResult = {
13
- contentHash: string;
14
- kind: string;
15
- sizeBytes: number;
16
- };
17
- export declare function uploadSourceBundle(opts: SourceBundleUploadOptions): Promise<SourceBundleUploadResult>;
18
- /** Walk `dir`, pick platform-relevant source files, and tar.gz them
19
- * into a temp file. Caller is responsible for invoking `cleanup()`.
20
- * Uses the system `tar` binary; that's portable enough on macOS +
21
- * Linux + WSL, which covers every realistic CI environment Sentori
22
- * ships to. Native Windows CI without WSL would need to gzip the
23
- * archive themselves (the pre-built-path mode still works). */
24
- export declare function buildSourceBundleFromDir(dir: string, platform: 'android' | 'ios'): Promise<{
25
- cleanup: () => Promise<void>;
26
- path: string;
27
- }>;
28
- //# sourceMappingURL=source-bundle.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"source-bundle.d.ts","sourceRoot":"","sources":["../src/source-bundle.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAyBxD,MAAM,MAAM,yBAAyB,GAAG,WAAW,GAAG;IACpD;;;uCAGmC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,SAAS,GAAG,KAAK,CAAA;CAC5B,CAAA;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,yBAAyB,GAC9B,OAAO,CAAC,wBAAwB,CAAC,CA6BnC;AAuDD;;;;;gEAKgE;AAChE,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,SAAS,GAAG,KAAK,GAC1B,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCzD"}