@rootnode-app/cli 0.0.0-stage → 0.1.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/README.md +42 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +116 -0
- package/dist/discover.d.ts +19 -0
- package/dist/discover.js +94 -0
- package/dist/plan.d.ts +47 -0
- package/dist/plan.js +117 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rootnode-app/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Upload the files that turn release crashes into real code lines —
|
|
4
|
+
iOS dSYMs, Android ProGuard/R8 mappings, native debug files and JS source maps —
|
|
5
|
+
to [Rootnode](https://rootnode.app). Authenticates with the app's analytics
|
|
6
|
+
key; nothing else to configure.
|
|
7
|
+
|
|
8
|
+
## Expo / EAS (automatic)
|
|
9
|
+
|
|
10
|
+
```jsonc
|
|
11
|
+
// app.json → "expo"
|
|
12
|
+
"plugins": ["@rootnode-app/react-native"]
|
|
13
|
+
|
|
14
|
+
// package.json → "scripts"
|
|
15
|
+
"eas-build-on-success": "npx @rootnode-app/cli eas"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
After every successful EAS build it finds that build's dSYMs, mapping, bundle
|
|
19
|
+
and source map and uploads them under the release the SDK reports
|
|
20
|
+
(`bundleId@version+build`). Uses `EXPO_PUBLIC_ROOTNODE_KEY` (or `ROOTNODE_KEY`).
|
|
21
|
+
Uploads go to the app's target for that build's platform (`ios` / `android`)
|
|
22
|
+
and to its `react-native` target, whichever exist. Never fails the build
|
|
23
|
+
unless you pass `--strict`.
|
|
24
|
+
|
|
25
|
+
## Any build
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export ROOTNODE_KEY=rn_…
|
|
29
|
+
npx @rootnode-app/cli upload-symbols --target ios ./build/App.xcarchive
|
|
30
|
+
npx @rootnode-app/cli upload-symbols --target android app/build/outputs/mapping/release/mapping.txt
|
|
31
|
+
npx @rootnode-app/cli upload-sourcemaps --target react-native \
|
|
32
|
+
--bundle index.android.bundle --map index.android.bundle.map \
|
|
33
|
+
--release com.acme.shop@2.1.0+47 --dist 47
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`--target`: which target of the app the crashes come from (`react-native`
|
|
37
|
+
default, `ios`, `android`, …). `--host` for self-hosted Rootnode.
|
|
38
|
+
|
|
39
|
+
Symbols apply to crashes received **after** the upload — upload during the
|
|
40
|
+
build, not after the first crash. Check the result in the panel: Setup →
|
|
41
|
+
target → Symbols.
|
|
42
|
+
|
|
43
|
+
MIT © Doğan Aktı. Bundles the BSD-3-licensed upload engine at a pinned version.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const discover_1 = require("./discover");
|
|
6
|
+
const plan_1 = require("./plan");
|
|
7
|
+
async function lookupTarget(o, name) {
|
|
8
|
+
const res = await fetch(`${o.host}/api/0/rootnode/targets/${encodeURIComponent(name)}`, {
|
|
9
|
+
headers: { Authorization: `Bearer ${o.key}` },
|
|
10
|
+
});
|
|
11
|
+
const body = (await res.json().catch(() => ({})));
|
|
12
|
+
if (res.status === 401)
|
|
13
|
+
throw new Error('the key was rejected — use the app\'s analytics key (rn_…)');
|
|
14
|
+
if (res.status === 404)
|
|
15
|
+
return null;
|
|
16
|
+
if (!res.ok || !body.organization || !body.project)
|
|
17
|
+
throw new Error(body.detail ?? `target lookup failed (http ${res.status})`);
|
|
18
|
+
return { organization: body.organization, project: body.project };
|
|
19
|
+
}
|
|
20
|
+
async function resolveTarget(o) {
|
|
21
|
+
const t = await lookupTarget(o, o.target);
|
|
22
|
+
if (!t)
|
|
23
|
+
throw new Error(`the app has no "${o.target}" target — pass --target with one of its targets (see Rootnode → Setup)`);
|
|
24
|
+
return t;
|
|
25
|
+
}
|
|
26
|
+
function uploaderPath() {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
|
+
return require('@sentry/cli').getPath();
|
|
29
|
+
}
|
|
30
|
+
/** The uploader talks about itself; our users only ever deal with Rootnode. */
|
|
31
|
+
const rebrand = (text) => text.replace(/\bsentry-cli\b/gi, 'rootnode').replace(/\bSentry\b/g, 'Rootnode');
|
|
32
|
+
function execute(runs, env) {
|
|
33
|
+
let ok = true;
|
|
34
|
+
for (const r of runs) {
|
|
35
|
+
console.log(`rootnode: uploading ${r.label}…`);
|
|
36
|
+
const p = (0, child_process_1.spawnSync)(uploaderPath(), r.args, { stdio: ['inherit', 'pipe', 'pipe'], env: { ...process.env, ...env }, encoding: 'utf8' });
|
|
37
|
+
process.stdout.write(rebrand(p.stdout ?? ''));
|
|
38
|
+
process.stderr.write(rebrand(p.stderr ?? ''));
|
|
39
|
+
if (p.status !== 0) {
|
|
40
|
+
ok = false;
|
|
41
|
+
console.error(`rootnode: ${r.label} failed (exit ${p.status ?? p.signal})`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return ok;
|
|
45
|
+
}
|
|
46
|
+
function runsFor(out) {
|
|
47
|
+
const runs = (0, plan_1.symbolRuns)(out.debugPaths);
|
|
48
|
+
if (out.bundle && out.map && out.release)
|
|
49
|
+
runs.push((0, plan_1.sourcemapRun)(out.bundle, out.map, out.release, out.dist));
|
|
50
|
+
return runs;
|
|
51
|
+
}
|
|
52
|
+
async function main(argv) {
|
|
53
|
+
const cmd = (0, plan_1.parseArgs)(argv, process.env);
|
|
54
|
+
const o = cmd.options;
|
|
55
|
+
if (cmd.name === 'help') {
|
|
56
|
+
console.log(plan_1.HELP);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (!o.key)
|
|
60
|
+
throw new Error('no key — pass --key rn_… or set ROOTNODE_KEY (EXPO_PUBLIC_ROOTNODE_KEY works too)');
|
|
61
|
+
if (cmd.name === 'eas') {
|
|
62
|
+
// EAS runs this hook once per platform; outside EAS both are tried.
|
|
63
|
+
const platform = process.env.EAS_BUILD_PLATFORM;
|
|
64
|
+
const root = process.cwd();
|
|
65
|
+
const outs = [platform !== 'android' && (0, discover_1.discoverIos)(root), platform !== 'ios' && (0, discover_1.discoverAndroid)(root)].filter(Boolean);
|
|
66
|
+
for (const out of outs)
|
|
67
|
+
for (const n of out.notes)
|
|
68
|
+
console.log(`rootnode: ${out.platform}: ${n}`);
|
|
69
|
+
if (!outs.some((out) => runsFor(out).length)) {
|
|
70
|
+
console.log('rootnode: nothing to upload');
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
let ok = true;
|
|
75
|
+
for (const out of outs) {
|
|
76
|
+
const runs = runsFor(out);
|
|
77
|
+
if (!runs.length)
|
|
78
|
+
continue;
|
|
79
|
+
const names = (0, plan_1.easTargetCandidates)(out.platform, o);
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const t = await lookupTarget(o, name);
|
|
83
|
+
if (t)
|
|
84
|
+
found.push({ name, t });
|
|
85
|
+
}
|
|
86
|
+
if (!found.length) {
|
|
87
|
+
console.error(`rootnode: ${out.platform}: the app has none of these targets: ${names.join(', ')} — add one in Rootnode → Setup or pass --target`);
|
|
88
|
+
ok = false;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const { name, t } of found) {
|
|
92
|
+
console.log(`rootnode: ${out.platform} → target ${name}`);
|
|
93
|
+
ok = execute(runs, (0, plan_1.uploaderEnv)(o, t)) && ok;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return ok || !o.strict ? 0 : 1;
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
console.error(`rootnode: ${e.message}`);
|
|
100
|
+
return o.strict ? 1 : 0; // an upload problem must not fail a good build
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const target = await resolveTarget(o);
|
|
104
|
+
if (cmd.name === 'upload-symbols') {
|
|
105
|
+
if (!o.paths.length)
|
|
106
|
+
throw new Error('give the dSYM / .xcarchive / .so folder / mapping.txt paths');
|
|
107
|
+
return execute((0, plan_1.symbolRuns)(o.paths), (0, plan_1.uploaderEnv)(o, target)) ? 0 : 1;
|
|
108
|
+
}
|
|
109
|
+
if (!o.bundle || !o.map || !o.release)
|
|
110
|
+
throw new Error('upload-sourcemaps needs --bundle, --map and --release');
|
|
111
|
+
return execute([(0, plan_1.sourcemapRun)(o.bundle, o.map, o.release, o.dist)], (0, plan_1.uploaderEnv)(o, target)) ? 0 : 1;
|
|
112
|
+
}
|
|
113
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
|
|
114
|
+
console.error(`rootnode: ${e.message}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** What one native build left behind that crash readability needs. */
|
|
2
|
+
export interface BuildOutputs {
|
|
3
|
+
platform: 'ios' | 'android';
|
|
4
|
+
debugPaths: string[];
|
|
5
|
+
bundle: string | null;
|
|
6
|
+
map: string | null;
|
|
7
|
+
release: string | null;
|
|
8
|
+
dist: string | null;
|
|
9
|
+
notes: string[];
|
|
10
|
+
}
|
|
11
|
+
export type PlistReader = (file: string) => Record<string, any> | null;
|
|
12
|
+
/** Info.plist may be binary — plutil (present on every macOS build machine) reads both. */
|
|
13
|
+
export declare const readPlist: PlistReader;
|
|
14
|
+
/** iOS: the .xcarchive EAS/xcodebuild produced under ios/build, plus the map our config plugin writes next to it. */
|
|
15
|
+
/** Newest .xcarchive in Xcode's Archives folder (where fastlane/gym archives by default), if made in the last 3 hours. */
|
|
16
|
+
export declare function newestXcodeArchive(home: string, now?: number): string | null;
|
|
17
|
+
export declare function discoverIos(root: string, plist?: PlistReader, home?: string, override?: string | undefined): BuildOutputs;
|
|
18
|
+
/** Android: R8 mapping, the release bundle + its (Hermes-composed) source map, version from build.gradle. */
|
|
19
|
+
export declare function discoverAndroid(root: string): BuildOutputs;
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readPlist = void 0;
|
|
4
|
+
exports.newestXcodeArchive = newestXcodeArchive;
|
|
5
|
+
exports.discoverIos = discoverIos;
|
|
6
|
+
exports.discoverAndroid = discoverAndroid;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const os_1 = require("os");
|
|
10
|
+
const path_1 = require("path");
|
|
11
|
+
const plan_1 = require("./plan");
|
|
12
|
+
/** Info.plist may be binary — plutil (present on every macOS build machine) reads both. */
|
|
13
|
+
const readPlist = (file) => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse((0, child_process_1.execFileSync)('plutil', ['-convert', 'json', '-o', '-', file], { encoding: 'utf8' }));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
exports.readPlist = readPlist;
|
|
22
|
+
const dirs = (p) => ((0, fs_1.existsSync)(p) ? (0, fs_1.readdirSync)(p).filter((n) => (0, fs_1.statSync)((0, path_1.join)(p, n)).isDirectory()) : []);
|
|
23
|
+
const first = (...paths) => paths.find((p) => (0, fs_1.existsSync)(p)) ?? null;
|
|
24
|
+
/** iOS: the .xcarchive EAS/xcodebuild produced under ios/build, plus the map our config plugin writes next to it. */
|
|
25
|
+
/** Newest .xcarchive in Xcode's Archives folder (where fastlane/gym archives by default), if made in the last 3 hours. */
|
|
26
|
+
function newestXcodeArchive(home, now = Date.now()) {
|
|
27
|
+
const base = (0, path_1.join)(home, 'Library', 'Developer', 'Xcode', 'Archives');
|
|
28
|
+
const all = dirs(base).flatMap((day) => dirs((0, path_1.join)(base, day)).filter((n) => n.endsWith('.xcarchive')).map((n) => (0, path_1.join)(base, day, n)));
|
|
29
|
+
const fresh = all.map((p) => ({ p, t: (0, fs_1.statSync)(p).mtimeMs })).filter((x) => now - x.t < 3 * 3600_000);
|
|
30
|
+
return fresh.sort((a, b) => b.t - a.t)[0]?.p ?? null;
|
|
31
|
+
}
|
|
32
|
+
function discoverIos(root, plist = exports.readPlist, home = (0, os_1.homedir)(), override = process.env.ROOTNODE_IOS_ARCHIVE) {
|
|
33
|
+
const out = { platform: 'ios', debugPaths: [], bundle: null, map: null, release: null, dist: null, notes: [] };
|
|
34
|
+
const buildDir = (0, path_1.join)(root, 'ios', 'build');
|
|
35
|
+
const local = dirs(buildDir).find((n) => n.endsWith('.xcarchive'));
|
|
36
|
+
const arch = override || (local ? (0, path_1.join)(buildDir, local) : newestXcodeArchive(home));
|
|
37
|
+
if (!arch || !(0, fs_1.existsSync)(arch)) {
|
|
38
|
+
out.notes.push('no .xcarchive under ios/build or in Xcode Archives — nothing to upload for iOS (set ROOTNODE_IOS_ARCHIVE to point at one)');
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
const archive = arch.split('/').pop();
|
|
42
|
+
const dsyms = (0, path_1.join)(arch, 'dSYMs');
|
|
43
|
+
if ((0, fs_1.existsSync)(dsyms))
|
|
44
|
+
out.debugPaths.push(dsyms);
|
|
45
|
+
else
|
|
46
|
+
out.notes.push(`${archive} has no dSYMs folder (is DEBUG_INFORMATION_FORMAT dwarf-with-dsym?)`);
|
|
47
|
+
const apps = (0, path_1.join)(arch, 'Products', 'Applications');
|
|
48
|
+
const appName = dirs(apps).find((n) => n.endsWith('.app'));
|
|
49
|
+
if (appName)
|
|
50
|
+
out.bundle = first((0, path_1.join)(apps, appName, 'main.jsbundle'));
|
|
51
|
+
out.map = first((0, path_1.join)(buildDir, 'main.jsbundle.map'));
|
|
52
|
+
if (out.bundle && !out.map)
|
|
53
|
+
out.notes.push('no ios/build/main.jsbundle.map — add the "@rootnode-app/react-native" plugin to app.json so the build writes it');
|
|
54
|
+
// The archive's own Info.plist usually carries ApplicationProperties; the app bundle's always does.
|
|
55
|
+
const complete = (i) => (i?.CFBundleIdentifier && i?.CFBundleShortVersionString && i?.CFBundleVersion ? i : null);
|
|
56
|
+
const info = complete(plist((0, path_1.join)(arch, 'Info.plist'))?.ApplicationProperties) ?? (appName ? complete(plist((0, path_1.join)(apps, appName, 'Info.plist'))) : null);
|
|
57
|
+
if (info) {
|
|
58
|
+
out.release = (0, plan_1.releaseName)(info.CFBundleIdentifier, info.CFBundleShortVersionString, String(info.CFBundleVersion));
|
|
59
|
+
out.dist = String(info.CFBundleVersion);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
out.notes.push('could not read the version from the archive or app Info.plist — dSYMs still upload, the JS source map is skipped');
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Android: R8 mapping, the release bundle + its (Hermes-composed) source map, version from build.gradle. */
|
|
66
|
+
function discoverAndroid(root) {
|
|
67
|
+
const out = { platform: 'android', debugPaths: [], bundle: null, map: null, release: null, dist: null, notes: [] };
|
|
68
|
+
const app = (0, path_1.join)(root, 'android', 'app');
|
|
69
|
+
const b = (0, path_1.join)(app, 'build');
|
|
70
|
+
if (!(0, fs_1.existsSync)(b)) {
|
|
71
|
+
out.notes.push('no android/app/build — nothing to upload for Android');
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const mapping = first((0, path_1.join)(b, 'outputs', 'mapping', 'release', 'mapping.txt'));
|
|
75
|
+
if (mapping)
|
|
76
|
+
out.debugPaths.push(mapping);
|
|
77
|
+
const nativeLibs = first((0, path_1.join)(b, 'intermediates', 'merged_native_libs', 'release'));
|
|
78
|
+
if (nativeLibs)
|
|
79
|
+
out.debugPaths.push(nativeLibs);
|
|
80
|
+
out.bundle = first((0, path_1.join)(b, 'generated', 'assets', 'react', 'release', 'index.android.bundle'), (0, path_1.join)(b, 'generated', 'assets', 'createBundleReleaseJsAndAssets', 'index.android.bundle'));
|
|
81
|
+
out.map = first((0, path_1.join)(b, 'generated', 'sourcemaps', 'react', 'release', 'index.android.bundle.map'), (0, path_1.join)(b, 'generated', 'sourcemaps', 'react', 'release', 'index.android.bundle.packager.map'));
|
|
82
|
+
const gradle = first((0, path_1.join)(app, 'build.gradle'), (0, path_1.join)(app, 'build.gradle.kts'));
|
|
83
|
+
const text = gradle ? (0, fs_1.readFileSync)(gradle, 'utf8') : '';
|
|
84
|
+
const id = text.match(/applicationId\s*=?\s*["']([^"']+)["']/)?.[1];
|
|
85
|
+
const code = text.match(/versionCode\s*=?\s*(\d+)/)?.[1];
|
|
86
|
+
const name = text.match(/versionName\s*=?\s*["']([^"']+)["']/)?.[1];
|
|
87
|
+
if (id && code && name) {
|
|
88
|
+
out.release = (0, plan_1.releaseName)(id, name, code);
|
|
89
|
+
out.dist = code;
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
out.notes.push('could not read applicationId / versionName / versionCode from android/app/build.gradle');
|
|
93
|
+
return out;
|
|
94
|
+
}
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for the Rootnode CLI: which upload runs to make, with which
|
|
3
|
+
* arguments. The uploader underneath is sentry-cli 2.58.2 (BSD-3, pinned —
|
|
4
|
+
* later versions are FSL); nothing here touches the network or the disk.
|
|
5
|
+
*/
|
|
6
|
+
export interface Options {
|
|
7
|
+
key: string | null;
|
|
8
|
+
host: string;
|
|
9
|
+
target: string;
|
|
10
|
+
/** --target or ROOTNODE_TARGET was given — no automatic target choice. */
|
|
11
|
+
targetExplicit: boolean;
|
|
12
|
+
release: string | null;
|
|
13
|
+
dist: string | null;
|
|
14
|
+
bundle: string | null;
|
|
15
|
+
map: string | null;
|
|
16
|
+
strict: boolean;
|
|
17
|
+
paths: string[];
|
|
18
|
+
}
|
|
19
|
+
export type Command = {
|
|
20
|
+
name: 'upload-symbols' | 'upload-sourcemaps' | 'eas' | 'help';
|
|
21
|
+
options: Options;
|
|
22
|
+
};
|
|
23
|
+
export interface UploadTarget {
|
|
24
|
+
organization: string;
|
|
25
|
+
project: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Run {
|
|
28
|
+
label: string;
|
|
29
|
+
args: string[];
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_HOST = "https://rootnode.app";
|
|
32
|
+
export declare function parseArgs(argv: string[], env: Record<string, string | undefined>): Command;
|
|
33
|
+
/** Environment for the uploader: our host, the app's analytics key, the resolved org/project. */
|
|
34
|
+
export declare function uploaderEnv(o: Options, t: UploadTarget): Record<string, string>;
|
|
35
|
+
/** ProGuard/R8 mapping files go through their own upload; everything else is a debug file (dSYM, .so, archive). */
|
|
36
|
+
export declare function symbolRuns(paths: string[]): Run[];
|
|
37
|
+
/** A Hermes/JS bundle with its source map, tied to the release the SDK reports. */
|
|
38
|
+
export declare function sourcemapRun(bundle: string, map: string, release: string, dist: string | null): Run;
|
|
39
|
+
/**
|
|
40
|
+
* Which app targets one EAS build uploads to. A React Native app may report
|
|
41
|
+
* crashes through its per-platform targets (ios / android DSNs) or one
|
|
42
|
+
* react-native target — symbols go to every one of these that exists.
|
|
43
|
+
*/
|
|
44
|
+
export declare function easTargetCandidates(platform: 'ios' | 'android', o: Options): string[];
|
|
45
|
+
/** The release name the React Native SDK reports: bundleId@version+build. */
|
|
46
|
+
export declare const releaseName: (id: string, version: string, build: string) => string;
|
|
47
|
+
export declare const HELP = "rootnode \u2014 upload symbols so release crashes show real code lines\n\n rootnode eas\n After an EAS build (package.json \"eas-build-on-success\": \"npx @rootnode-app/cli eas\"):\n finds this build's dSYMs, ProGuard mapping and JS bundle + source map and\n uploads them. Never fails the build unless --strict.\n\n rootnode upload-symbols <path...>\n dSYM folders, .xcarchive, unstripped .so folders, mapping.txt.\n\n rootnode upload-sourcemaps --bundle <file> --map <file> --release <name> [--dist <build>]\n\nOptions\n --key <rn_\u2026> the app's analytics key (default: ROOTNODE_KEY or EXPO_PUBLIC_ROOTNODE_KEY)\n --target <name> which target of the app: react-native (default; eas picks ios/android + react-native), ios, android\u2026\n --host <url> default https://rootnode.app\n --strict exit non-zero when an upload fails";
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pure planning for the Rootnode CLI: which upload runs to make, with which
|
|
4
|
+
* arguments. The uploader underneath is sentry-cli 2.58.2 (BSD-3, pinned —
|
|
5
|
+
* later versions are FSL); nothing here touches the network or the disk.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.HELP = exports.releaseName = exports.DEFAULT_HOST = void 0;
|
|
9
|
+
exports.parseArgs = parseArgs;
|
|
10
|
+
exports.uploaderEnv = uploaderEnv;
|
|
11
|
+
exports.symbolRuns = symbolRuns;
|
|
12
|
+
exports.sourcemapRun = sourcemapRun;
|
|
13
|
+
exports.easTargetCandidates = easTargetCandidates;
|
|
14
|
+
exports.DEFAULT_HOST = 'https://rootnode.app';
|
|
15
|
+
function parseArgs(argv, env) {
|
|
16
|
+
const [name = 'help', ...rest] = argv;
|
|
17
|
+
const o = {
|
|
18
|
+
key: env.ROOTNODE_KEY ?? env.EXPO_PUBLIC_ROOTNODE_KEY ?? null,
|
|
19
|
+
host: (env.ROOTNODE_HOST ?? exports.DEFAULT_HOST).replace(/\/$/, ''),
|
|
20
|
+
target: env.ROOTNODE_TARGET ?? 'react-native',
|
|
21
|
+
targetExplicit: !!env.ROOTNODE_TARGET,
|
|
22
|
+
release: null,
|
|
23
|
+
dist: null,
|
|
24
|
+
bundle: null,
|
|
25
|
+
map: null,
|
|
26
|
+
strict: false,
|
|
27
|
+
paths: [],
|
|
28
|
+
};
|
|
29
|
+
const flags = {
|
|
30
|
+
'--key': (v) => (o.key = v),
|
|
31
|
+
'--host': (v) => (o.host = v.replace(/\/$/, '')),
|
|
32
|
+
'--target': (v) => {
|
|
33
|
+
o.target = v;
|
|
34
|
+
o.targetExplicit = true;
|
|
35
|
+
},
|
|
36
|
+
'--release': (v) => (o.release = v),
|
|
37
|
+
'--dist': (v) => (o.dist = v),
|
|
38
|
+
'--bundle': (v) => (o.bundle = v),
|
|
39
|
+
'--map': (v) => (o.map = v),
|
|
40
|
+
};
|
|
41
|
+
for (let i = 0; i < rest.length; i++) {
|
|
42
|
+
const a = rest[i];
|
|
43
|
+
const [flag, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
|
|
44
|
+
if (flag === '--strict')
|
|
45
|
+
o.strict = true;
|
|
46
|
+
else if (flags[flag]) {
|
|
47
|
+
const v = inline ?? rest[++i];
|
|
48
|
+
if (v === undefined)
|
|
49
|
+
throw new Error(`${flag} needs a value`);
|
|
50
|
+
flags[flag](v);
|
|
51
|
+
}
|
|
52
|
+
else if (a.startsWith('--'))
|
|
53
|
+
throw new Error(`unknown option ${a}`);
|
|
54
|
+
else
|
|
55
|
+
o.paths.push(a);
|
|
56
|
+
}
|
|
57
|
+
const known = ['upload-symbols', 'upload-sourcemaps', 'eas', 'help', '--help', '-h'];
|
|
58
|
+
if (!known.includes(name))
|
|
59
|
+
throw new Error(`unknown command "${name}" — run: rootnode help`);
|
|
60
|
+
return { name: name.startsWith('-') ? 'help' : name, options: o };
|
|
61
|
+
}
|
|
62
|
+
/** Environment for the uploader: our host, the app's analytics key, the resolved org/project. */
|
|
63
|
+
function uploaderEnv(o, t) {
|
|
64
|
+
return {
|
|
65
|
+
SENTRY_URL: o.host,
|
|
66
|
+
SENTRY_AUTH_TOKEN: o.key ?? '',
|
|
67
|
+
SENTRY_ORG: t.organization,
|
|
68
|
+
SENTRY_PROJECT: t.project,
|
|
69
|
+
SENTRY_DISABLE_UPDATE_CHECK: 'true',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** ProGuard/R8 mapping files go through their own upload; everything else is a debug file (dSYM, .so, archive). */
|
|
73
|
+
function symbolRuns(paths) {
|
|
74
|
+
const mappings = paths.filter((p) => /mapping\.txt$/i.test(p));
|
|
75
|
+
const debug = paths.filter((p) => !mappings.includes(p));
|
|
76
|
+
const runs = [];
|
|
77
|
+
if (debug.length)
|
|
78
|
+
runs.push({ label: `debug files (${debug.length})`, args: ['debug-files', 'upload', ...debug] });
|
|
79
|
+
for (const m of mappings)
|
|
80
|
+
runs.push({ label: 'ProGuard mapping', args: ['upload-proguard', m] });
|
|
81
|
+
return runs;
|
|
82
|
+
}
|
|
83
|
+
/** A Hermes/JS bundle with its source map, tied to the release the SDK reports. */
|
|
84
|
+
function sourcemapRun(bundle, map, release, dist) {
|
|
85
|
+
return {
|
|
86
|
+
label: `source map (${release}${dist ? `, build ${dist}` : ''})`,
|
|
87
|
+
args: ['sourcemaps', 'upload', '--release', release, ...(dist ? ['--dist', dist] : []), '--bundle', bundle, '--bundle-sourcemap', map],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Which app targets one EAS build uploads to. A React Native app may report
|
|
92
|
+
* crashes through its per-platform targets (ios / android DSNs) or one
|
|
93
|
+
* react-native target — symbols go to every one of these that exists.
|
|
94
|
+
*/
|
|
95
|
+
function easTargetCandidates(platform, o) {
|
|
96
|
+
return o.targetExplicit ? [o.target] : [platform, 'react-native'];
|
|
97
|
+
}
|
|
98
|
+
/** The release name the React Native SDK reports: bundleId@version+build. */
|
|
99
|
+
const releaseName = (id, version, build) => `${id}@${version}+${build}`;
|
|
100
|
+
exports.releaseName = releaseName;
|
|
101
|
+
exports.HELP = `rootnode — upload symbols so release crashes show real code lines
|
|
102
|
+
|
|
103
|
+
rootnode eas
|
|
104
|
+
After an EAS build (package.json "eas-build-on-success": "npx @rootnode-app/cli eas"):
|
|
105
|
+
finds this build's dSYMs, ProGuard mapping and JS bundle + source map and
|
|
106
|
+
uploads them. Never fails the build unless --strict.
|
|
107
|
+
|
|
108
|
+
rootnode upload-symbols <path...>
|
|
109
|
+
dSYM folders, .xcarchive, unstripped .so folders, mapping.txt.
|
|
110
|
+
|
|
111
|
+
rootnode upload-sourcemaps --bundle <file> --map <file> --release <name> [--dist <build>]
|
|
112
|
+
|
|
113
|
+
Options
|
|
114
|
+
--key <rn_…> the app's analytics key (default: ROOTNODE_KEY or EXPO_PUBLIC_ROOTNODE_KEY)
|
|
115
|
+
--target <name> which target of the app: react-native (default; eas picks ios/android + react-native), ios, android…
|
|
116
|
+
--host <url> default ${exports.DEFAULT_HOST}
|
|
117
|
+
--strict exit non-zero when an upload fails`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rootnode-app/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Rootnode command line: upload dSYMs, ProGuard mappings, native debug files and source maps so release crashes show real code lines.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Doğan Aktı",
|
|
7
|
+
"homepage": "https://rootnode.app/docs/integration.md",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/doganakti/rootnode.git",
|
|
11
|
+
"directory": "sdk/cli"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"rootnode": "dist/bin.js"
|
|
15
|
+
},
|
|
16
|
+
"main": "dist/plan.js",
|
|
17
|
+
"types": "dist/plan.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@sentry/cli": "2.58.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.20.5",
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^2.1.9"
|
|
37
|
+
}
|
|
38
|
+
}
|