@goliapkg/sentori-cli 0.2.0 → 0.3.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.
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,193 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { reactNativeUpload } from './react-native.js';
4
+ import { uploadSourcemaps } from './upload.js';
5
+ const HELP = `sentori-cli — upload release artifacts to Sentori
6
+
7
+ Usage:
8
+ sentori-cli upload sourcemap [options] <path...>
9
+ Upload one or more files or directories. A directory is scanned
10
+ (one level) for *.map / *.js / *.jsbundle / *.bundle / *.hbc;
11
+ a file given explicitly is uploaded as-is. Use this for web
12
+ bundlers (point at the build dir) or for already-composed RN maps.
13
+
14
+ sentori-cli react-native upload [options]
15
+ Compose a Metro packager map + a Hermes map into one source map
16
+ and upload it (plus the bundle). Use this for a Hermes release
17
+ build. Requires --metro-map and --hermes-map.
18
+
19
+ Options (both commands):
20
+ --release <r> release identifier — MUST equal the value the SDK
21
+ reports via init({ release }). Required. A mismatch
22
+ means the dashboard silently can't symbolicate.
23
+ --token <t> Sentori token (or set $SENTORI_TOKEN).
24
+ --api-url <url> Sentori API base (default https://api.sentori.golia.jp,
25
+ or $SENTORI_API_URL). For a self-hosted instance,
26
+ your host. (Accepts --ingest-url as an alias.)
27
+ --dry-run describe what would be uploaded; don't upload.
28
+ -h, --help show this help.
29
+
30
+ Options (react-native upload):
31
+ --metro-map <p> the *.packager.map Metro emits (--sourcemap-output).
32
+ --hermes-map <p> the *.hbc.map the Hermes compiler emits.
33
+ --bundle <p> optional: also upload the bundle (.jsbundle / .bundle).
34
+
35
+ Hermes release build, by hand:
36
+ npx react-native bundle --platform ios --dev false --entry-file index.js \\
37
+ --bundle-output main.jsbundle --sourcemap-output main.jsbundle.packager.map
38
+ # (the iOS/Android build compiles to Hermes and writes main.jsbundle.hbc.map)
39
+ npx @goliapkg/sentori-cli react-native upload \\
40
+ --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
41
+ --metro-map main.jsbundle.packager.map --hermes-map main.jsbundle.hbc.map \\
42
+ --bundle main.jsbundle
43
+ `;
44
+ /** Parse the shared options, or print an error + return null. */
45
+ function parseCommon(values) {
46
+ const release = typeof values.release === 'string' ? values.release : undefined;
47
+ if (!release) {
48
+ console.error('error: --release is required (must match the SDK’s init({ release }))');
49
+ return null;
50
+ }
51
+ const dryRun = values['dry-run'] === true;
52
+ const token = (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN;
53
+ if (!token && !dryRun) {
54
+ console.error('error: --token (or $SENTORI_TOKEN) is required');
55
+ return null;
56
+ }
57
+ const apiUrl = (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
58
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
59
+ process.env.SENTORI_API_URL ??
60
+ 'https://api.sentori.golia.jp';
61
+ return { apiUrl, dryRun, release, token: token ?? '' };
62
+ }
63
+ async function cmdUploadSourcemap(argv) {
64
+ let parsed;
65
+ try {
66
+ parsed = parseArgs({
67
+ allowPositionals: true,
68
+ args: argv,
69
+ options: {
70
+ 'api-url': { type: 'string' },
71
+ 'dry-run': { type: 'boolean' },
72
+ help: { short: 'h', type: 'boolean' },
73
+ 'ingest-url': { type: 'string' },
74
+ release: { type: 'string' },
75
+ token: { type: 'string' },
76
+ },
77
+ });
78
+ }
79
+ catch (e) {
80
+ console.error(`error: ${e.message}\n`);
81
+ console.error(HELP);
82
+ return 2;
83
+ }
84
+ if (parsed.values.help) {
85
+ console.log(HELP);
86
+ return 0;
87
+ }
88
+ const c = parseCommon(parsed.values);
89
+ if (!c)
90
+ return 2;
91
+ if (parsed.positionals.length === 0) {
92
+ console.error('error: at least one path (file or directory) is required');
93
+ return 2;
94
+ }
95
+ try {
96
+ const result = await uploadSourcemaps({
97
+ apiUrl: c.apiUrl,
98
+ dryRun: c.dryRun,
99
+ paths: parsed.positionals,
100
+ release: c.release,
101
+ token: c.token,
102
+ });
103
+ reportUpload(result, c);
104
+ return 0;
105
+ }
106
+ catch (e) {
107
+ console.error(`upload failed: ${e.message}`);
108
+ return 1;
109
+ }
110
+ }
111
+ async function cmdReactNativeUpload(argv) {
112
+ let parsed;
113
+ try {
114
+ parsed = parseArgs({
115
+ args: argv,
116
+ options: {
117
+ 'api-url': { type: 'string' },
118
+ bundle: { type: 'string' },
119
+ 'dry-run': { type: 'boolean' },
120
+ help: { short: 'h', type: 'boolean' },
121
+ 'hermes-map': { type: 'string' },
122
+ 'ingest-url': { type: 'string' },
123
+ 'metro-map': { type: 'string' },
124
+ release: { type: 'string' },
125
+ token: { type: 'string' },
126
+ },
127
+ });
128
+ }
129
+ catch (e) {
130
+ console.error(`error: ${e.message}\n`);
131
+ console.error(HELP);
132
+ return 2;
133
+ }
134
+ if (parsed.values.help) {
135
+ console.log(HELP);
136
+ return 0;
137
+ }
138
+ const c = parseCommon(parsed.values);
139
+ if (!c)
140
+ return 2;
141
+ const metroMap = parsed.values['metro-map'];
142
+ const hermesMap = parsed.values['hermes-map'];
143
+ if (typeof metroMap !== 'string' || typeof hermesMap !== 'string') {
144
+ console.error('error: --metro-map and --hermes-map are both required');
145
+ return 2;
146
+ }
147
+ try {
148
+ const result = await reactNativeUpload({
149
+ apiUrl: c.apiUrl,
150
+ bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
151
+ dryRun: c.dryRun,
152
+ hermesMap,
153
+ metroMap,
154
+ release: c.release,
155
+ token: c.token,
156
+ });
157
+ reportUpload(result, c);
158
+ return 0;
159
+ }
160
+ catch (e) {
161
+ console.error(`react-native upload failed: ${e.message}`);
162
+ return 1;
163
+ }
164
+ }
165
+ function reportUpload(result, c) {
166
+ if (c.dryRun) {
167
+ console.log(`would upload ${result.files.length} file(s) to ${c.apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(c.release)}/sourcemaps:`);
168
+ for (const f of result.files)
169
+ console.log(` ${f}`);
170
+ }
171
+ else {
172
+ console.log(`uploaded ${result.uploaded ?? result.files.length} file(s) for release "${c.release}" — minified stacks on this release will now resolve to source.`);
173
+ }
174
+ }
175
+ async function main(argv) {
176
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
177
+ console.log(HELP);
178
+ return 0;
179
+ }
180
+ const [a, b, ...rest] = argv;
181
+ if (a === 'upload' && b === 'sourcemap')
182
+ return cmdUploadSourcemap(rest);
183
+ if (a === 'react-native' && b === 'upload')
184
+ return cmdReactNativeUpload(rest);
185
+ console.error(`unknown command: ${[a, b].filter(Boolean).join(' ') || '(none)'}\n`);
186
+ console.error(HELP);
187
+ return 2;
188
+ }
189
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
190
+ console.error(`fatal: ${e.message}`);
191
+ process.exit(1);
192
+ });
193
+ //# 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,25 @@
1
+ /** Resolve `react-native/scripts/compose-source-maps.js` from the
2
+ * current project's node_modules. Returns null if react-native isn't
3
+ * installed or the version doesn't ship that script. */
4
+ export declare function resolveComposeScript(fromDir?: string): null | string;
5
+ /** Compose a Metro packager source map + a Hermes source map into a
6
+ * single map (a temp file the caller is responsible for deleting).
7
+ * Throws if react-native's compose script can't be found or fails. */
8
+ export declare function composeSourceMaps(metroMap: string, hermesMap: string): string;
9
+ export type RnUploadOptions = {
10
+ apiUrl: string;
11
+ /** Optional bundle file (.jsbundle / .bundle) to upload alongside the map. */
12
+ bundle?: string;
13
+ dryRun?: boolean;
14
+ hermesMap: string;
15
+ metroMap: string;
16
+ release: string;
17
+ token: string;
18
+ };
19
+ /** Compose the Metro + Hermes maps, then upload the result (and the
20
+ * bundle, if given). Cleans up the temp composed map. */
21
+ export declare function reactNativeUpload(opts: RnUploadOptions): Promise<{
22
+ files: string[];
23
+ uploaded?: number;
24
+ }>;
25
+ //# sourceMappingURL=react-native.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-native.d.ts","sourceRoot":"","sources":["../src/react-native.ts"],"names":[],"mappings":"AAQA;;yDAEyD;AACzD,wBAAgB,oBAAoB,CAAC,OAAO,SAAgB,GAAG,IAAI,GAAG,MAAM,CAc3E;AAED;;uEAEuE;AACvE,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAkB7E;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED;0DAC0D;AAC1D,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC;IACtE,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,CAAC,CAoBD"}
@@ -0,0 +1,74 @@
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
+ import { uploadSourcemaps } from './upload.js';
7
+ /** Resolve `react-native/scripts/compose-source-maps.js` from the
8
+ * current project's node_modules. Returns null if react-native isn't
9
+ * installed or the version doesn't ship that script. */
10
+ export function resolveComposeScript(fromDir = process.cwd()) {
11
+ const req = createRequire(join(fromDir, 'noop.js'));
12
+ for (const id of [
13
+ 'react-native/scripts/compose-source-maps.js',
14
+ '@react-native/community-cli-plugin/dist/utils/composeSourceMaps.js',
15
+ ]) {
16
+ try {
17
+ const p = req.resolve(id);
18
+ if (existsSync(p))
19
+ return p;
20
+ }
21
+ catch {
22
+ // try next
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ /** Compose a Metro packager source map + a Hermes source map into a
28
+ * single map (a temp file the caller is responsible for deleting).
29
+ * Throws if react-native's compose script can't be found or fails. */
30
+ export function composeSourceMaps(metroMap, hermesMap) {
31
+ for (const p of [metroMap, hermesMap]) {
32
+ if (!existsSync(p))
33
+ throw new Error(`no such file: ${p}`);
34
+ }
35
+ const script = resolveComposeScript();
36
+ if (!script) {
37
+ throw new Error("couldn't find react-native's compose-source-maps.js — install react-native, or " +
38
+ 'compose the maps yourself and use `sentori-cli upload sourcemap <composed.map>`');
39
+ }
40
+ const out = join(mkdtempSync(join(tmpdir(), 'sentori-rn-')), 'composed.map');
41
+ const r = spawnSync('node', [script, metroMap, hermesMap, '-o', out], { stdio: 'inherit' });
42
+ if (r.status !== 0) {
43
+ throw new Error(`compose-source-maps.js exited with ${r.status ?? 'signal'}`);
44
+ }
45
+ if (!existsSync(out))
46
+ throw new Error('compose-source-maps.js produced no output');
47
+ return out;
48
+ }
49
+ /** Compose the Metro + Hermes maps, then upload the result (and the
50
+ * bundle, if given). Cleans up the temp composed map. */
51
+ export async function reactNativeUpload(opts) {
52
+ const composed = composeSourceMaps(opts.metroMap, opts.hermesMap);
53
+ try {
54
+ const paths = [composed, ...(opts.bundle ? [opts.bundle] : [])];
55
+ const r = await uploadSourcemaps({
56
+ apiUrl: opts.apiUrl,
57
+ dryRun: opts.dryRun,
58
+ paths,
59
+ release: opts.release,
60
+ token: opts.token,
61
+ });
62
+ return { files: r.files, uploaded: r.uploaded };
63
+ }
64
+ finally {
65
+ try {
66
+ rmSync(composed, { force: true });
67
+ rmSync(join(composed, '..'), { force: true, recursive: true });
68
+ }
69
+ catch {
70
+ // best-effort cleanup
71
+ }
72
+ }
73
+ }
74
+ //# sourceMappingURL=react-native.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-native.js","sourceRoot":"","sources":["../src/react-native.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.3.0",
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,37 @@
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 { composeSourceMaps, resolveComposeScript } from '../react-native.js'
8
+
9
+ let dir = ''
10
+ beforeEach(async () => {
11
+ dir = await mkdtemp(join(tmpdir(), 'sentori-cli-rn-'))
12
+ })
13
+ afterEach(async () => {
14
+ await rm(dir, { force: true, recursive: true })
15
+ })
16
+
17
+ describe('react-native helpers', () => {
18
+ test('resolveComposeScript returns null when react-native is not installed', () => {
19
+ // This package doesn't depend on react-native, so from sdk/cli's
20
+ // own node_modules there's nothing to find.
21
+ expect(resolveComposeScript()).toBeNull()
22
+ })
23
+
24
+ test('composeSourceMaps throws "no such file" for a missing input', () => {
25
+ expect(() => composeSourceMaps(join(dir, 'nope.map'), join(dir, 'nope2.map'))).toThrow(
26
+ 'no such file',
27
+ )
28
+ })
29
+
30
+ test('composeSourceMaps throws a helpful message when react-native is absent', async () => {
31
+ await writeFile(join(dir, 'a.packager.map'), '{"version":3}')
32
+ await writeFile(join(dir, 'a.hbc.map'), '{"version":3}')
33
+ expect(() =>
34
+ composeSourceMaps(join(dir, 'a.packager.map'), join(dir, 'a.hbc.map')),
35
+ ).toThrow(/compose-source-maps\.js/)
36
+ })
37
+ })
@@ -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,204 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+
4
+ import { reactNativeUpload } from './react-native.js'
5
+ import { uploadSourcemaps } from './upload.js'
6
+
7
+ const HELP = `sentori-cli — upload release artifacts to Sentori
8
+
9
+ Usage:
10
+ sentori-cli upload sourcemap [options] <path...>
11
+ Upload one or more files or directories. A directory is scanned
12
+ (one level) for *.map / *.js / *.jsbundle / *.bundle / *.hbc;
13
+ a file given explicitly is uploaded as-is. Use this for web
14
+ bundlers (point at the build dir) or for already-composed RN maps.
15
+
16
+ sentori-cli react-native upload [options]
17
+ Compose a Metro packager map + a Hermes map into one source map
18
+ and upload it (plus the bundle). Use this for a Hermes release
19
+ build. Requires --metro-map and --hermes-map.
20
+
21
+ Options (both commands):
22
+ --release <r> release identifier — MUST equal the value the SDK
23
+ reports via init({ release }). Required. A mismatch
24
+ means the dashboard silently can't symbolicate.
25
+ --token <t> Sentori token (or set $SENTORI_TOKEN).
26
+ --api-url <url> Sentori API base (default https://api.sentori.golia.jp,
27
+ or $SENTORI_API_URL). For a self-hosted instance,
28
+ your host. (Accepts --ingest-url as an alias.)
29
+ --dry-run describe what would be uploaded; don't upload.
30
+ -h, --help show this help.
31
+
32
+ Options (react-native upload):
33
+ --metro-map <p> the *.packager.map Metro emits (--sourcemap-output).
34
+ --hermes-map <p> the *.hbc.map the Hermes compiler emits.
35
+ --bundle <p> optional: also upload the bundle (.jsbundle / .bundle).
36
+
37
+ Hermes release build, by hand:
38
+ npx react-native bundle --platform ios --dev false --entry-file index.js \\
39
+ --bundle-output main.jsbundle --sourcemap-output main.jsbundle.packager.map
40
+ # (the iOS/Android build compiles to Hermes and writes main.jsbundle.hbc.map)
41
+ npx @goliapkg/sentori-cli react-native upload \\
42
+ --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
43
+ --metro-map main.jsbundle.packager.map --hermes-map main.jsbundle.hbc.map \\
44
+ --bundle main.jsbundle
45
+ `
46
+
47
+ type Common = { apiUrl: string; dryRun: boolean; release: string; token: string }
48
+
49
+ /** Parse the shared options, or print an error + return null. */
50
+ function parseCommon(values: Record<string, unknown>): Common | null {
51
+ const release = typeof values.release === 'string' ? values.release : undefined
52
+ if (!release) {
53
+ console.error('error: --release is required (must match the SDK’s init({ release }))')
54
+ return null
55
+ }
56
+ const dryRun = values['dry-run'] === true
57
+ const token =
58
+ (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
59
+ if (!token && !dryRun) {
60
+ console.error('error: --token (or $SENTORI_TOKEN) is required')
61
+ return null
62
+ }
63
+ const apiUrl =
64
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
65
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
66
+ process.env.SENTORI_API_URL ??
67
+ 'https://api.sentori.golia.jp'
68
+ return { apiUrl, dryRun, release, token: token ?? '' }
69
+ }
70
+
71
+ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
72
+ let parsed
73
+ try {
74
+ parsed = parseArgs({
75
+ allowPositionals: true,
76
+ args: argv,
77
+ options: {
78
+ 'api-url': { type: 'string' },
79
+ 'dry-run': { type: 'boolean' },
80
+ help: { short: 'h', type: 'boolean' },
81
+ 'ingest-url': { type: 'string' },
82
+ release: { type: 'string' },
83
+ token: { type: 'string' },
84
+ },
85
+ })
86
+ } catch (e) {
87
+ console.error(`error: ${(e as Error).message}\n`)
88
+ console.error(HELP)
89
+ return 2
90
+ }
91
+ if (parsed.values.help) {
92
+ console.log(HELP)
93
+ return 0
94
+ }
95
+ const c = parseCommon(parsed.values)
96
+ if (!c) return 2
97
+ if (parsed.positionals.length === 0) {
98
+ console.error('error: at least one path (file or directory) is required')
99
+ return 2
100
+ }
101
+ try {
102
+ const result = await uploadSourcemaps({
103
+ apiUrl: c.apiUrl,
104
+ dryRun: c.dryRun,
105
+ paths: parsed.positionals,
106
+ release: c.release,
107
+ token: c.token,
108
+ })
109
+ reportUpload(result, c)
110
+ return 0
111
+ } catch (e) {
112
+ console.error(`upload failed: ${(e as Error).message}`)
113
+ return 1
114
+ }
115
+ }
116
+
117
+ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
118
+ let parsed
119
+ try {
120
+ parsed = parseArgs({
121
+ args: argv,
122
+ options: {
123
+ 'api-url': { type: 'string' },
124
+ bundle: { type: 'string' },
125
+ 'dry-run': { type: 'boolean' },
126
+ help: { short: 'h', type: 'boolean' },
127
+ 'hermes-map': { type: 'string' },
128
+ 'ingest-url': { type: 'string' },
129
+ 'metro-map': { type: 'string' },
130
+ release: { type: 'string' },
131
+ token: { type: 'string' },
132
+ },
133
+ })
134
+ } catch (e) {
135
+ console.error(`error: ${(e as Error).message}\n`)
136
+ console.error(HELP)
137
+ return 2
138
+ }
139
+ if (parsed.values.help) {
140
+ console.log(HELP)
141
+ return 0
142
+ }
143
+ const c = parseCommon(parsed.values)
144
+ if (!c) return 2
145
+ const metroMap = parsed.values['metro-map']
146
+ const hermesMap = parsed.values['hermes-map']
147
+ if (typeof metroMap !== 'string' || typeof hermesMap !== 'string') {
148
+ console.error('error: --metro-map and --hermes-map are both required')
149
+ return 2
150
+ }
151
+ try {
152
+ const result = await reactNativeUpload({
153
+ apiUrl: c.apiUrl,
154
+ bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
155
+ dryRun: c.dryRun,
156
+ hermesMap,
157
+ metroMap,
158
+ release: c.release,
159
+ token: c.token,
160
+ })
161
+ reportUpload(result, c)
162
+ return 0
163
+ } catch (e) {
164
+ console.error(`react-native upload failed: ${(e as Error).message}`)
165
+ return 1
166
+ }
167
+ }
168
+
169
+ function reportUpload(
170
+ result: { files: string[]; uploaded?: number },
171
+ c: Common,
172
+ ): void {
173
+ if (c.dryRun) {
174
+ console.log(
175
+ `would upload ${result.files.length} file(s) to ${c.apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(c.release)}/sourcemaps:`,
176
+ )
177
+ for (const f of result.files) console.log(` ${f}`)
178
+ } else {
179
+ console.log(
180
+ `uploaded ${result.uploaded ?? result.files.length} file(s) for release "${c.release}" — minified stacks on this release will now resolve to source.`,
181
+ )
182
+ }
183
+ }
184
+
185
+ async function main(argv: string[]): Promise<number> {
186
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
187
+ console.log(HELP)
188
+ return 0
189
+ }
190
+ const [a, b, ...rest] = argv
191
+ if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
192
+ if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
193
+ console.error(`unknown command: ${[a, b].filter(Boolean).join(' ') || '(none)'}\n`)
194
+ console.error(HELP)
195
+ return 2
196
+ }
197
+
198
+ main(process.argv.slice(2)).then(
199
+ (code) => process.exit(code),
200
+ (e: unknown) => {
201
+ console.error(`fatal: ${(e as Error).message}`)
202
+ process.exit(1)
203
+ },
204
+ )
@@ -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
+ }
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
- })