@goliapkg/sentori-cli 0.5.3 → 1.0.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 (58) 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 +723 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/issue.d.ts +26 -0
  7. package/lib/issue.d.ts.map +1 -0
  8. package/lib/issue.js +50 -0
  9. package/lib/issue.js.map +1 -0
  10. package/lib/lenient.d.ts +13 -0
  11. package/lib/lenient.d.ts.map +1 -0
  12. package/lib/lenient.js +25 -0
  13. package/lib/lenient.js.map +1 -0
  14. package/lib/mcp.d.ts +16 -0
  15. package/lib/mcp.d.ts.map +1 -0
  16. package/lib/mcp.js +194 -0
  17. package/lib/mcp.js.map +1 -0
  18. package/lib/native-artifacts.d.ts +42 -0
  19. package/lib/native-artifacts.d.ts.map +1 -0
  20. package/lib/native-artifacts.js +137 -0
  21. package/lib/native-artifacts.js.map +1 -0
  22. package/lib/probes.d.ts +10 -0
  23. package/lib/probes.d.ts.map +1 -0
  24. package/lib/probes.js +75 -0
  25. package/lib/probes.js.map +1 -0
  26. package/lib/push.d.ts +42 -0
  27. package/lib/push.d.ts.map +1 -0
  28. package/lib/push.js +92 -0
  29. package/lib/push.js.map +1 -0
  30. package/lib/react-native.d.ts +25 -0
  31. package/lib/react-native.d.ts.map +1 -0
  32. package/lib/react-native.js +82 -0
  33. package/lib/react-native.js.map +1 -0
  34. package/lib/source-bundle.d.ts +28 -0
  35. package/lib/source-bundle.d.ts.map +1 -0
  36. package/lib/source-bundle.js +193 -0
  37. package/lib/source-bundle.js.map +1 -0
  38. package/lib/upload.d.ts +13 -0
  39. package/lib/upload.d.ts.map +1 -0
  40. package/lib/upload.js +28 -0
  41. package/lib/upload.js.map +1 -0
  42. package/package.json +23 -13
  43. package/src/__tests__/lenient.test.ts +32 -0
  44. package/src/__tests__/mcp.test.ts +32 -0
  45. package/src/__tests__/native-artifacts.test.ts +125 -0
  46. package/src/__tests__/probes.test.ts +41 -0
  47. package/src/__tests__/react-native.test.ts +37 -0
  48. package/src/index.ts +727 -0
  49. package/src/issue.ts +82 -0
  50. package/src/lenient.ts +39 -0
  51. package/src/mcp.ts +230 -0
  52. package/src/native-artifacts.ts +175 -0
  53. package/src/probes.ts +75 -0
  54. package/src/push.ts +174 -0
  55. package/src/react-native.ts +94 -0
  56. package/src/upload.ts +44 -0
  57. package/bin/sentori-cli.js +0 -32
  58. 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,94 @@
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 { uploadArtifact } from './upload.js'
8
+ import { basename } from 'node:path'
9
+
10
+ /** Resolve `react-native/scripts/compose-source-maps.js` from the
11
+ * current project's node_modules. Returns null if react-native isn't
12
+ * installed or the version doesn't ship that script. */
13
+ export function resolveComposeScript(fromDir = process.cwd()): null | string {
14
+ const req = createRequire(join(fromDir, 'noop.js'))
15
+ for (const id of [
16
+ 'react-native/scripts/compose-source-maps.js',
17
+ '@react-native/community-cli-plugin/dist/utils/composeSourceMaps.js',
18
+ ]) {
19
+ try {
20
+ const p = req.resolve(id)
21
+ if (existsSync(p)) return p
22
+ } catch {
23
+ // try next
24
+ }
25
+ }
26
+ return null
27
+ }
28
+
29
+ /** Compose a Metro packager source map + a Hermes source map into a
30
+ * single map (a temp file the caller is responsible for deleting).
31
+ * Throws if react-native's compose script can't be found or fails. */
32
+ export function composeSourceMaps(metroMap: string, hermesMap: string): string {
33
+ for (const p of [metroMap, hermesMap]) {
34
+ if (!existsSync(p)) throw new Error(`no such file: ${p}`)
35
+ }
36
+ const script = resolveComposeScript()
37
+ if (!script) {
38
+ throw new Error(
39
+ "couldn't find react-native's compose-source-maps.js — install react-native, or " +
40
+ 'compose the maps yourself and use `sentori-cli upload sourcemap <composed.map>`',
41
+ )
42
+ }
43
+ const out = join(mkdtempSync(join(tmpdir(), 'sentori-rn-')), 'composed.map')
44
+ const r = spawnSync('node', [script, metroMap, hermesMap, '-o', out], { stdio: 'inherit' })
45
+ if (r.status !== 0) {
46
+ throw new Error(`compose-source-maps.js exited with ${r.status ?? 'signal'}`)
47
+ }
48
+ if (!existsSync(out)) throw new Error('compose-source-maps.js produced no output')
49
+ return out
50
+ }
51
+
52
+ export type RnUploadOptions = {
53
+ apiUrl: string
54
+ /** Optional bundle file (.jsbundle / .bundle) to upload alongside the map. */
55
+ bundle?: string
56
+ dryRun?: boolean
57
+ hermesMap: string
58
+ metroMap: string
59
+ release: string
60
+ token: string
61
+ }
62
+
63
+ /** Compose the Metro + Hermes maps, then upload the result (and the
64
+ * bundle, if given). Cleans up the temp composed map. */
65
+ export async function reactNativeUpload(opts: RnUploadOptions): Promise<{
66
+ files: string[]
67
+ uploaded?: number
68
+ }> {
69
+ const composed = composeSourceMaps(opts.metroMap, opts.hermesMap)
70
+ try {
71
+ const paths = [composed, ...(opts.bundle ? [opts.bundle] : [])]
72
+ if (opts.dryRun) return { files: paths }
73
+ let uploaded = 0
74
+ for (const p of paths) {
75
+ await uploadArtifact({
76
+ apiUrl: opts.apiUrl,
77
+ token: opts.token,
78
+ release: opts.release,
79
+ kind: 'sourcemap',
80
+ path: p,
81
+ name: basename(p),
82
+ })
83
+ uploaded += 1
84
+ }
85
+ return { files: paths, uploaded }
86
+ } finally {
87
+ try {
88
+ rmSync(composed, { force: true })
89
+ rmSync(join(composed, '..'), { force: true, recursive: true })
90
+ } catch {
91
+ // best-effort cleanup
92
+ }
93
+ }
94
+ }
package/src/upload.ts ADDED
@@ -0,0 +1,44 @@
1
+ // Unified symbolication-artifact upload: sourcemap / dsym / proguard
2
+ // all land on `POST /v1/releases/{release}/artifacts` (multipart
3
+ // `kind` + `file`), authenticated with an api-scope token. A late
4
+ // upload triggers retro-symbolication server-side, which is what
5
+ // makes the lenient exit-0 contract honest — nothing is lost
6
+ // forever.
7
+
8
+ import { readFileSync } from 'node:fs'
9
+ import { basename } from 'node:path'
10
+
11
+ export type UploadOpts = {
12
+ apiUrl: string
13
+ token: string
14
+ release: string
15
+ kind: 'dsym' | 'proguard' | 'sourcemap'
16
+ path: string
17
+ /** Override the stored artifact name (defaults to the filename). */
18
+ name?: string
19
+ }
20
+
21
+ export async function uploadArtifact(opts: UploadOpts): Promise<{ id: string }> {
22
+ const bytes = readFileSync(opts.path)
23
+ if (bytes.length === 0) throw new Error(`empty file: ${opts.path}`)
24
+
25
+ const form = new FormData()
26
+ form.append('kind', opts.kind)
27
+ form.append(
28
+ 'file',
29
+ new Blob([new Uint8Array(bytes)]),
30
+ opts.name ?? basename(opts.path),
31
+ )
32
+
33
+ const url = `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/${encodeURIComponent(opts.release)}/artifacts`
34
+ const resp = await fetch(url, {
35
+ method: 'POST',
36
+ headers: { Authorization: `Bearer ${opts.token}` },
37
+ body: form,
38
+ })
39
+ if (!resp.ok) {
40
+ const detail = await resp.text().catch(() => '')
41
+ throw new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ''}`)
42
+ }
43
+ return (await resp.json()) as { id: string }
44
+ }
@@ -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
- })