@goliapkg/sentori-cli 0.6.0 → 1.1.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 (47) hide show
  1. package/lib/index.js +269 -340
  2. package/lib/index.js.map +1 -1
  3. package/lib/issue.d.ts +16 -18
  4. package/lib/issue.d.ts.map +1 -1
  5. package/lib/issue.js +29 -32
  6. package/lib/issue.js.map +1 -1
  7. package/lib/lenient.d.ts +13 -0
  8. package/lib/lenient.d.ts.map +1 -0
  9. package/lib/lenient.js +25 -0
  10. package/lib/lenient.js.map +1 -0
  11. package/lib/mcp.d.ts +4 -2
  12. package/lib/mcp.d.ts.map +1 -1
  13. package/lib/mcp.js +43 -220
  14. package/lib/mcp.js.map +1 -1
  15. package/lib/native-artifacts.d.ts +0 -1
  16. package/lib/native-artifacts.d.ts.map +1 -1
  17. package/lib/native-artifacts.js +34 -41
  18. package/lib/native-artifacts.js.map +1 -1
  19. package/lib/probes.d.ts +10 -0
  20. package/lib/probes.d.ts.map +1 -0
  21. package/lib/probes.js +75 -0
  22. package/lib/probes.js.map +1 -0
  23. package/lib/react-native.d.ts.map +1 -1
  24. package/lib/react-native.js +17 -9
  25. package/lib/react-native.js.map +1 -1
  26. package/lib/upload.d.ts +21 -20
  27. package/lib/upload.d.ts.map +1 -1
  28. package/lib/upload.js +65 -57
  29. package/lib/upload.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/__tests__/lenient.test.ts +32 -0
  32. package/src/__tests__/mcp.test.ts +20 -112
  33. package/src/__tests__/native-artifacts.test.ts +43 -12
  34. package/src/__tests__/probes.test.ts +41 -0
  35. package/src/index.ts +291 -362
  36. package/src/issue.ts +47 -46
  37. package/src/lenient.ts +39 -0
  38. package/src/mcp.ts +46 -215
  39. package/src/native-artifacts.ts +42 -44
  40. package/src/probes.ts +75 -0
  41. package/src/react-native.ts +16 -9
  42. package/src/upload.ts +78 -68
  43. package/src/__tests__/issue.test.ts +0 -95
  44. package/src/__tests__/source-bundle-from-dir.test.ts +0 -85
  45. package/src/__tests__/source-bundle.test.ts +0 -105
  46. package/src/__tests__/upload.test.ts +0 -121
  47. package/src/source-bundle.ts +0 -234
@@ -1,121 +0,0 @@
1
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
- import { tmpdir } from 'node:os'
3
- import { join } from 'node:path'
4
-
5
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
6
-
7
- import { collectFiles, uploadSourcemaps } from '../upload.js'
8
-
9
- let dir = ''
10
- beforeEach(async () => {
11
- dir = await mkdtemp(join(tmpdir(), 'sentori-cli-test-'))
12
- await writeFile(join(dir, 'main.jsbundle'), '/*bundle*/')
13
- await writeFile(join(dir, 'main.jsbundle.map'), '{"version":3}')
14
- await writeFile(join(dir, 'app.js'), 'console.log(1)')
15
- await writeFile(join(dir, 'app.js.map'), '{"version":3}')
16
- await writeFile(join(dir, 'README.txt'), 'not a build artifact')
17
- })
18
- afterEach(async () => {
19
- await rm(dir, { force: true, recursive: true })
20
- })
21
-
22
- describe('collectFiles', () => {
23
- test('a directory yields its .map / .js / .bundle files, not others', async () => {
24
- const files = await collectFiles([dir])
25
- const names = files.map((f) => f.replace(`${dir}/`, '')).sort()
26
- expect(names).toEqual(['app.js', 'app.js.map', 'main.jsbundle', 'main.jsbundle.map'])
27
- })
28
-
29
- test('an explicit file is taken as-is regardless of extension', async () => {
30
- const files = await collectFiles([join(dir, 'README.txt')])
31
- expect(files).toEqual([join(dir, 'README.txt')])
32
- })
33
-
34
- test('dedupes when a file is named both directly and via its dir', async () => {
35
- const files = await collectFiles([dir, join(dir, 'app.js.map')])
36
- expect(files.filter((f) => f.endsWith('app.js.map'))).toHaveLength(1)
37
- })
38
-
39
- test('throws on a nonexistent path', async () => {
40
- await expect(collectFiles([join(dir, 'nope')])).rejects.toThrow('no such file or directory')
41
- })
42
-
43
- test('throws when a directory has no uploadable files', async () => {
44
- const empty = await mkdtemp(join(tmpdir(), 'sentori-cli-empty-'))
45
- try {
46
- await expect(collectFiles([empty])).rejects.toThrow('no .map')
47
- } finally {
48
- await rm(empty, { force: true, recursive: true })
49
- }
50
- })
51
- })
52
-
53
- describe('uploadSourcemaps', () => {
54
- test('dryRun returns the file list, makes no request', async () => {
55
- let called = false
56
- const orig = globalThis.fetch
57
- globalThis.fetch = (async () => {
58
- called = true
59
- return new Response()
60
- }) as typeof fetch
61
- try {
62
- const r = await uploadSourcemaps({
63
- apiUrl: 'https://api.example.com',
64
- dryRun: true,
65
- paths: [dir],
66
- release: 'app@1.0.0+1',
67
- token: 'st_pk_x',
68
- })
69
- expect(r.files).toHaveLength(4)
70
- expect(called).toBe(false)
71
- } finally {
72
- globalThis.fetch = orig
73
- }
74
- })
75
-
76
- test('POSTs multipart to /admin/api/releases/<release>/sourcemaps with the token', async () => {
77
- const calls: { headers: Headers; url: string }[] = []
78
- const orig = globalThis.fetch
79
- globalThis.fetch = (async (url: Request | string | URL, init?: RequestInit) => {
80
- calls.push({ headers: new Headers(init?.headers), url: String(url) })
81
- return new Response(JSON.stringify({ artifacts: [], uploaded: 4 }), {
82
- headers: { 'content-type': 'application/json' },
83
- status: 200,
84
- })
85
- }) as typeof fetch
86
- try {
87
- const r = await uploadSourcemaps({
88
- apiUrl: 'https://api.example.com/',
89
- paths: [dir],
90
- release: 'my app@1.0.0+1',
91
- token: 'st_pk_secret',
92
- })
93
- expect(calls).toHaveLength(1)
94
- expect(calls[0]?.url).toBe(
95
- 'https://api.example.com/admin/api/releases/my%20app%401.0.0%2B1/sourcemaps',
96
- )
97
- expect(calls[0]?.headers.get('authorization')).toBe('Bearer st_pk_secret')
98
- expect(r.uploaded).toBe(4)
99
- } finally {
100
- globalThis.fetch = orig
101
- }
102
- })
103
-
104
- test('throws with the server detail on a non-2xx response', async () => {
105
- const orig = globalThis.fetch
106
- globalThis.fetch = (async () =>
107
- new Response('release frozen', { status: 403, statusText: 'Forbidden' })) as typeof fetch
108
- try {
109
- await expect(
110
- uploadSourcemaps({
111
- apiUrl: 'https://api.example.com',
112
- paths: [dir],
113
- release: 'app@1.0.0+1',
114
- token: 'st_pk_x',
115
- }),
116
- ).rejects.toThrow('403 Forbidden — release frozen')
117
- } finally {
118
- globalThis.fetch = orig
119
- }
120
- })
121
- })
@@ -1,234 +0,0 @@
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
- }