@goliapkg/sentori-cli 0.2.0 → 0.2.1

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/lib/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/lib/index.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { uploadSourcemaps } from './upload.js';
4
+ const HELP = `sentori-cli — upload release artifacts to Sentori
5
+
6
+ Usage:
7
+ sentori-cli upload sourcemap [options] <path...>
8
+
9
+ <path> one or more files or directories. A directory is scanned
10
+ (one level) for *.map and *.js / *.bundle / *.hbc files.
11
+ A file given explicitly is uploaded as-is.
12
+
13
+ Options:
14
+ --release <r> release identifier — MUST equal the value the SDK
15
+ reports via init({ release }). Required. A mismatch
16
+ means the dashboard silently can't symbolicate.
17
+ --token <t> Sentori token (or set $SENTORI_TOKEN).
18
+ --api-url <url> Sentori API base (default https://api.sentori.golia.jp,
19
+ or $SENTORI_API_URL). For a self-hosted instance, your
20
+ host. (Accepts --ingest-url as an alias.)
21
+ --dry-run list what would be uploaded; don't upload.
22
+ -h, --help show this help.
23
+
24
+ React Native / Hermes: in a release build the *minified* map (Metro)
25
+ and the *bytecode* map (Hermes) must be composed first —
26
+ npx react-native bundle --platform ios --dev false \\
27
+ --entry-file index.js --bundle-output main.jsbundle \\
28
+ --sourcemap-output main.jsbundle.packager.map
29
+ node node_modules/react-native/scripts/compose-source-maps.js \\
30
+ main.jsbundle.packager.map main.jsbundle.hbc.map -o main.jsbundle.map
31
+ then \`sentori-cli upload sourcemap --release "<app>@<version>+<build>" main.jsbundle.map main.jsbundle\`.
32
+ (Web bundlers emit a usable .map directly — just point at the build dir.)
33
+ `;
34
+ async function main(argv) {
35
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
36
+ console.log(HELP);
37
+ return 0;
38
+ }
39
+ if (argv[0] !== 'upload' || argv[1] !== 'sourcemap') {
40
+ console.error(`unknown command: ${argv.slice(0, 2).join(' ') || '(none)'}\n`);
41
+ console.error(HELP);
42
+ return 2;
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = parseArgs({
47
+ allowPositionals: true,
48
+ args: argv.slice(2),
49
+ options: {
50
+ 'api-url': { type: 'string' },
51
+ 'dry-run': { type: 'boolean' },
52
+ help: { short: 'h', type: 'boolean' },
53
+ 'ingest-url': { type: 'string' }, // alias, kept for older docs
54
+ release: { type: 'string' },
55
+ token: { type: 'string' },
56
+ },
57
+ });
58
+ }
59
+ catch (e) {
60
+ console.error(`error: ${e.message}\n`);
61
+ console.error(HELP);
62
+ return 2;
63
+ }
64
+ const { positionals, values } = parsed;
65
+ if (values.help) {
66
+ console.log(HELP);
67
+ return 0;
68
+ }
69
+ const release = values.release;
70
+ if (!release) {
71
+ console.error('error: --release is required (must match the SDK’s init({ release }))');
72
+ return 2;
73
+ }
74
+ const dryRun = !!values['dry-run'];
75
+ const token = values.token ?? process.env.SENTORI_TOKEN;
76
+ if (!token && !dryRun) {
77
+ console.error('error: --token (or $SENTORI_TOKEN) is required');
78
+ return 2;
79
+ }
80
+ const apiUrl = values['api-url'] ??
81
+ values['ingest-url'] ??
82
+ process.env.SENTORI_API_URL ??
83
+ 'https://api.sentori.golia.jp';
84
+ if (positionals.length === 0) {
85
+ console.error('error: at least one path (file or directory) is required');
86
+ return 2;
87
+ }
88
+ try {
89
+ const result = await uploadSourcemaps({
90
+ apiUrl,
91
+ dryRun,
92
+ paths: positionals,
93
+ release,
94
+ token: token ?? '',
95
+ });
96
+ if (dryRun) {
97
+ console.log(`would upload ${result.files.length} file(s) to ${apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(release)}/sourcemaps:`);
98
+ for (const f of result.files)
99
+ console.log(` ${f}`);
100
+ }
101
+ else {
102
+ console.log(`uploaded ${result.uploaded} file(s) for release "${release}"`);
103
+ for (const a of result.artifacts ?? [])
104
+ console.log(` ${a.name} [${a.kind}]`);
105
+ console.log('done — minified stack traces on this release will now resolve to source.');
106
+ }
107
+ return 0;
108
+ }
109
+ catch (e) {
110
+ console.error(`upload failed: ${e.message}`);
111
+ return 1;
112
+ }
113
+ }
114
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
115
+ console.error(`fatal: ${e.message}`);
116
+ process.exit(1);
117
+ });
118
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ export type UploadOptions = {
2
+ release: string;
3
+ token: string;
4
+ /** Sentori API base, e.g. https://api.sentori.golia.jp or your host. */
5
+ apiUrl: string;
6
+ /** Files or directories. Directories are scanned one level deep. */
7
+ paths: string[];
8
+ dryRun?: boolean;
9
+ };
10
+ export type UploadResult = {
11
+ files: string[];
12
+ uploaded?: number;
13
+ artifacts?: {
14
+ kind: string;
15
+ name: string;
16
+ }[];
17
+ };
18
+ /** Resolve `paths` (files or dirs) to a deduped list of files to upload.
19
+ * A directory contributes its top-level `.map` / `.js` / `.bundle` /
20
+ * `.hbc` files; a file given explicitly is taken as-is (even if its
21
+ * extension isn't in the list — the caller asked for it). */
22
+ export declare function collectFiles(paths: string[]): Promise<string[]>;
23
+ export declare function uploadSourcemaps(opts: UploadOptions): Promise<UploadResult>;
24
+ //# sourceMappingURL=upload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAA;IACd,oEAAoE;IACpE,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC7C,CAAA;AAED;;;8DAG8D;AAC9D,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAoBrE;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAiCjF"}
package/lib/upload.js ADDED
@@ -0,0 +1,65 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ // Files worth uploading: source maps, and the bundle they map (so the
4
+ // dashboard can show the minified line too if a frame falls outside
5
+ // the map). `.jsbundle` (iOS) and `.bundle` (Android) are RN's bundle
6
+ // names; `.hbc` is the Hermes bytecode bundle.
7
+ const UPLOADABLE = /\.(map|js|jsbundle|bundle|hbc)$/i;
8
+ /** Resolve `paths` (files or dirs) to a deduped list of files to upload.
9
+ * A directory contributes its top-level `.map` / `.js` / `.bundle` /
10
+ * `.hbc` files; a file given explicitly is taken as-is (even if its
11
+ * extension isn't in the list — the caller asked for it). */
12
+ export async function collectFiles(paths) {
13
+ const out = [];
14
+ for (const p of paths) {
15
+ const s = await stat(p).catch(() => null);
16
+ if (!s)
17
+ throw new Error(`no such file or directory: ${p}`);
18
+ if (s.isDirectory()) {
19
+ for (const entry of await readdir(p)) {
20
+ const full = join(p, entry);
21
+ const es = await stat(full).catch(() => null);
22
+ if (es?.isFile() && UPLOADABLE.test(entry))
23
+ out.push(full);
24
+ }
25
+ }
26
+ else {
27
+ out.push(p);
28
+ }
29
+ }
30
+ const deduped = [...new Set(out)];
31
+ if (deduped.length === 0) {
32
+ throw new Error('no .map / .js / .bundle files found in the given path(s)');
33
+ }
34
+ return deduped;
35
+ }
36
+ export async function uploadSourcemaps(opts) {
37
+ const files = await collectFiles(opts.paths);
38
+ if (opts.dryRun)
39
+ return { files };
40
+ const form = new FormData();
41
+ for (const f of files) {
42
+ const buf = await readFile(f);
43
+ form.append('file', new Blob([buf]), basename(f));
44
+ }
45
+ const base = opts.apiUrl.replace(/\/+$/, '');
46
+ const url = `${base}/admin/api/releases/${encodeURIComponent(opts.release)}/sourcemaps`;
47
+ const resp = await fetch(url, {
48
+ body: form,
49
+ headers: { Authorization: `Bearer ${opts.token}` },
50
+ method: 'POST',
51
+ });
52
+ if (!resp.ok) {
53
+ let detail = '';
54
+ try {
55
+ detail = await resp.text();
56
+ }
57
+ catch {
58
+ // ignore
59
+ }
60
+ throw new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`);
61
+ }
62
+ const body = (await resp.json().catch(() => ({})));
63
+ return { artifacts: body.artifacts, files, uploaded: body.uploaded ?? files.length };
64
+ }
65
+ //# sourceMappingURL=upload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.js","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-cli",
3
- "version": "0.2.0",
4
- "description": "Sentori CLI upload source maps + other release artifacts to a Sentori server. Thin wrapper that downloads the matching prebuilt Rust binary at install time.",
3
+ "version": "0.2.1",
4
+ "description": "Sentori CLI \u2014 upload source maps so dashboard stack traces resolve to your original source.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://sentori.golia.jp",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/goliajp/sentori.git",
10
- "directory": "cli/npm"
10
+ "directory": "sdk/cli"
11
11
  },
12
12
  "bugs": {
13
13
  "url": "https://github.com/goliajp/sentori/issues"
@@ -15,25 +15,34 @@
15
15
  "keywords": [
16
16
  "sentori",
17
17
  "cli",
18
- "sourcemap",
18
+ "sourcemaps",
19
19
  "error-tracking"
20
20
  ],
21
+ "type": "module",
21
22
  "bin": {
22
- "sentori-cli": "bin/sentori-cli.js"
23
- },
24
- "scripts": {
25
- "postinstall": "node scripts/postinstall.js || echo 'sentori-cli postinstall failed — run `npx @goliapkg/sentori-cli` once you have network'"
23
+ "sentori-cli": "./lib/index.js"
26
24
  },
25
+ "main": "./lib/index.js",
26
+ "types": "./lib/index.d.ts",
27
27
  "files": [
28
- "bin/",
29
- "scripts/",
28
+ "lib/",
29
+ "src/",
30
30
  "README.md"
31
31
  ],
32
32
  "engines": {
33
33
  "node": ">=18"
34
34
  },
35
- "os": ["darwin", "linux"],
36
- "cpu": ["x64", "arm64"],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "bun test",
39
+ "prepack": "bun run build"
40
+ },
41
+ "devDependencies": {
42
+ "@types/bun": "latest",
43
+ "@types/node": "*",
44
+ "typescript": "^5"
45
+ },
37
46
  "publishConfig": {
38
47
  "access": "public"
39
48
  }
@@ -0,0 +1,121 @@
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
+ })
package/src/index.ts ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+
4
+ import { uploadSourcemaps } from './upload.js'
5
+
6
+ const HELP = `sentori-cli — upload release artifacts to Sentori
7
+
8
+ Usage:
9
+ sentori-cli upload sourcemap [options] <path...>
10
+
11
+ <path> one or more files or directories. A directory is scanned
12
+ (one level) for *.map and *.js / *.bundle / *.hbc files.
13
+ A file given explicitly is uploaded as-is.
14
+
15
+ Options:
16
+ --release <r> release identifier — MUST equal the value the SDK
17
+ reports via init({ release }). Required. A mismatch
18
+ means the dashboard silently can't symbolicate.
19
+ --token <t> Sentori token (or set $SENTORI_TOKEN).
20
+ --api-url <url> Sentori API base (default https://api.sentori.golia.jp,
21
+ or $SENTORI_API_URL). For a self-hosted instance, your
22
+ host. (Accepts --ingest-url as an alias.)
23
+ --dry-run list what would be uploaded; don't upload.
24
+ -h, --help show this help.
25
+
26
+ React Native / Hermes: in a release build the *minified* map (Metro)
27
+ and the *bytecode* map (Hermes) must be composed first —
28
+ npx react-native bundle --platform ios --dev false \\
29
+ --entry-file index.js --bundle-output main.jsbundle \\
30
+ --sourcemap-output main.jsbundle.packager.map
31
+ node node_modules/react-native/scripts/compose-source-maps.js \\
32
+ main.jsbundle.packager.map main.jsbundle.hbc.map -o main.jsbundle.map
33
+ then \`sentori-cli upload sourcemap --release "<app>@<version>+<build>" main.jsbundle.map main.jsbundle\`.
34
+ (Web bundlers emit a usable .map directly — just point at the build dir.)
35
+ `
36
+
37
+ async function main(argv: string[]): Promise<number> {
38
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
39
+ console.log(HELP)
40
+ return 0
41
+ }
42
+ if (argv[0] !== 'upload' || argv[1] !== 'sourcemap') {
43
+ console.error(`unknown command: ${argv.slice(0, 2).join(' ') || '(none)'}\n`)
44
+ console.error(HELP)
45
+ return 2
46
+ }
47
+
48
+ let parsed
49
+ try {
50
+ parsed = parseArgs({
51
+ allowPositionals: true,
52
+ args: argv.slice(2),
53
+ options: {
54
+ 'api-url': { type: 'string' },
55
+ 'dry-run': { type: 'boolean' },
56
+ help: { short: 'h', type: 'boolean' },
57
+ 'ingest-url': { type: 'string' }, // alias, kept for older docs
58
+ release: { type: 'string' },
59
+ token: { type: 'string' },
60
+ },
61
+ })
62
+ } catch (e) {
63
+ console.error(`error: ${(e as Error).message}\n`)
64
+ console.error(HELP)
65
+ return 2
66
+ }
67
+ const { positionals, values } = parsed
68
+
69
+ if (values.help) {
70
+ console.log(HELP)
71
+ return 0
72
+ }
73
+ const release = values.release
74
+ if (!release) {
75
+ console.error('error: --release is required (must match the SDK’s init({ release }))')
76
+ return 2
77
+ }
78
+ const dryRun = !!values['dry-run']
79
+ const token = values.token ?? process.env.SENTORI_TOKEN
80
+ if (!token && !dryRun) {
81
+ console.error('error: --token (or $SENTORI_TOKEN) is required')
82
+ return 2
83
+ }
84
+ const apiUrl =
85
+ values['api-url'] ??
86
+ values['ingest-url'] ??
87
+ process.env.SENTORI_API_URL ??
88
+ 'https://api.sentori.golia.jp'
89
+ if (positionals.length === 0) {
90
+ console.error('error: at least one path (file or directory) is required')
91
+ return 2
92
+ }
93
+
94
+ try {
95
+ const result = await uploadSourcemaps({
96
+ apiUrl,
97
+ dryRun,
98
+ paths: positionals,
99
+ release,
100
+ token: token ?? '',
101
+ })
102
+ if (dryRun) {
103
+ console.log(
104
+ `would upload ${result.files.length} file(s) to ${apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(release)}/sourcemaps:`,
105
+ )
106
+ for (const f of result.files) console.log(` ${f}`)
107
+ } else {
108
+ console.log(`uploaded ${result.uploaded} file(s) for release "${release}"`)
109
+ for (const a of result.artifacts ?? []) console.log(` ${a.name} [${a.kind}]`)
110
+ console.log('done — minified stack traces on this release will now resolve to source.')
111
+ }
112
+ return 0
113
+ } catch (e) {
114
+ console.error(`upload failed: ${(e as Error).message}`)
115
+ return 1
116
+ }
117
+ }
118
+
119
+ main(process.argv.slice(2)).then(
120
+ (code) => process.exit(code),
121
+ (e: unknown) => {
122
+ console.error(`fatal: ${(e as Error).message}`)
123
+ process.exit(1)
124
+ },
125
+ )
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://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
+ }
package/README.md DELETED
@@ -1,51 +0,0 @@
1
- # @goliapkg/sentori-cli
2
-
3
- Thin npm wrapper around the [Sentori](https://sentori.golia.jp) Rust CLI. The package downloads the prebuilt binary for your platform on `npm install` (postinstall hook) so `npx @goliapkg/sentori-cli` works the moment you have it in `package.json`.
4
-
5
- ## Install
6
-
7
- ```sh
8
- npm install -D @goliapkg/sentori-cli
9
- # or pnpm / yarn
10
-
11
- npx @goliapkg/sentori-cli upload sourcemap \
12
- --release myapp@1.2.3+456 \
13
- --token st_pk_... \
14
- --ingest-url https://ingest.sentori.your-host.com \
15
- dist/bundle.js.map
16
- ```
17
-
18
- ### Using bun
19
-
20
- Bun blocks postinstall scripts by default. After `bun add`, run:
21
-
22
- ```sh
23
- bun pm trust @goliapkg/sentori-cli
24
- ```
25
-
26
- once to allow the binary download, or use `bun add --trust @goliapkg/sentori-cli` from the start.
27
-
28
- Supported platforms:
29
-
30
- | OS | Arch |
31
- |----|------|
32
- | Linux | x64, arm64 |
33
- | macOS | x64 (Intel), arm64 (Apple Silicon) |
34
-
35
- If your platform isn't covered, the postinstall hook will print a soft warning and you can fall back to:
36
-
37
- ```sh
38
- cargo install --git https://github.com/goliajp/sentori --bin sentori-cli
39
- ```
40
-
41
- ## Skipping postinstall
42
-
43
- `SENTORI_SKIP_DOWNLOAD=1` skips the binary download — useful in monorepo bootstrap or sandbox CI environments where the binary will be vendored separately.
44
-
45
- ## Verifying the binary
46
-
47
- Each release artifact ships with a `.sha256` sidecar published next to the `.tar.gz` on the GitHub Release page; the postinstall script downloads only the tarball but you can manually verify:
48
-
49
- ```sh
50
- curl -L https://github.com/goliajp/sentori/releases/download/cli-v<VERSION>/sentori-cli-cli-v<VERSION>-darwin-arm64.tar.gz.sha256
51
- ```
@@ -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
- })