@goliapkg/sentori-cli 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -26
- package/lib/apps.d.ts +24 -0
- package/lib/apps.d.ts.map +1 -0
- package/lib/apps.js +17 -0
- package/lib/apps.js.map +1 -0
- package/lib/artifacts.d.ts +7 -0
- package/lib/artifacts.d.ts.map +1 -1
- package/lib/artifacts.js +5 -1
- package/lib/artifacts.js.map +1 -1
- package/lib/compatibility.d.ts +6 -0
- package/lib/compatibility.d.ts.map +1 -0
- package/lib/compatibility.js +21 -0
- package/lib/compatibility.js.map +1 -0
- package/lib/index.js +150 -68
- package/lib/index.js.map +1 -1
- package/lib/issue.d.ts +4 -0
- package/lib/issue.d.ts.map +1 -1
- package/lib/issue.js +8 -0
- package/lib/issue.js.map +1 -1
- package/lib/srcbundle.d.ts +37 -2
- package/lib/srcbundle.d.ts.map +1 -1
- package/lib/srcbundle.js +181 -58
- package/lib/srcbundle.js.map +1 -1
- package/lib/upload.d.ts +25 -0
- package/lib/upload.d.ts.map +1 -1
- package/lib/upload.js +52 -1
- package/lib/upload.js.map +1 -1
- package/lib/workflow.d.ts +47 -0
- package/lib/workflow.d.ts.map +1 -0
- package/lib/workflow.js +289 -0
- package/lib/workflow.js.map +1 -0
- package/package.json +10 -6
- package/src/__tests__/apps.test.ts +48 -0
- package/src/__tests__/artifacts.test.ts +1 -1
- package/src/__tests__/compatibility.test.ts +80 -0
- package/src/__tests__/expand-sourcemaps.test.ts +56 -0
- package/src/__tests__/rn-upload-hook.test.ts +35 -0
- package/src/__tests__/srcbundle.test.ts +66 -0
- package/src/__tests__/workflow.test.ts +102 -0
- package/src/apps.ts +31 -0
- package/src/artifacts.ts +12 -1
- package/src/compatibility.ts +21 -0
- package/src/index.ts +123 -69
- package/src/issue.ts +5 -1
- package/src/srcbundle.ts +150 -61
- package/src/upload.ts +51 -1
- package/src/workflow.ts +240 -0
- package/lib/source-bundle.d.ts +0 -28
- package/lib/source-bundle.d.ts.map +0 -1
- package/lib/source-bundle.js +0 -193
- package/lib/source-bundle.js.map +0 -1
package/src/srcbundle.ts
CHANGED
|
@@ -1,71 +1,160 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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 {
|
|
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}`)
|
package/src/workflow.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/source-bundle.d.ts
DELETED
|
@@ -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"}
|