@goliapkg/sentori-cli 0.5.1 → 0.5.3

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.
@@ -1,182 +0,0 @@
1
- // `sentori-cli upload dsym` (iOS) + `sentori-cli upload mapping` (Android).
2
- // Both endpoints take the raw artifact bytes (NOT multipart) with a few
3
- // headers / query params.
4
-
5
- import { spawnSync } from 'node:child_process'
6
- import { readFile } from 'node:fs/promises'
7
- import { basename, extname, join } from 'node:path'
8
- import { statSync, readdirSync } from 'node:fs'
9
-
10
- export type AdminUpload = {
11
- apiUrl: string
12
- projectId: string
13
- release?: string
14
- token: string
15
- }
16
-
17
- async function postBytes(
18
- url: string,
19
- body: Buffer,
20
- token: string,
21
- headers: Record<string, string> = {},
22
- ): Promise<unknown> {
23
- const resp = await fetch(url, {
24
- body,
25
- headers: {
26
- Authorization: `Bearer ${token}`,
27
- 'Content-Type': 'application/octet-stream',
28
- ...headers,
29
- },
30
- method: 'POST',
31
- })
32
- if (!resp.ok) {
33
- let detail = ''
34
- try {
35
- detail = await resp.text()
36
- } catch {
37
- // ignore
38
- }
39
- throw new Error(
40
- `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
41
- )
42
- }
43
- const txt = await resp.text()
44
- return txt ? JSON.parse(txt) : null
45
- }
46
-
47
- // ── dSYM ──────────────────────────────────────────────────────────
48
-
49
- export type DsymSlice = { arch: string; debugId: string; file: string }
50
-
51
- const ARCHES = new Set([
52
- 'arm64',
53
- 'arm64_32',
54
- 'arm64e',
55
- 'armv7',
56
- 'armv7k',
57
- 'armv7s',
58
- 'i386',
59
- 'x86_64',
60
- 'x86_64h',
61
- ])
62
-
63
- /**
64
- * Use `dwarfdump --uuid <path>` to enumerate `(arch, debug_id, file)`
65
- * for each Mach-O slice. Returns [] if dwarfdump isn't installed or the
66
- * output couldn't be parsed; callers should fall back to explicit
67
- * `--debug-id` / `--arch` flags in that case.
68
- */
69
- export function dsymSlicesFromDwarfdump(path: string): DsymSlice[] {
70
- const r = spawnSync('dwarfdump', ['--uuid', path])
71
- if (r.status !== 0 || !r.stdout) return []
72
- const out: DsymSlice[] = []
73
- // Output lines look like:
74
- // UUID: 1234ABCD-... (arm64) /path/to/Foo.dSYM/Contents/Resources/DWARF/Foo
75
- const re = /^UUID:\s+([0-9A-Fa-f-]{32,36})\s+\(([^)]+)\)\s+(.+)\s*$/m
76
- for (const line of r.stdout.toString().split('\n')) {
77
- const m = re.exec(line)
78
- if (!m) continue
79
- const [, debugId, arch, file] = m as unknown as [string, string, string, string]
80
- if (!ARCHES.has(arch)) continue
81
- out.push({ arch, debugId: debugId.toUpperCase(), file: file.trim() })
82
- }
83
- return out
84
- }
85
-
86
- /** Walk a `.dSYM` bundle and return the DWARF binary files inside
87
- * `Contents/Resources/DWARF/`. If `path` already points at a binary
88
- * (not a bundle), returns `[path]`. */
89
- export function dwarfBinariesIn(path: string): string[] {
90
- let st
91
- try {
92
- st = statSync(path)
93
- } catch {
94
- return []
95
- }
96
- if (st.isFile()) return [path]
97
- const dwarfDir = join(path, 'Contents/Resources/DWARF')
98
- try {
99
- return readdirSync(dwarfDir)
100
- .filter((n) => !n.startsWith('.'))
101
- .map((n) => join(dwarfDir, n))
102
- } catch {
103
- // not a .dSYM bundle layout — try the top-level path
104
- return [path]
105
- }
106
- }
107
-
108
- export type DsymUploadOptions = AdminUpload & {
109
- /** Explicit overrides when dwarfdump isn't available. */
110
- arch?: string
111
- debugId?: string
112
- /** A `Foo.dSYM` bundle or a raw DWARF binary. */
113
- path: string
114
- objectName?: string
115
- }
116
-
117
- export type DsymUploadResult = { slices: { arch: string; debugId: string }[] }
118
-
119
- export async function uploadDsym(opts: DsymUploadOptions): Promise<DsymUploadResult> {
120
- const slices: DsymSlice[] = []
121
- if (opts.debugId && opts.arch) {
122
- // Explicit single-slice upload — no parsing needed.
123
- const binaries = dwarfBinariesIn(opts.path)
124
- if (binaries.length === 0) throw new Error(`no DWARF binary at: ${opts.path}`)
125
- slices.push({ arch: opts.arch, debugId: opts.debugId.toUpperCase(), file: binaries[0]! })
126
- } else {
127
- // Auto-discover via dwarfdump.
128
- const found = dsymSlicesFromDwarfdump(opts.path)
129
- if (found.length === 0) {
130
- throw new Error(
131
- 'couldn’t enumerate dSYM slices — install Xcode command-line tools ' +
132
- '(for `dwarfdump`), or pass --debug-id and --arch explicitly',
133
- )
134
- }
135
- slices.push(...found)
136
- }
137
-
138
- const base = opts.apiUrl.replace(/\/+$/, '')
139
- const q = new URLSearchParams()
140
- if (opts.release) q.set('release', opts.release)
141
- if (opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, ''))
142
- q.set('objectName', opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, ''))
143
- const qs = q.toString()
144
- const url = `${base}/admin/api/projects/${encodeURIComponent(opts.projectId)}/dsyms${qs ? '?' + qs : ''}`
145
-
146
- const uploaded: { arch: string; debugId: string }[] = []
147
- for (const s of slices) {
148
- const body = await readFile(s.file)
149
- await postBytes(url, body, opts.token, {
150
- 'x-sentori-arch': s.arch,
151
- 'x-sentori-debug-id': s.debugId,
152
- })
153
- uploaded.push({ arch: s.arch, debugId: s.debugId })
154
- }
155
- return { slices: uploaded }
156
- }
157
-
158
- // ── ProGuard / R8 mapping ─────────────────────────────────────────
159
-
160
- export type MappingUploadOptions = AdminUpload & {
161
- debugId?: string
162
- path: string
163
- }
164
-
165
- export async function uploadMapping(opts: MappingUploadOptions): Promise<void> {
166
- const ext = extname(opts.path).toLowerCase()
167
- if (ext && ext !== '.txt' && ext !== '.map') {
168
- // R8 emits `mapping.txt` by default; accept anything but warn.
169
- console.warn(`[sentori-cli] upload mapping: unexpected extension ${ext} — uploading anyway`)
170
- }
171
- const body = await readFile(opts.path)
172
- if (body.length === 0) throw new Error(`empty mapping file: ${opts.path}`)
173
-
174
- const base = opts.apiUrl.replace(/\/+$/, '')
175
- const q = new URLSearchParams()
176
- if (opts.release) q.set('release', opts.release)
177
- const qs = q.toString()
178
- const url = `${base}/admin/api/projects/${encodeURIComponent(opts.projectId)}/mappings${qs ? '?' + qs : ''}`
179
- const headers: Record<string, string> = {}
180
- if (opts.debugId) headers['x-sentori-debug-id'] = opts.debugId
181
- await postBytes(url, body, opts.token, headers)
182
- }
@@ -1,87 +0,0 @@
1
- import { spawnSync } from 'node:child_process'
2
- import { existsSync, mkdtempSync, rmSync } from 'node:fs'
3
- import { createRequire } from 'node:module'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
-
7
- import { uploadSourcemaps } from './upload.js'
8
-
9
- /** Resolve `react-native/scripts/compose-source-maps.js` from the
10
- * current project's node_modules. Returns null if react-native isn't
11
- * installed or the version doesn't ship that script. */
12
- export function resolveComposeScript(fromDir = process.cwd()): null | string {
13
- const req = createRequire(join(fromDir, 'noop.js'))
14
- for (const id of [
15
- 'react-native/scripts/compose-source-maps.js',
16
- '@react-native/community-cli-plugin/dist/utils/composeSourceMaps.js',
17
- ]) {
18
- try {
19
- const p = req.resolve(id)
20
- if (existsSync(p)) return p
21
- } catch {
22
- // try next
23
- }
24
- }
25
- return null
26
- }
27
-
28
- /** Compose a Metro packager source map + a Hermes source map into a
29
- * single map (a temp file the caller is responsible for deleting).
30
- * Throws if react-native's compose script can't be found or fails. */
31
- export function composeSourceMaps(metroMap: string, hermesMap: string): string {
32
- for (const p of [metroMap, hermesMap]) {
33
- if (!existsSync(p)) throw new Error(`no such file: ${p}`)
34
- }
35
- const script = resolveComposeScript()
36
- if (!script) {
37
- throw new Error(
38
- "couldn't find react-native's compose-source-maps.js — install react-native, or " +
39
- 'compose the maps yourself and use `sentori-cli upload sourcemap <composed.map>`',
40
- )
41
- }
42
- const out = join(mkdtempSync(join(tmpdir(), 'sentori-rn-')), 'composed.map')
43
- const r = spawnSync('node', [script, metroMap, hermesMap, '-o', out], { stdio: 'inherit' })
44
- if (r.status !== 0) {
45
- throw new Error(`compose-source-maps.js exited with ${r.status ?? 'signal'}`)
46
- }
47
- if (!existsSync(out)) throw new Error('compose-source-maps.js produced no output')
48
- return out
49
- }
50
-
51
- export type RnUploadOptions = {
52
- apiUrl: string
53
- /** Optional bundle file (.jsbundle / .bundle) to upload alongside the map. */
54
- bundle?: string
55
- dryRun?: boolean
56
- hermesMap: string
57
- metroMap: string
58
- release: string
59
- token: string
60
- }
61
-
62
- /** Compose the Metro + Hermes maps, then upload the result (and the
63
- * bundle, if given). Cleans up the temp composed map. */
64
- export async function reactNativeUpload(opts: RnUploadOptions): Promise<{
65
- files: string[]
66
- uploaded?: number
67
- }> {
68
- const composed = composeSourceMaps(opts.metroMap, opts.hermesMap)
69
- try {
70
- const paths = [composed, ...(opts.bundle ? [opts.bundle] : [])]
71
- const r = await uploadSourcemaps({
72
- apiUrl: opts.apiUrl,
73
- dryRun: opts.dryRun,
74
- paths,
75
- release: opts.release,
76
- token: opts.token,
77
- })
78
- return { files: r.files, uploaded: r.uploaded }
79
- } finally {
80
- try {
81
- rmSync(composed, { force: true })
82
- rmSync(join(composed, '..'), { force: true, recursive: true })
83
- } catch {
84
- // best-effort cleanup
85
- }
86
- }
87
- }
package/src/upload.ts DELETED
@@ -1,85 +0,0 @@
1
- import { readFile, readdir, stat } from 'node:fs/promises'
2
- import { basename, join } from 'node:path'
3
-
4
- // Files worth uploading: source maps, and the bundle they map (so the
5
- // dashboard can show the minified line too if a frame falls outside
6
- // the map). `.jsbundle` (iOS) and `.bundle` (Android) are RN's bundle
7
- // names; `.hbc` is the Hermes bytecode bundle.
8
- const UPLOADABLE = /\.(map|js|jsbundle|bundle|hbc)$/i
9
-
10
- export type UploadOptions = {
11
- release: string
12
- token: string
13
- /** Sentori API base, e.g. https://api.sentori.golia.jp or your host. */
14
- apiUrl: string
15
- /** Files or directories. Directories are scanned one level deep. */
16
- paths: string[]
17
- dryRun?: boolean
18
- }
19
-
20
- export type UploadResult = {
21
- files: string[]
22
- uploaded?: number
23
- artifacts?: { kind: string; name: string }[]
24
- }
25
-
26
- /** Resolve `paths` (files or dirs) to a deduped list of files to upload.
27
- * A directory contributes its top-level `.map` / `.js` / `.bundle` /
28
- * `.hbc` files; a file given explicitly is taken as-is (even if its
29
- * extension isn't in the list — the caller asked for it). */
30
- export async function collectFiles(paths: string[]): Promise<string[]> {
31
- const out: string[] = []
32
- for (const p of paths) {
33
- const s = await stat(p).catch(() => null)
34
- if (!s) throw new Error(`no such file or directory: ${p}`)
35
- if (s.isDirectory()) {
36
- for (const entry of await readdir(p)) {
37
- const full = join(p, entry)
38
- const es = await stat(full).catch(() => null)
39
- if (es?.isFile() && UPLOADABLE.test(entry)) out.push(full)
40
- }
41
- } else {
42
- out.push(p)
43
- }
44
- }
45
- const deduped = [...new Set(out)]
46
- if (deduped.length === 0) {
47
- throw new Error('no .map / .js / .bundle files found in the given path(s)')
48
- }
49
- return deduped
50
- }
51
-
52
- export async function uploadSourcemaps(opts: UploadOptions): Promise<UploadResult> {
53
- const files = await collectFiles(opts.paths)
54
- if (opts.dryRun) return { files }
55
-
56
- const form = new FormData()
57
- for (const f of files) {
58
- const buf = await readFile(f)
59
- form.append('file', new Blob([buf]), basename(f))
60
- }
61
-
62
- const base = opts.apiUrl.replace(/\/+$/, '')
63
- const url = `${base}/admin/api/releases/${encodeURIComponent(opts.release)}/sourcemaps`
64
- const resp = await fetch(url, {
65
- body: form,
66
- headers: { Authorization: `Bearer ${opts.token}` },
67
- method: 'POST',
68
- })
69
- if (!resp.ok) {
70
- let detail = ''
71
- try {
72
- detail = await resp.text()
73
- } catch {
74
- // ignore
75
- }
76
- throw new Error(
77
- `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
78
- )
79
- }
80
- const body = (await resp.json().catch(() => ({}))) as {
81
- artifacts?: { kind: string; name: string }[]
82
- uploaded?: number
83
- }
84
- return { artifacts: body.artifacts, files, uploaded: body.uploaded ?? files.length }
85
- }