@goliapkg/sentori-cli 0.5.3 → 0.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 (51) hide show
  1. package/README.md +27 -32
  2. package/lib/index.d.ts +3 -0
  3. package/lib/index.d.ts.map +1 -0
  4. package/lib/index.js +795 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/issue.d.ts +28 -0
  7. package/lib/issue.d.ts.map +1 -0
  8. package/lib/issue.js +53 -0
  9. package/lib/issue.js.map +1 -0
  10. package/lib/mcp.d.ts +14 -0
  11. package/lib/mcp.d.ts.map +1 -0
  12. package/lib/mcp.js +371 -0
  13. package/lib/mcp.js.map +1 -0
  14. package/lib/native-artifacts.d.ts +43 -0
  15. package/lib/native-artifacts.d.ts.map +1 -0
  16. package/lib/native-artifacts.js +148 -0
  17. package/lib/native-artifacts.js.map +1 -0
  18. package/lib/push.d.ts +42 -0
  19. package/lib/push.d.ts.map +1 -0
  20. package/lib/push.js +92 -0
  21. package/lib/push.js.map +1 -0
  22. package/lib/react-native.d.ts +25 -0
  23. package/lib/react-native.d.ts.map +1 -0
  24. package/lib/react-native.js +74 -0
  25. package/lib/react-native.js.map +1 -0
  26. package/lib/source-bundle.d.ts +28 -0
  27. package/lib/source-bundle.d.ts.map +1 -0
  28. package/lib/source-bundle.js +193 -0
  29. package/lib/source-bundle.js.map +1 -0
  30. package/lib/upload.d.ts +24 -0
  31. package/lib/upload.d.ts.map +1 -0
  32. package/lib/upload.js +65 -0
  33. package/lib/upload.js.map +1 -0
  34. package/package.json +23 -13
  35. package/src/__tests__/issue.test.ts +95 -0
  36. package/src/__tests__/mcp.test.ts +124 -0
  37. package/src/__tests__/native-artifacts.test.ts +132 -0
  38. package/src/__tests__/react-native.test.ts +37 -0
  39. package/src/__tests__/source-bundle-from-dir.test.ts +85 -0
  40. package/src/__tests__/source-bundle.test.ts +105 -0
  41. package/src/__tests__/upload.test.ts +121 -0
  42. package/src/index.ts +799 -0
  43. package/src/issue.ts +81 -0
  44. package/src/mcp.ts +399 -0
  45. package/src/native-artifacts.ts +182 -0
  46. package/src/push.ts +174 -0
  47. package/src/react-native.ts +87 -0
  48. package/src/source-bundle.ts +234 -0
  49. package/src/upload.ts +85 -0
  50. package/bin/sentori-cli.js +0 -32
  51. package/scripts/postinstall.js +0 -90
package/src/push.ts ADDED
@@ -0,0 +1,174 @@
1
+ // v2.12 — `sentori-cli push *` commands.
2
+ //
3
+ // Wraps the v2.7 admin REST + v2.7 ingest endpoints so operators can
4
+ // drive credential CRUD, ad-hoc sends, and receipt lookups from the
5
+ // terminal without touching curl.
6
+ //
7
+ // `push send` POST /v1/push/send (ingest Bearer)
8
+ // `push receipt` GET /v1/push/receipts/:id (ingest Bearer)
9
+ // `push creds list` GET /admin/api/projects/:id/push/credentials (admin Bearer)
10
+ // `push creds set` PUT /admin/api/projects/:id/push/credentials (admin Bearer)
11
+ // `push creds delete` DELETE /admin/api/projects/:id/push/credentials/:provider
12
+
13
+ import { readFileSync } from 'node:fs'
14
+
15
+ export type PushClientConfig = {
16
+ apiUrl: string
17
+ projectId: string
18
+ token: string
19
+ }
20
+
21
+ type AdminCredentialRow = {
22
+ provider: string
23
+ config: Record<string, unknown>
24
+ updatedAt: string
25
+ }
26
+
27
+ type Ticket = {
28
+ id: string
29
+ status: 'queued' | 'sent' | 'failed'
30
+ providerOutcome?: string | null
31
+ error?: string | null
32
+ retryCount: number
33
+ createdAt: string
34
+ sentAt?: string | null
35
+ }
36
+
37
+ const VALID_PROVIDERS = new Set(['apns', 'fcm', 'webpush', 'hcm', 'mipush'])
38
+
39
+ function joinUrl(base: string, path: string): string {
40
+ return `${base.replace(/\/+$/, '')}${path}`
41
+ }
42
+
43
+ async function bearerFetch<T>(
44
+ url: string,
45
+ token: string,
46
+ init?: RequestInit,
47
+ ): Promise<T> {
48
+ const resp = await fetch(url, {
49
+ ...init,
50
+ headers: {
51
+ Authorization: `Bearer ${token}`,
52
+ 'Content-Type': 'application/json',
53
+ ...(init?.headers ?? {}),
54
+ },
55
+ })
56
+ if (!resp.ok) {
57
+ const detail = await resp.text().catch(() => '')
58
+ throw new Error(
59
+ `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
60
+ )
61
+ }
62
+ const txt = await resp.text()
63
+ return (txt ? JSON.parse(txt) : null) as T
64
+ }
65
+
66
+ /** Parse a CLI flag value that may be `@file.json` (read from disk
67
+ * and parse) or a literal JSON string. */
68
+ export function parseJsonArg(raw: string, kind: string): unknown {
69
+ if (raw.startsWith('@')) {
70
+ const path = raw.slice(1)
71
+ const body = readFileSync(path, 'utf-8')
72
+ try {
73
+ return JSON.parse(body)
74
+ } catch (e) {
75
+ throw new Error(`${kind} file ${path} is not valid JSON: ${(e as Error).message}`)
76
+ }
77
+ }
78
+ try {
79
+ return JSON.parse(raw)
80
+ } catch (e) {
81
+ throw new Error(`${kind} arg is not valid JSON: ${(e as Error).message}`)
82
+ }
83
+ }
84
+
85
+ // ── credential CRUD ───────────────────────────────────────────────
86
+
87
+ export async function pushCredsList(cfg: PushClientConfig): Promise<AdminCredentialRow[]> {
88
+ return bearerFetch<AdminCredentialRow[]>(
89
+ joinUrl(cfg.apiUrl, `/admin/api/projects/${cfg.projectId}/push/credentials`),
90
+ cfg.token,
91
+ )
92
+ }
93
+
94
+ export async function pushCredsSet(
95
+ cfg: PushClientConfig,
96
+ provider: string,
97
+ config: unknown,
98
+ secret: unknown,
99
+ ): Promise<{ ok: boolean }> {
100
+ if (!VALID_PROVIDERS.has(provider)) {
101
+ throw new Error(
102
+ `invalid provider '${provider}'; expected one of ${[...VALID_PROVIDERS].join('/')}`,
103
+ )
104
+ }
105
+ return bearerFetch<{ ok: boolean }>(
106
+ joinUrl(cfg.apiUrl, `/admin/api/projects/${cfg.projectId}/push/credentials`),
107
+ cfg.token,
108
+ {
109
+ body: JSON.stringify({ provider, config, secret }),
110
+ method: 'PUT',
111
+ },
112
+ )
113
+ }
114
+
115
+ export async function pushCredsDelete(
116
+ cfg: PushClientConfig,
117
+ provider: string,
118
+ ): Promise<void> {
119
+ await bearerFetch<null>(
120
+ joinUrl(
121
+ cfg.apiUrl,
122
+ `/admin/api/projects/${cfg.projectId}/push/credentials/${provider}`,
123
+ ),
124
+ cfg.token,
125
+ { method: 'DELETE' },
126
+ )
127
+ }
128
+
129
+ // ── send / receipt ────────────────────────────────────────────────
130
+
131
+ export type SendOpts = {
132
+ to: string
133
+ title?: string
134
+ body?: string
135
+ data?: unknown
136
+ priority?: 'high' | 'normal'
137
+ ttl?: number
138
+ idempotencyKey?: string
139
+ }
140
+
141
+ export async function pushSend(cfg: PushClientConfig, opts: SendOpts): Promise<Ticket> {
142
+ const payload: Record<string, unknown> = {
143
+ to: opts.to,
144
+ title: opts.title,
145
+ body: opts.body,
146
+ data: opts.data,
147
+ idempotencyKey: opts.idempotencyKey,
148
+ }
149
+ if (opts.priority || opts.ttl != null) {
150
+ payload.options = { priority: opts.priority, ttl: opts.ttl }
151
+ }
152
+ const resp = await bearerFetch<{ tickets: Ticket[] }>(
153
+ joinUrl(cfg.apiUrl, '/v1/push/send'),
154
+ cfg.token,
155
+ {
156
+ body: JSON.stringify(payload),
157
+ method: 'POST',
158
+ },
159
+ )
160
+ if (!resp.tickets?.length) {
161
+ throw new Error('server returned no tickets')
162
+ }
163
+ return resp.tickets[0]!
164
+ }
165
+
166
+ export async function pushReceipt(
167
+ cfg: PushClientConfig,
168
+ sendId: string,
169
+ ): Promise<{ ticket: Ticket }> {
170
+ return bearerFetch<{ ticket: Ticket }>(
171
+ joinUrl(cfg.apiUrl, `/v1/push/receipts/${encodeURIComponent(sendId)}`),
172
+ cfg.token,
173
+ )
174
+ }
@@ -0,0 +1,87 @@
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
+ }
@@ -0,0 +1,234 @@
1
+ // v1.2 W3.a — `sentori-cli upload source-bundle`.
2
+ //
3
+ // Uploads a pre-built `*.tar.gz` archive of the project's source tree
4
+ // for one platform. Server stores it under
5
+ // `release_artifacts` with `kind = source_bundle_<platform>`; on
6
+ // click-frame, the dashboard's FrameSourceDrawer pulls the matching
7
+ // source file out of the archive and shows ±N lines.
8
+ //
9
+ // We intentionally keep the CLI side small: the operator (or CI)
10
+ // already knows how to build a tarball. v1.3 may add an
11
+ // auto-bundle-from-directory mode; for now you do:
12
+ //
13
+ // tar -czf ios-source.tar.gz Sources/
14
+ // sentori-cli upload source-bundle --project <uuid> \
15
+ // --release myapp@1.0.0 --platform ios --path ios-source.tar.gz
16
+
17
+ import { spawnSync } from 'node:child_process'
18
+ import { mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises'
19
+ import { tmpdir } from 'node:os'
20
+ import { join, relative, sep as pathSep } from 'node:path'
21
+
22
+ import type { AdminUpload } from './native-artifacts.js'
23
+
24
+ const PLATFORMS = new Set(['ios', 'android'])
25
+
26
+ /** v1.2 W3.d — per-platform file extensions the CLI bundles when
27
+ * `--from-dir` mode picks files from a project tree. Kept narrow on
28
+ * purpose: source-bundle is only useful for native source viewing,
29
+ * and including non-source files would balloon the archive without
30
+ * helping the lookup path. */
31
+ const EXTENSIONS: Record<'android' | 'ios', string[]> = {
32
+ android: ['.kt', '.java'],
33
+ ios: ['.swift', '.m', '.mm', '.h', '.hpp'],
34
+ }
35
+
36
+ const SKIP_DIRS = new Set([
37
+ '.git',
38
+ 'node_modules',
39
+ 'Pods',
40
+ 'build',
41
+ 'DerivedData',
42
+ '.gradle',
43
+ '.build',
44
+ 'target',
45
+ ])
46
+
47
+ export type SourceBundleUploadOptions = AdminUpload & {
48
+ /** v1.4 W26 — optional module label so polyrepo apps (main +
49
+ * watch ext + share ext etc.) can upload multiple bundles per
50
+ * (release, platform). Empty/undefined → unlabelled single
51
+ * bundle (v1.3 W15 behaviour). */
52
+ module?: string
53
+ /** Pre-built tar.gz archive of the project's source tree. */
54
+ path: string
55
+ platform: 'android' | 'ios'
56
+ }
57
+
58
+ export type SourceBundleUploadResult = {
59
+ contentHash: string
60
+ kind: string
61
+ sizeBytes: number
62
+ }
63
+
64
+ export async function uploadSourceBundle(
65
+ opts: SourceBundleUploadOptions,
66
+ ): Promise<SourceBundleUploadResult> {
67
+ if (!PLATFORMS.has(opts.platform)) {
68
+ throw new Error(`--platform must be 'ios' or 'android' (got '${opts.platform}')`)
69
+ }
70
+ if (!opts.release) {
71
+ throw new Error('--release is required for source-bundle uploads')
72
+ }
73
+
74
+ // v1.2 W3.d: when `opts.path` is a directory, bundle it on the fly
75
+ // instead of requiring the operator to pre-build the archive.
76
+ let archivePath = opts.path
77
+ let cleanup: undefined | (() => Promise<void>)
78
+ try {
79
+ const st = await stat(opts.path)
80
+ if (st.isDirectory()) {
81
+ const built = await buildSourceBundleFromDir(opts.path, opts.platform)
82
+ archivePath = built.path
83
+ cleanup = built.cleanup
84
+ }
85
+ } catch (e) {
86
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
87
+ throw new Error(`source path not found: ${opts.path}`)
88
+ }
89
+
90
+ try {
91
+ return await uploadPrebuiltTarGz({ ...opts, path: archivePath })
92
+ } finally {
93
+ if (cleanup) await cleanup()
94
+ }
95
+ }
96
+
97
+ async function uploadPrebuiltTarGz(
98
+ opts: SourceBundleUploadOptions,
99
+ ): Promise<SourceBundleUploadResult> {
100
+ const body = await readFile(opts.path)
101
+ if (body.length === 0) throw new Error(`empty archive: ${opts.path}`)
102
+ // Same gzip magic check the server enforces. Failing early here
103
+ // saves a network round-trip when an operator accidentally points
104
+ // at a raw .tar.
105
+ if (body.length < 2 || body[0] !== 0x1f || body[1] !== 0x8b) {
106
+ throw new Error(
107
+ `${opts.path} does not look like a gzip stream (expected 1f 8b magic) — ` +
108
+ `did you mean to gzip it first? \`tar -czf <out>.tar.gz <dir>/\``,
109
+ )
110
+ }
111
+ if (!opts.release) throw new Error('--release is required for source-bundle uploads')
112
+ const base = opts.apiUrl.replace(/\/+$/, '')
113
+ const q = new URLSearchParams()
114
+ q.set('release', opts.release)
115
+ q.set('platform', opts.platform)
116
+ if (opts.module) q.set('module', opts.module)
117
+ const url = `${base}/admin/api/projects/${encodeURIComponent(opts.projectId)}/source-bundles?${q.toString()}`
118
+
119
+ const resp = await fetch(url, {
120
+ body,
121
+ headers: {
122
+ Authorization: `Bearer ${opts.token}`,
123
+ 'Content-Type': 'application/gzip',
124
+ },
125
+ method: 'POST',
126
+ })
127
+ if (!resp.ok) {
128
+ let detail = ''
129
+ try {
130
+ detail = await resp.text()
131
+ } catch {
132
+ // ignore
133
+ }
134
+ throw new Error(
135
+ `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
136
+ )
137
+ }
138
+ const parsed: unknown = await resp.json()
139
+ if (
140
+ !parsed ||
141
+ typeof parsed !== 'object' ||
142
+ typeof (parsed as { contentHash?: unknown }).contentHash !== 'string'
143
+ ) {
144
+ throw new Error(`unexpected response shape: ${JSON.stringify(parsed)}`)
145
+ }
146
+ const r = parsed as { contentHash: string; kind: string; sizeBytes: number }
147
+ return { contentHash: r.contentHash, kind: r.kind, sizeBytes: r.sizeBytes }
148
+ }
149
+
150
+ /** Walk `dir`, pick platform-relevant source files, and tar.gz them
151
+ * into a temp file. Caller is responsible for invoking `cleanup()`.
152
+ * Uses the system `tar` binary; that's portable enough on macOS +
153
+ * Linux + WSL, which covers every realistic CI environment Sentori
154
+ * ships to. Native Windows CI without WSL would need to gzip the
155
+ * archive themselves (the pre-built-path mode still works). */
156
+ export async function buildSourceBundleFromDir(
157
+ dir: string,
158
+ platform: 'android' | 'ios',
159
+ ): Promise<{ cleanup: () => Promise<void>; path: string }> {
160
+ const exts = EXTENSIONS[platform]
161
+ const files: string[] = []
162
+ await walk(dir, dir, exts, files)
163
+ if (files.length === 0) {
164
+ throw new Error(
165
+ `no ${platform} source files (${exts.join(', ')}) found under ${dir} — ` +
166
+ `pass --platform with the right value or check your source layout`,
167
+ )
168
+ }
169
+ files.sort()
170
+
171
+ // Write the file list to a temp file, then have tar consume it via
172
+ // -T —. Avoids the per-arg shell length cap on huge projects.
173
+ const tmp = await mkdtemp(join(tmpdir(), 'sentori-srcbun-'))
174
+ const listPath = join(tmp, 'files.txt')
175
+ const archivePath = join(tmp, `${platform}-source.tar.gz`)
176
+ await writeFile(listPath, files.join('\n'))
177
+ const r = spawnSync('tar', ['-czf', archivePath, '-C', dir, '-T', listPath])
178
+ if (r.status !== 0) {
179
+ throw new Error(
180
+ `tar failed (status ${r.status}): ${(r.stderr ?? '').toString().slice(0, 300)}`,
181
+ )
182
+ }
183
+ return {
184
+ cleanup: async () => {
185
+ try {
186
+ await readdir(tmp).then(async (entries) => {
187
+ for (const e of entries) {
188
+ await unlinkIfExists(join(tmp, e))
189
+ }
190
+ })
191
+ await unlinkIfExists(tmp, true)
192
+ } catch {
193
+ // best-effort cleanup
194
+ }
195
+ },
196
+ path: archivePath,
197
+ }
198
+ }
199
+
200
+ async function walk(
201
+ root: string,
202
+ dir: string,
203
+ exts: string[],
204
+ out: string[],
205
+ ): Promise<void> {
206
+ const entries = await readdir(dir, { withFileTypes: true })
207
+ for (const e of entries) {
208
+ if (e.name.startsWith('.') && e.isDirectory() && e.name !== '.') {
209
+ // Hidden directory: skip (.git, .gradle, .build…). The known
210
+ // ones are listed in SKIP_DIRS for clarity, but any leading-dot
211
+ // dir is also skipped as a safety net.
212
+ continue
213
+ }
214
+ if (SKIP_DIRS.has(e.name)) continue
215
+ const full = join(dir, e.name)
216
+ if (e.isDirectory()) {
217
+ await walk(root, full, exts, out)
218
+ } else if (e.isFile()) {
219
+ const lower = e.name.toLowerCase()
220
+ if (exts.some((ext) => lower.endsWith(ext))) {
221
+ out.push(relative(root, full).split(pathSep).join('/'))
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ async function unlinkIfExists(path: string, isDir = false): Promise<void> {
228
+ const { rm } = await import('node:fs/promises')
229
+ try {
230
+ await rm(path, { force: true, recursive: isDir })
231
+ } catch {
232
+ // ignore
233
+ }
234
+ }
package/src/upload.ts ADDED
@@ -0,0 +1,85 @@
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://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
+ }
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env node
2
- // Phase 17 sub-B: Node entry point for `npx @goliapkg/sentori-cli ...`.
3
- // Forwards argv + stdio to the prebuilt Rust binary that postinstall
4
- // dropped at ../vendor/sentori-cli.
5
-
6
- 'use strict'
7
-
8
- const path = require('node:path')
9
- const fs = require('node:fs')
10
- const { spawn } = require('node:child_process')
11
-
12
- const bin = path.join(__dirname, '..', 'vendor', 'sentori-cli')
13
- if (!fs.existsSync(bin)) {
14
- console.error(
15
- 'sentori-cli binary missing — postinstall did not complete. ' +
16
- 'Try: `npm rebuild @goliapkg/sentori-cli` or set SENTORI_SKIP_DOWNLOAD=0 then reinstall.'
17
- )
18
- process.exit(127)
19
- }
20
-
21
- const child = spawn(bin, process.argv.slice(2), { stdio: 'inherit' })
22
- child.on('exit', (code, signal) => {
23
- if (signal) {
24
- process.kill(process.pid, signal)
25
- } else {
26
- process.exit(code ?? 1)
27
- }
28
- })
29
- child.on('error', (e) => {
30
- console.error('failed to run sentori-cli:', e.message)
31
- process.exit(1)
32
- })
@@ -1,90 +0,0 @@
1
- #!/usr/bin/env node
2
- // Phase 17 sub-B: download the prebuilt sentori-cli binary that
3
- // matches the current platform / arch and place it at ../vendor/.
4
- //
5
- // Skips if SENTORI_SKIP_DOWNLOAD=1 (CI, monorepo bootstrap, etc.).
6
-
7
- 'use strict'
8
-
9
- const fs = require('node:fs')
10
- const https = require('node:https')
11
- const path = require('node:path')
12
- const { spawnSync } = require('node:child_process')
13
-
14
- const pkg = require(path.join(__dirname, '..', 'package.json'))
15
- const TAG = `cli-v${pkg.version}`
16
-
17
- const SUPPORTED = {
18
- 'linux-x64': 'linux-x64',
19
- 'linux-arm64': 'linux-arm64',
20
- 'darwin-x64': 'darwin-x64',
21
- 'darwin-arm64': 'darwin-arm64',
22
- }
23
-
24
- function detect() {
25
- return `${process.platform}-${process.arch}`
26
- }
27
-
28
- function fetch(url, destStream) {
29
- return new Promise((resolve, reject) => {
30
- const handle = (u) => {
31
- https
32
- .get(u, { headers: { 'user-agent': `sentori-cli-installer/${pkg.version}` } }, (res) => {
33
- const code = res.statusCode || 0
34
- if (code >= 300 && code < 400 && res.headers.location) {
35
- return handle(res.headers.location)
36
- }
37
- if (code !== 200) {
38
- return reject(new Error(`HTTP ${code} from ${u}`))
39
- }
40
- res.pipe(destStream)
41
- destStream.on('finish', resolve)
42
- destStream.on('error', reject)
43
- })
44
- .on('error', reject)
45
- }
46
- handle(url)
47
- })
48
- }
49
-
50
- async function main() {
51
- if (process.env.SENTORI_SKIP_DOWNLOAD === '1') {
52
- console.log('SENTORI_SKIP_DOWNLOAD=1; skipping sentori-cli binary download')
53
- return
54
- }
55
-
56
- const key = detect()
57
- const slug = SUPPORTED[key]
58
- if (!slug) {
59
- console.error(
60
- `sentori-cli has no prebuilt binary for ${key}. ` +
61
- `Cargo install instead: cargo install --git https://github.com/goliajp/sentori --bin sentori-cli`
62
- )
63
- process.exit(0) // soft fail — user can still cargo install
64
- }
65
-
66
- const filename = `sentori-cli-${TAG}-${slug}.tar.gz`
67
- const url = `https://github.com/goliajp/sentori/releases/download/${TAG}/${filename}`
68
- const vendorDir = path.join(__dirname, '..', 'vendor')
69
- fs.mkdirSync(vendorDir, { recursive: true })
70
- const tarball = path.join(vendorDir, 'sentori-cli.tar.gz')
71
-
72
- console.log(`sentori-cli ${pkg.version}: downloading ${slug} from GitHub Release`)
73
- await fetch(url, fs.createWriteStream(tarball))
74
-
75
- const tar = spawnSync('tar', ['-xzf', tarball, '-C', vendorDir], { stdio: 'inherit' })
76
- if (tar.status !== 0) {
77
- console.error('tar -xzf failed; is `tar` on PATH?')
78
- process.exit(1)
79
- }
80
- fs.unlinkSync(tarball)
81
-
82
- const bin = path.join(vendorDir, 'sentori-cli')
83
- fs.chmodSync(bin, 0o755)
84
- console.log(`✓ sentori-cli ${pkg.version} ready (${slug})`)
85
- }
86
-
87
- main().catch((e) => {
88
- console.error('sentori-cli install failed:', e.message)
89
- process.exit(1)
90
- })