@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 +3 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +193 -0
- package/lib/index.js.map +1 -0
- package/lib/react-native.d.ts +25 -0
- package/lib/react-native.d.ts.map +1 -0
- package/lib/react-native.js +74 -0
- package/lib/react-native.js.map +1 -0
- package/lib/upload.d.ts +24 -0
- package/lib/upload.d.ts.map +1 -0
- package/lib/upload.js +65 -0
- package/lib/upload.js.map +1 -0
- package/package.json +21 -12
- package/src/__tests__/react-native.test.ts +37 -0
- package/src/__tests__/upload.test.ts +121 -0
- package/src/index.ts +204 -0
- package/src/react-native.ts +87 -0
- package/src/upload.ts +85 -0
- package/README.md +0 -51
- package/bin/sentori-cli.js +0 -32
- package/scripts/postinstall.js +0 -90
package/lib/index.d.ts
ADDED
|
@@ -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
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAErC,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAE9C,MAAM,IAAI,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCZ,CAAA;AAID,iEAAiE;AACjE,SAAS,WAAW,CAAC,MAA+B;IAClD,MAAM,OAAO,GAAG,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;IAC/E,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,uEAAuE,CAAC,CAAA;QACtF,OAAO,IAAI,CAAA;IACb,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,KAAK,IAAI,CAAA;IACzC,MAAM,KAAK,GACT,CAAC,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,aAAa,CAAA;IAC5F,IAAI,CAAC,KAAK,IAAI,CAAC,MAAM,EAAE,CAAC;QACtB,OAAO,CAAC,KAAK,CAAC,gDAAgD,CAAC,CAAA;QAC/D,OAAO,IAAI,CAAA;IACb,CAAC;IACD,MAAM,MAAM,GACV,CAAC,OAAO,MAAM,CAAC,SAAS,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QACvE,CAAC,OAAO,MAAM,CAAC,YAAY,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAC7E,OAAO,CAAC,GAAG,CAAC,eAAe;QAC3B,8BAA8B,CAAA;IAChC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE,EAAE,CAAA;AACxD,CAAC;AAED,KAAK,UAAU,kBAAkB,CAAC,IAAc;IAC9C,IAAI,MAAM,CAAA;IACV,IAAI,CAAC;QACH,MAAM,GAAG,SAAS,CAAC;YACjB,gBAAgB,EAAE,IAAI;YACtB,IAAI,EAAE,IAAI;YACV,OAAO,EAAE;gBACP,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAC7B,SAAS,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE;gBAC9B,IAAI,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,EAAE;gBACrC,YAAY,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAChC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAC3B,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;aAC1B;SACF,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,UAAW,CAAW,CAAC,OAAO,IAAI,CAAC,CAAA;QACjD,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACnB,OAAO,CAAC,CAAA;IACV,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACvB,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACjB,OAAO,CAAC,CAAA;IACV,CAAC;IACD,MAAM,CAAC,GAAG,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACpC,IAAI,CAAC,CAAC;QAAE,OAAO,CAAC,CAAA;IAChB,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpC,OAAO,CAAC,KAAK,CAAC,0DAA0D,CAAC,CAAA;QACzE,OAAO,CAAC,CAAA;IACV,CAAC;IACD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC;YACpC,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,KAAK,EAAE,MAAM,CAAC,WAAW;YACzB,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,KAAK,EAAE,CAAC,CAAC,KAAK;SACf,CAAC,CAAA;QACF,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC,CAAA;QACvB,OAAO,CAAC,CAAA;IACV,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,kBAAmB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAA;QACvD,OAAO,CAAC,CAAA;IACV,CAAC;AACH,CAAC;AAED,KAAK,UAAU,oBAAoB,CAAC,IAAc;IAChD,IAAI,MAAM,CAAA;IACV,IAAI,CAAC;QACH,MAAM,GAAG,SAAS,CAAC;YACjB,IAAI,EAAE,IAAI;YACV,OAAO,EAAE;gBACP,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAC7B,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAC1B,SAAS,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE;gBAC9B,IAAI,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,EAAE;gBACrC,YAAY,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAChC,YAAY,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAChC,WAAW,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAC/B,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBAC3B,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;aAC1B;SACF,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,UAAW,CAAW,CAAC,OAAO,IAAI,CAAC,CAAA;QACjD,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACnB,OAAO,CAAC,CAAA;IACV,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACvB,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACjB,OAAO,CAAC,CAAA;IACV,CAAC;IACD,MAAM,CAAC,GAAG,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACpC,IAAI,CAAC,CAAC;QAAE,OAAO,CAAC,CAAA;IAChB,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;IAC3C,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;IAC7C,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;QAClE,OAAO,CAAC,KAAK,CAAC,uDAAuD,CAAC,CAAA;QACtE,OAAO,CAAC,CAAA;IACV,CAAC;IACD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC;YACrC,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS;YACnF,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,SAAS;YACT,QAAQ;YACR,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,KAAK,EAAE,CAAC,CAAC,KAAK;SACf,CAAC,CAAA;QACF,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC,CAAA;QACvB,OAAO,CAAC,CAAA;IACV,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,+BAAgC,CAAW,CAAC,OAAO,EAAE,CAAC,CAAA;QACpE,OAAO,CAAC,CAAA;IACV,CAAC;AACH,CAAC;AAED,SAAS,YAAY,CACnB,MAA8C,EAC9C,CAAS;IAET,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC;QACb,OAAO,CAAC,GAAG,CACT,gBAAgB,MAAM,CAAC,KAAK,CAAC,MAAM,eAAe,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,uBAAuB,kBAAkB,CAAC,CAAC,CAAC,OAAO,CAAC,cAAc,CACjJ,CAAA;QACD,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,KAAK;YAAE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;IACrD,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CACT,YAAY,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,yBAAyB,CAAC,CAAC,OAAO,iEAAiE,CACtJ,CAAA;IACH,CAAC;AACH,CAAC;AAED,KAAK,UAAU,IAAI,CAAC,IAAc;IAChC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,QAAQ,EAAE,CAAC;QAClE,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACjB,OAAO,CAAC,CAAA;IACV,CAAC;IACD,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,IAAI,CAAA;IAC5B,IAAI,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,WAAW;QAAE,OAAO,kBAAkB,CAAC,IAAI,CAAC,CAAA;IACxE,IAAI,CAAC,KAAK,cAAc,IAAI,CAAC,KAAK,QAAQ;QAAE,OAAO,oBAAoB,CAAC,IAAI,CAAC,CAAA;IAC7E,OAAO,CAAC,KAAK,CAAC,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,QAAQ,IAAI,CAAC,CAAA;IACnF,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACnB,OAAO,CAAC,CAAA;AACV,CAAC;AAED,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAC9B,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAC5B,CAAC,CAAU,EAAE,EAAE;IACb,OAAO,CAAC,KAAK,CAAC,UAAW,CAAW,CAAC,OAAO,EAAE,CAAC,CAAA;IAC/C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CACF,CAAA"}
|
|
@@ -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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"}
|
package/lib/upload.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Sentori CLI
|
|
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
|
|
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
|
-
"
|
|
18
|
+
"sourcemaps",
|
|
19
19
|
"error-tracking"
|
|
20
20
|
],
|
|
21
|
+
"type": "module",
|
|
21
22
|
"bin": {
|
|
22
|
-
"sentori-cli": "
|
|
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
|
-
"
|
|
29
|
-
"
|
|
28
|
+
"lib/",
|
|
29
|
+
"src/",
|
|
30
30
|
"README.md"
|
|
31
31
|
],
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=18"
|
|
34
34
|
},
|
|
35
|
-
"
|
|
36
|
-
|
|
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
|
-
```
|
package/bin/sentori-cli.js
DELETED
|
@@ -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
|
-
})
|
package/scripts/postinstall.js
DELETED
|
@@ -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
|
-
})
|