@bugsee/cli 0.0.0 → 0.7.7

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 ADDED
@@ -0,0 +1,78 @@
1
+ # @bugsee/cli
2
+
3
+ The [Bugsee CLI](https://github.com/bugsee/bugsee-cli) — a cross-platform Rust
4
+ binary that collects debug information files (dSYM, ELF, PE/PDB, R8/ProGuard
5
+ mappings, JS source maps), resolves build-environment metadata, and uploads
6
+ symbols to Bugsee.
7
+
8
+ ```sh
9
+ npm install --save-dev @bugsee/cli
10
+ npx bugsee-cli --version
11
+ ```
12
+
13
+ ## How the binary gets here
14
+
15
+ The binary is **not** in this package. It ships in six per-platform packages,
16
+ declared as `optionalDependencies`:
17
+
18
+ | Package | `os` / `cpu` |
19
+ | -------------------------- | ------------------ |
20
+ | `@bugsee/cli-darwin-arm64` | `darwin` / `arm64` |
21
+ | `@bugsee/cli-darwin-x64` | `darwin` / `x64` |
22
+ | `@bugsee/cli-linux-arm64` | `linux` / `arm64` |
23
+ | `@bugsee/cli-linux-x64` | `linux` / `x64` |
24
+ | `@bugsee/cli-win32-x64` | `win32` / `x64` |
25
+ | `@bugsee/cli-win32-arm64` | `win32` / `arm64` |
26
+
27
+ npm installs only the one matching your machine and skips the rest. Nothing is
28
+ downloaded at install time, so this works with `--ignore-scripts`, with a
29
+ lockfile-pinned CI install, and offline from a warm cache.
30
+
31
+ The Linux builds link glibc and are marked `"libc": ["glibc"]`. On musl (Alpine)
32
+ the install still succeeds, and `bugsee-cli` reports that plainly if invoked —
33
+ use a glibc base image, or build from source.
34
+
35
+ If the platform package is unavailable — `--no-optional`, a registry mirror
36
+ that carries only this package, or an unsupported platform — a `postinstall`
37
+ fallback downloads the release archive for your host and verifies its SHA-256
38
+ before unpacking it into `vendor/`. That fallback **never fails the install**:
39
+ if it cannot fetch the binary it warns and exits 0, and the error surfaces only
40
+ if you actually invoke `bugsee-cli`.
41
+
42
+ | Variable | Effect |
43
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | `BUGSEE_CLI_SKIP_DOWNLOAD=1` | Skip the fallback download entirely. |
45
+ | `BUGSEE_CLI_BASE_URL=<url>` | Fetch `<url>/bugsee-cli-<triple>.<ext>` (+ `.sha256`) instead of the GitHub release — point it at an internal mirror, or at `https://download.bugsee.com/cli/v<version>`. |
46
+
47
+ ## Programmatic use
48
+
49
+ ```js
50
+ const { spawnSync } = require("node:child_process");
51
+ const { binaryPath } = require("@bugsee/cli");
52
+
53
+ const out = spawnSync(binaryPath(), ["vcs-metadata"], { encoding: "utf8" });
54
+ const metadata = JSON.parse(out.stdout);
55
+ ```
56
+
57
+ - `binaryPath()` — absolute path to the binary; throws with a diagnostic if
58
+ there is none for this platform.
59
+ - `resolveBinaryPath()` — the same lookup, returning `null` instead of throwing.
60
+ - `version` — the CLI version this package carries.
61
+
62
+ ## Exit codes
63
+
64
+ `bugsee-cli` exits with a
65
+ [stable, documented code](https://github.com/bugsee/bugsee-cli#exit-code-contract)
66
+ and the launcher forwards it unchanged (`0` success, `1`/`2` structural — the
67
+ caller should fall back, `10`–`39` substantive failures, `40` a deliberate build
68
+ gate). A launcher that cannot find a binary at all exits `1`, which is the
69
+ "structural, fall back" case by that same contract.
70
+
71
+ ## Other install channels
72
+
73
+ `@bugsee/bugsee-cli` is the same CLI published by cargo-dist as a single
74
+ package that downloads its binary in a `postinstall`. It remains supported as
75
+ an alias; **prefer `@bugsee/cli`** — it is the one that works under
76
+ `--ignore-scripts`. See the
77
+ [Distribution section](https://github.com/bugsee/bugsee-cli#distribution) for
78
+ the shell installer, Homebrew tap, and the per-build-system bundles.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the `bugsee-cli` bin of @bugsee/cli.
3
+ //
4
+ // This is a pass-through, and "pass-through" is a contract here, not a
5
+ // nicety: every integrator that shells to the CLI branches on its EXIT CODE
6
+ // (see the exit-code contract in README.md — <= 2 means "fall back to the
7
+ // in-language uploader", >= 10 means "a real failure, do not fall back"), and
8
+ // the metadata commands' stdout is parsed with `json.loads`. So this wrapper
9
+ // must forward argv verbatim, leave all three stdio streams untouched, and
10
+ // reproduce the child's exit status exactly.
11
+
12
+ "use strict";
13
+
14
+ const { spawnSync } = require("child_process");
15
+ const os = require("os");
16
+ const { resolveBinaryPath, diagnostic } = require("../lib/index.js");
17
+
18
+ const binary = resolveBinaryPath();
19
+ if (!binary) {
20
+ console.error(diagnostic());
21
+ // Exit 1 = "structural" in the CLI's own exit-code contract: the CLI never
22
+ // got a fair chance to run, so a caller is expected to fall back. Any other
23
+ // code here would lie about what happened.
24
+ process.exit(1);
25
+ }
26
+
27
+ const result = spawnSync(binary, process.argv.slice(2), {
28
+ stdio: "inherit",
29
+ // No `shell: true` — it would re-parse arguments (quoting, globs, `&`) that
30
+ // are meant to reach clap untouched, and paths with spaces are routine here.
31
+ windowsHide: true,
32
+ });
33
+
34
+ if (result.error) {
35
+ console.error(`failed to execute ${binary}: ${result.error.message}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ if (result.signal) {
40
+ // The child died from a signal, which has no exit code. Report it the way a
41
+ // shell does (128 + signum) so `$?` still tells a CI script what happened.
42
+ const signum = os.constants.signals[result.signal];
43
+ process.exit(signum ? 128 + signum : 1);
44
+ }
45
+
46
+ process.exit(result.status === null ? 1 : result.status);
package/lib/index.js ADDED
@@ -0,0 +1,105 @@
1
+ // Programmatic entry point for @bugsee/cli.
2
+ //
3
+ // const { binaryPath } = require("@bugsee/cli");
4
+ // spawnSync(binaryPath(), ["vcs-metadata"], { encoding: "utf8" });
5
+ //
6
+ // Exists so a Node integrator (the React Native / Cordova / Capacitor hooks)
7
+ // can spawn the binary directly and read its stdout JSON, instead of going
8
+ // through `bin` and paying for an extra node process just to re-exec.
9
+
10
+ "use strict";
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { PLATFORMS, currentTriple, isMuslLinux } = require("./platforms.js");
15
+
16
+ const { version } = require("../package.json");
17
+
18
+ /** Where scripts/postinstall.js parks a binary it downloaded itself. */
19
+ function vendorPath(triple) {
20
+ const p = PLATFORMS[triple];
21
+ if (!p) return null;
22
+ return path.join(__dirname, "..", "vendor", p.bin);
23
+ }
24
+
25
+ /**
26
+ * Locate the binary, or return null.
27
+ *
28
+ * Two sources, in order:
29
+ *
30
+ * 1. The platform package installed as an optionalDependency. This is the
31
+ * normal path and the whole point of the package split: npm picks exactly
32
+ * one of the six by `os`/`cpu`, and it works under `--ignore-scripts`
33
+ * because nothing had to run to put the binary there.
34
+ * 2. `vendor/`, where the postinstall fallback downloads to when (1) is
35
+ * unavailable — `--no-optional`, a registry mirror that carries only the
36
+ * front package, or a corporate proxy that 404s the platform scopes.
37
+ *
38
+ * Never throws: callers that want an exception want binaryPath().
39
+ */
40
+ function resolveBinaryPath() {
41
+ const triple = currentTriple();
42
+ if (!triple) return null;
43
+ const { pkg, bin } = PLATFORMS[triple];
44
+
45
+ // Exact-file resolution: `bin/bugsee-cli` has no extension, so Node's
46
+ // LOAD_AS_FILE matches it verbatim. Kept as the primary lookup because it
47
+ // fails loudly-in-try if the package is present but was packed wrong.
48
+ try {
49
+ return require.resolve(`${pkg}/bin/${bin}`);
50
+ } catch {
51
+ /* fall through */
52
+ }
53
+
54
+ // Same package, resolved via its manifest. This survives a consumer that
55
+ // restricts subpath resolution (yarn PnP strictness, a future `exports`
56
+ // field on the platform packages) where the direct lookup would not.
57
+ try {
58
+ const manifest = require.resolve(`${pkg}/package.json`);
59
+ const candidate = path.join(path.dirname(manifest), "bin", bin);
60
+ if (fs.existsSync(candidate)) return candidate;
61
+ } catch {
62
+ /* fall through */
63
+ }
64
+
65
+ const vendored = vendorPath(triple);
66
+ if (vendored && fs.existsSync(vendored)) return vendored;
67
+
68
+ return null;
69
+ }
70
+
71
+ /** Human-readable reason there is no binary — used in both error paths. */
72
+ function diagnostic() {
73
+ if (isMuslLinux()) {
74
+ return (
75
+ "bugsee-cli publishes glibc Linux builds only; this looks like a musl " +
76
+ "system (e.g. Alpine). Use a glibc base image, or build from source: " +
77
+ "https://github.com/bugsee/bugsee-cli"
78
+ );
79
+ }
80
+ const triple = currentTriple();
81
+ if (!triple) {
82
+ return (
83
+ `bugsee-cli has no published binary for ${process.platform}-${process.arch}. ` +
84
+ `Supported: ${Object.values(PLATFORMS)
85
+ .map((p) => `${p.os}-${p.cpu}`)
86
+ .join(", ")}.`
87
+ );
88
+ }
89
+ const { pkg } = PLATFORMS[triple];
90
+ return (
91
+ `bugsee-cli binary not found. Expected the optional dependency ${pkg} ` +
92
+ `(installed automatically) or a postinstall download in vendor/. ` +
93
+ `If you installed with --no-optional, re-run without it, or run ` +
94
+ `\`node node_modules/@bugsee/cli/scripts/postinstall.js\` to fetch it.`
95
+ );
96
+ }
97
+
98
+ /** Absolute path to the binary. Throws if there is none. */
99
+ function binaryPath() {
100
+ const resolved = resolveBinaryPath();
101
+ if (!resolved) throw new Error(diagnostic());
102
+ return resolved;
103
+ }
104
+
105
+ module.exports = { binaryPath, resolveBinaryPath, diagnostic, version };
@@ -0,0 +1,129 @@
1
+ // The single source of truth for "which npm package carries which binary".
2
+ //
3
+ // Consumed by three things that MUST agree, or the front package resolves a
4
+ // binary the publisher never built:
5
+ // - lib/index.js (runtime resolution of the optional dependency)
6
+ // - scripts/postinstall.js (the download fallback, which needs the release
7
+ // artifact name for the same triple)
8
+ // - ../../build.mjs (the assembler, which mints one npm package per row)
9
+ //
10
+ // `triple` is the Rust target triple, and it is the join key with everything
11
+ // outside this directory: `[workspace.metadata.dist].targets` in Cargo.toml
12
+ // names these, cargo-dist names its release assets after them
13
+ // (`bugsee-cli-<triple><archiveExt>`), and installer/install.sh builds the same
14
+ // filename. Adding a platform here without adding the triple to `targets`
15
+ // produces a package with nothing to put in it. (Do NOT then run
16
+ // `dist generate`: `allow-dirty = ["ci"]` makes it a silent no-op for
17
+ // release.yml. Check the plan with `dist plan --output-format=json` — see the
18
+ // `allow-dirty` comment in Cargo.toml.)
19
+ //
20
+ // Windows arm64 (`aarch64-pc-windows-msvc`) IS published as of 0.7.7 — see
21
+ // bugsee/bugsee-cli#20 for why it was absent before. It cross-compiles under
22
+ // cargo-xwin only if `ring` can assemble its ARM64 Windows `.S` files, which it
23
+ // cannot, so that leg builds NATIVELY on a `windows-11-arm` runner instead
24
+ // (`[workspace.metadata.dist.github-custom-runners]` in Cargo.toml). ci.yml
25
+ // build-checks the target on every PR so the leg cannot break a tag release:
26
+ // dist's `host` job needs EVERY build-local-artifacts leg, and a failing leg
27
+ // means no GitHub Release, hence no S3 mirror and no npm publish.
28
+
29
+ "use strict";
30
+
31
+ const PLATFORMS = Object.freeze({
32
+ "aarch64-apple-darwin": {
33
+ pkg: "@bugsee/cli-darwin-arm64",
34
+ os: "darwin",
35
+ cpu: "arm64",
36
+ bin: "bugsee-cli",
37
+ archiveExt: ".tar.xz",
38
+ label: "macOS arm64 (Apple silicon)",
39
+ },
40
+ "x86_64-apple-darwin": {
41
+ pkg: "@bugsee/cli-darwin-x64",
42
+ os: "darwin",
43
+ cpu: "x64",
44
+ bin: "bugsee-cli",
45
+ archiveExt: ".tar.xz",
46
+ label: "macOS x86_64 (Intel)",
47
+ },
48
+ "aarch64-unknown-linux-gnu": {
49
+ pkg: "@bugsee/cli-linux-arm64",
50
+ os: "linux",
51
+ cpu: "arm64",
52
+ // Our Linux builds are `unknown-linux-gnu`. npm >= 10.5 (and pnpm/yarn)
53
+ // honour this the way they honour os/cpu, so a musl host skips the
54
+ // download entirely; older npm ignores the field and installs a binary it
55
+ // cannot load, which is what currentTriple()'s musl check catches.
56
+ libc: "glibc",
57
+ bin: "bugsee-cli",
58
+ archiveExt: ".tar.xz",
59
+ label: "Linux arm64 (glibc)",
60
+ },
61
+ "x86_64-unknown-linux-gnu": {
62
+ pkg: "@bugsee/cli-linux-x64",
63
+ os: "linux",
64
+ cpu: "x64",
65
+ // See the note on aarch64-unknown-linux-gnu above.
66
+ libc: "glibc",
67
+ bin: "bugsee-cli",
68
+ archiveExt: ".tar.xz",
69
+ label: "Linux x86_64 (glibc)",
70
+ },
71
+ "x86_64-pc-windows-msvc": {
72
+ pkg: "@bugsee/cli-win32-x64",
73
+ os: "win32",
74
+ cpu: "x64",
75
+ bin: "bugsee-cli.exe",
76
+ archiveExt: ".zip",
77
+ label: "Windows x86_64",
78
+ },
79
+ "aarch64-pc-windows-msvc": {
80
+ pkg: "@bugsee/cli-win32-arm64",
81
+ os: "win32",
82
+ cpu: "arm64",
83
+ bin: "bugsee-cli.exe",
84
+ archiveExt: ".zip",
85
+ label: "Windows arm64",
86
+ },
87
+ });
88
+
89
+ /** Release-asset filename cargo-dist publishes for a triple. */
90
+ function artifactName(triple) {
91
+ const p = PLATFORMS[triple];
92
+ return p ? `bugsee-cli-${triple}${p.archiveExt}` : null;
93
+ }
94
+
95
+ // The published Linux builds are `unknown-linux-gnu`; there is no musl target
96
+ // (install.sh refuses musl for the same reason). Node exposes the runtime glibc
97
+ // version in its process report ONLY when it is actually linked against glibc,
98
+ // so an absent value means musl (or a non-glibc libc) — this is the zero-
99
+ // dependency equivalent of the `detect-libc` call cargo-dist's installer makes.
100
+ function isMuslLinux() {
101
+ if (process.platform !== "linux") return false;
102
+ try {
103
+ const report = process.report && process.report.getReport();
104
+ const header = report && report.header;
105
+ return !(header && header.glibcVersionRuntime);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Rust target triple for the host, or null when we publish nothing for it.
113
+ *
114
+ * Note this is deliberately driven by the triple Node reports for ITSELF, not
115
+ * by the hardware: an x64 Node under Rosetta on an arm64 Mac reports `x64` and
116
+ * gets the x86_64 binary, which is what runs correctly in that process tree.
117
+ */
118
+ function currentTriple() {
119
+ const { platform, arch } = process;
120
+ for (const [triple, p] of Object.entries(PLATFORMS)) {
121
+ if (p.os === platform && p.cpu === arch) {
122
+ if (platform === "linux" && isMuslLinux()) return null;
123
+ return triple;
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ module.exports = { PLATFORMS, artifactName, currentTriple, isMuslLinux };
package/package.json CHANGED
@@ -1,7 +1,44 @@
1
1
  {
2
2
  "name": "@bugsee/cli",
3
- "version": "0.0.0",
4
- "description": "Placeholder — see https://github.com/bugsee/bugsee-cli",
5
- "repository": { "type": "git", "url": "git+https://github.com/bugsee/bugsee-cli.git" },
6
- "license": "MIT"
3
+ "version": "0.7.7",
4
+ "description": "Bugsee CLI — cross-platform symbol collection, conversion, and upload.",
5
+ "keywords": [
6
+ "bugsee",
7
+ "symbols",
8
+ "dsym",
9
+ "sourcemaps",
10
+ "proguard",
11
+ "crash-reporting"
12
+ ],
13
+ "homepage": "https://github.com/bugsee/bugsee-cli",
14
+ "repository": "https://github.com/bugsee/bugsee-cli",
15
+ "author": "Bugsee",
16
+ "bin": {
17
+ "bugsee-cli": "bin/bugsee-cli.js"
18
+ },
19
+ "main": "lib/index.js",
20
+ "exports": {
21
+ ".": "./lib/index.js",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "lib",
27
+ "scripts",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "postinstall": "node scripts/postinstall.js"
32
+ },
33
+ "engines": {
34
+ "node": ">=14.18"
35
+ },
36
+ "optionalDependencies": {
37
+ "@bugsee/cli-darwin-arm64": "0.7.7",
38
+ "@bugsee/cli-darwin-x64": "0.7.7",
39
+ "@bugsee/cli-linux-arm64": "0.7.7",
40
+ "@bugsee/cli-linux-x64": "0.7.7",
41
+ "@bugsee/cli-win32-arm64": "0.7.7",
42
+ "@bugsee/cli-win32-x64": "0.7.7"
43
+ }
7
44
  }
@@ -0,0 +1,197 @@
1
+ // Install-time HTTP helper for scripts/postinstall.js. Not part of the runtime
2
+ // surface — lib/index.js never requires it.
3
+ //
4
+ // Deliberately dependency-free (node:https only). The front package having
5
+ // zero runtime dependencies is the reason it can be installed from a locked-
6
+ // down mirror at all, and a postinstall that needed `node-fetch` would defeat
7
+ // the fallback it exists to provide. Redirect following is required because
8
+ // GitHub release downloads 302 to objects.githubusercontent.com; proxy support
9
+ // is required because the CI boxes that hit the fallback path (no access to
10
+ // the platform scopes) are usually the ones behind a proxy.
11
+
12
+ "use strict";
13
+
14
+ const http = require("node:http");
15
+ const https = require("node:https");
16
+
17
+ // How many redirect hops we will FOLLOW. GitHub release downloads take two
18
+ // (release -> objects.githubusercontent.com -> the CDN), so 5 is generous.
19
+ const MAX_REDIRECTS = 5;
20
+
21
+ // HTTPS only, with one narrow exception. The artifact and its `.sha256`
22
+ // sidecar come from the same base URL, so anyone who can rewrite a cleartext
23
+ // hop controls BOTH and the checksum verification proves nothing. This is the
24
+ // same stance installer/install.sh takes with `curl --proto '=https'
25
+ // --tlsv1.2`. The exception is a loopback host, which exists so a local mirror
26
+ // (and this repo's own checksum-mismatch test) can be served over plain HTTP
27
+ // without a certificate; loopback cannot be intercepted by a network attacker.
28
+ function isLoopback(hostname) {
29
+ const host = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
30
+ return (
31
+ host === "localhost" ||
32
+ host === "::1" ||
33
+ host === "0:0:0:0:0:0:0:1" ||
34
+ /^127(?:\.\d{1,3}){3}$/.test(host)
35
+ );
36
+ }
37
+
38
+ function proxyFor(urlString) {
39
+ const url = new URL(urlString);
40
+ const isHttps = url.protocol === "https:";
41
+
42
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
43
+ if (noProxy === "*") return null;
44
+ if (noProxy) {
45
+ const host = url.hostname.toLowerCase();
46
+ for (const raw of noProxy.split(",")) {
47
+ const entry = raw.trim().toLowerCase();
48
+ if (entry && (host === entry || host.endsWith("." + entry))) return null;
49
+ }
50
+ }
51
+
52
+ const env = isHttps
53
+ ? process.env.HTTPS_PROXY || process.env.https_proxy
54
+ : process.env.HTTP_PROXY || process.env.http_proxy;
55
+ if (!env) return null;
56
+
57
+ const proxy = new URL(env);
58
+ return {
59
+ hostname: proxy.hostname,
60
+ port: proxy.port || (proxy.protocol === "https:" ? 443 : 80),
61
+ auth:
62
+ proxy.username || proxy.password
63
+ ? `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
64
+ : null,
65
+ };
66
+ }
67
+
68
+ function connectThroughProxy(proxy, target) {
69
+ return new Promise((resolve, reject) => {
70
+ const headers = {};
71
+ if (proxy.auth) {
72
+ headers["Proxy-Authorization"] =
73
+ "Basic " + Buffer.from(proxy.auth).toString("base64");
74
+ }
75
+ const req = http.request({
76
+ hostname: proxy.hostname,
77
+ port: proxy.port,
78
+ method: "CONNECT",
79
+ path: `${target.hostname}:${target.port || 443}`,
80
+ headers,
81
+ });
82
+ req.on("connect", (res, socket) => {
83
+ if (res.statusCode === 200) resolve(socket);
84
+ else
85
+ reject(new Error(`proxy CONNECT failed with status ${res.statusCode}`));
86
+ });
87
+ req.on("error", reject);
88
+ req.end();
89
+ });
90
+ }
91
+
92
+ /**
93
+ * GET a URL, following redirects; resolves with the response stream.
94
+ *
95
+ * `httpsOnly` is latched on the FIRST hop and carried through every redirect,
96
+ * so an https origin can never be walked down to cleartext by a 302 — without
97
+ * that, re-checking each hop independently would happily follow
98
+ * `https://mirror/... -> http://attacker/...`.
99
+ */
100
+ function get(urlString, redirectsFollowed = 0, httpsOnly = null) {
101
+ return new Promise((resolve, reject) => {
102
+ if (redirectsFollowed > MAX_REDIRECTS) {
103
+ return reject(new Error(`too many redirects (> ${MAX_REDIRECTS})`));
104
+ }
105
+
106
+ const parsed = new URL(urlString);
107
+ const isHttps = parsed.protocol === "https:";
108
+ if (!isHttps && parsed.protocol !== "http:") {
109
+ return reject(new Error(`unsupported protocol: ${parsed.protocol}`));
110
+ }
111
+ const secureRequired = httpsOnly === null ? isHttps : httpsOnly;
112
+ if (!isHttps) {
113
+ if (secureRequired) {
114
+ return reject(
115
+ new Error(
116
+ `refusing to follow an https -> http redirect: ${urlString}`,
117
+ ),
118
+ );
119
+ }
120
+ if (!isLoopback(parsed.hostname)) {
121
+ return reject(
122
+ new Error(
123
+ `refusing to fetch over plain HTTP from a non-loopback host: ${urlString}`,
124
+ ),
125
+ );
126
+ }
127
+ }
128
+ const mod = isHttps ? https : http;
129
+ const proxy = proxyFor(urlString);
130
+
131
+ const send = (extra) => {
132
+ const options = Object.assign(
133
+ {
134
+ hostname: parsed.hostname,
135
+ port: parsed.port || (isHttps ? 443 : 80),
136
+ path: parsed.pathname + parsed.search,
137
+ method: "GET",
138
+ headers: { "User-Agent": "bugsee-cli-npm-installer" },
139
+ timeout: 60_000,
140
+ },
141
+ extra || {},
142
+ );
143
+
144
+ if (proxy && !isHttps) {
145
+ // Plain HTTP through an HTTP proxy: absolute-URI request line.
146
+ options.hostname = proxy.hostname;
147
+ options.port = proxy.port;
148
+ options.path = urlString;
149
+ if (proxy.auth) {
150
+ options.headers["Proxy-Authorization"] =
151
+ "Basic " + Buffer.from(proxy.auth).toString("base64");
152
+ }
153
+ }
154
+
155
+ const req = mod.request(options, (res) => {
156
+ const { statusCode, headers } = res;
157
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
158
+ res.resume();
159
+ const next = new URL(headers.location, urlString).toString();
160
+ return get(next, redirectsFollowed + 1, secureRequired).then(
161
+ resolve,
162
+ reject,
163
+ );
164
+ }
165
+ if (statusCode < 200 || statusCode >= 300) {
166
+ res.resume();
167
+ return reject(new Error(`HTTP ${statusCode} from ${urlString}`));
168
+ }
169
+ resolve(res);
170
+ });
171
+ req.on("timeout", () =>
172
+ req.destroy(new Error(`timed out: ${urlString}`)),
173
+ );
174
+ req.on("error", reject);
175
+ req.end();
176
+ };
177
+
178
+ if (proxy && isHttps) {
179
+ connectThroughProxy(proxy, parsed).then(
180
+ (socket) => send({ socket, agent: false }),
181
+ reject,
182
+ );
183
+ } else {
184
+ send();
185
+ }
186
+ });
187
+ }
188
+
189
+ /** GET a small URL fully into a string (used for the .sha256 sidecar). */
190
+ async function getText(urlString) {
191
+ const res = await get(urlString);
192
+ const chunks = [];
193
+ for await (const chunk of res) chunks.push(chunk);
194
+ return Buffer.concat(chunks).toString("utf8");
195
+ }
196
+
197
+ module.exports = { get, getText };
@@ -0,0 +1,28 @@
1
+ # Unpack a .zip for scripts/postinstall.js on Windows, where `unzip` is not
2
+ # present on a stock image.
3
+ #
4
+ # Invoked with `powershell.exe -File`, NOT `-Command`. This matters: `-Command`
5
+ # does not bind trailing arguments to `$args` — it APPENDS them to the command
6
+ # text and evaluates the result. An earlier version of this code passed the
7
+ # archive and destination as trailing arguments to a `-Command` string that
8
+ # read `$args[0]` / `$args[1]`; `$args.Count` is 0 under `-Command`, so
9
+ # Expand-Archive ran with null paths and the Windows fallback silently produced
10
+ # no binary. That append is also an injection shape — text from the caller
11
+ # becomes part of the script — which `-File` avoids entirely, because
12
+ # everything after the script path is bound as a parameter VALUE and is never
13
+ # parsed as code.
14
+ [CmdletBinding()]
15
+ param(
16
+ [Parameter(Mandatory = $true)][string]$LiteralPath,
17
+ [Parameter(Mandatory = $true)][string]$DestinationPath
18
+ )
19
+
20
+ $ErrorActionPreference = 'Stop'
21
+
22
+ try {
23
+ Expand-Archive -LiteralPath $LiteralPath -DestinationPath $DestinationPath -Force
24
+ exit 0
25
+ } catch {
26
+ [Console]::Error.WriteLine($_.Exception.Message)
27
+ exit 1
28
+ }
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ // Fallback binary fetch for @bugsee/cli.
3
+ //
4
+ // The optionalDependencies split covers the normal case. This script covers
5
+ // the three cases it cannot:
6
+ //
7
+ // - `npm install --no-optional` / `--omit=optional` (and the CI images that
8
+ // bake that in), where npm never even looks at the platform packages;
9
+ // - an internal registry mirror or proxy that carries @bugsee/cli but not
10
+ // the six @bugsee/cli-<platform> scopes, where npm silently skips the
11
+ // optional dependency and leaves nothing behind;
12
+ // - a platform we publish no package for at all.
13
+ //
14
+ // THE RULE FOR THIS FILE: it must never fail the install. `npm install` of a
15
+ // dependency tree that happens to contain @bugsee/cli must succeed on a
16
+ // Raspberry Pi, on Alpine, and inside an air-gapped mirror — @bugsee/cli is
17
+ // pulled in transitively by the RN/Cordova/Capacitor integrations, and a
18
+ // postinstall that exits non-zero there breaks an unrelated build for a tool
19
+ // the user may never invoke. So: every failure path warns on stderr and exits
20
+ // 0, and the missing binary is reported later, by the launcher, only if
21
+ // someone actually tries to run it.
22
+ //
23
+ // Environment:
24
+ // BUGSEE_CLI_SKIP_DOWNLOAD=1 skip the fallback entirely (offline builds)
25
+ // BUGSEE_CLI_BASE_URL=<url> download root; the artifact is fetched from
26
+ // <url>/bugsee-cli-<triple>.<ext> and verified
27
+ // against <that>.sha256 — the same layout the
28
+ // GitHub release and download.bugsee.com/cli
29
+ // both use, so an internal mirror of either
30
+ // works. Default: the GitHub release for this
31
+ // package's version.
32
+
33
+ "use strict";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // The safety net goes FIRST — above the requires, not below them.
37
+ //
38
+ // Everything from here down can throw: a corrupted lib/platforms.js, a
39
+ // half-extracted package, a Node version that chokes on the syntax. A throw
40
+ // during a top-level `require` is an uncaught exception, and an uncaught
41
+ // exception in a postinstall exits non-zero and FAILS THE INSTALL — precisely
42
+ // the outcome this file exists to prevent. Registering the handler after the
43
+ // requires (as an earlier revision did) left exactly that hole: the file
44
+ // claimed "even a bug in this script must not take an install down" while
45
+ // corrupting lib/platforms.js produced exit 1.
46
+ //
47
+ // The logging helpers are defined here too, and swallow their own failures:
48
+ // console.warn/error write to a pipe that npm may have closed, and an EPIPE
49
+ // thrown from inside the handler would defeat the handler.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const warn = (msg) => {
53
+ try {
54
+ console.warn(`[@bugsee/cli] ${msg}`);
55
+ } catch {
56
+ /* EPIPE / closed stderr — nothing useful left to do */
57
+ }
58
+ };
59
+ const info = (msg) => {
60
+ try {
61
+ console.error(`[@bugsee/cli] ${msg}`);
62
+ } catch {
63
+ /* as above */
64
+ }
65
+ };
66
+
67
+ const describe = (err) => (err && err.message) || String(err);
68
+
69
+ process.on("uncaughtException", (err) => {
70
+ warn(`postinstall failed: ${describe(err)}`);
71
+ process.exit(0);
72
+ });
73
+ process.on("unhandledRejection", (err) => {
74
+ warn(`postinstall failed: ${describe(err)}`);
75
+ process.exit(0);
76
+ });
77
+ process.exitCode = 0;
78
+
79
+ const crypto = require("crypto");
80
+ const fs = require("fs");
81
+ const os = require("os");
82
+ const path = require("path");
83
+ const { spawnSync } = require("child_process");
84
+
85
+ const {
86
+ PLATFORMS,
87
+ artifactName,
88
+ currentTriple,
89
+ } = require("../lib/platforms.js");
90
+ const { resolveBinaryPath, diagnostic } = require("../lib/index.js");
91
+ const { version } = require("../package.json");
92
+
93
+ const DEFAULT_BASE_URL = `https://github.com/bugsee/bugsee-cli/releases/download/v${version}`;
94
+
95
+ function baseUrl() {
96
+ const override = process.env.BUGSEE_CLI_BASE_URL;
97
+ return override ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
98
+ }
99
+
100
+ function sha256(file) {
101
+ const hash = crypto.createHash("sha256");
102
+ hash.update(fs.readFileSync(file));
103
+ return hash.digest("hex");
104
+ }
105
+
106
+ // The sidecar is `<hex> *<filename>` (coreutils/BSD format) — install.sh
107
+ // reads it with `awk '{print $1}'`, and so do we.
108
+ function parseChecksum(text) {
109
+ const token = text.trim().split(/\s+/)[0] || "";
110
+ return /^[0-9a-f]{64}$/i.test(token) ? token.toLowerCase() : null;
111
+ }
112
+
113
+ function extract(archive, destDir, ext) {
114
+ if (ext === ".tar.xz") {
115
+ // The tarball has a single top-level directory; both shell installers
116
+ // strip one component, so we match them exactly.
117
+ return spawnSync(
118
+ "tar",
119
+ ["xf", archive, "--strip-components", "1", "-C", destDir],
120
+ {
121
+ stdio: ["ignore", "ignore", "pipe"],
122
+ encoding: "utf8",
123
+ },
124
+ );
125
+ }
126
+ if (ext === ".zip") {
127
+ if (process.platform === "win32") {
128
+ // `unzip` is not present on a stock Windows image, so shell out to
129
+ // Expand-Archive — via `-File` and a real script. NOT `-Command`: that
130
+ // appends trailing arguments to the command TEXT instead of binding them
131
+ // to $args, so the paths would be lost (and the append is an injection
132
+ // shape). See the header of expand-archive.ps1.
133
+ //
134
+ // `-ExecutionPolicy Bypass` is required because the machine policy that
135
+ // blocks unsigned .ps1 files is the default on Windows Server images;
136
+ // it applies to this one invocation only.
137
+ return spawnSync(
138
+ "powershell.exe",
139
+ [
140
+ "-NoProfile",
141
+ "-NonInteractive",
142
+ "-ExecutionPolicy",
143
+ "Bypass",
144
+ "-File",
145
+ path.join(__dirname, "expand-archive.ps1"),
146
+ "-LiteralPath",
147
+ archive,
148
+ "-DestinationPath",
149
+ destDir,
150
+ ],
151
+ { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8" },
152
+ );
153
+ }
154
+ return spawnSync("unzip", ["-q", "-o", archive, "-d", destDir], {
155
+ stdio: ["ignore", "ignore", "pipe"],
156
+ encoding: "utf8",
157
+ });
158
+ }
159
+ return { error: new Error(`unrecognised archive extension: ${ext}`) };
160
+ }
161
+
162
+ async function main() {
163
+ if (process.env.BUGSEE_CLI_SKIP_DOWNLOAD === "1") {
164
+ info("BUGSEE_CLI_SKIP_DOWNLOAD=1 — skipping the binary download.");
165
+ return;
166
+ }
167
+
168
+ // The happy path: npm already installed the right platform package. Do not
169
+ // touch the network — the overwhelming majority of installs land here.
170
+ const existing = resolveBinaryPath();
171
+ if (existing) {
172
+ info(`using ${existing}`);
173
+ return;
174
+ }
175
+
176
+ const triple = currentTriple();
177
+ if (!triple) {
178
+ warn(diagnostic());
179
+ return;
180
+ }
181
+
182
+ const platform = PLATFORMS[triple];
183
+ const asset = artifactName(triple);
184
+ const url = `${baseUrl()}/${asset}`;
185
+ const vendorDir = path.join(__dirname, "..", "vendor");
186
+ const target = path.join(vendorDir, platform.bin);
187
+
188
+ info(
189
+ `${platform.pkg} is not installed (optional dependencies skipped, or not ` +
190
+ `available on this registry) — downloading ${asset} instead.`,
191
+ );
192
+
193
+ const { get, getText } = require("./download.js");
194
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bugsee-cli-"));
195
+ try {
196
+ const archive = path.join(tmp, asset);
197
+ const res = await get(url);
198
+ await new Promise((resolve, reject) => {
199
+ const sink = fs.createWriteStream(archive);
200
+ res.pipe(sink);
201
+ sink.on("error", reject);
202
+ res.on("error", reject);
203
+ sink.on("close", resolve);
204
+ });
205
+
206
+ // Verify before extracting, not after: the archive is what we fetched over
207
+ // the wire, and `tar`/`Expand-Archive` are the things we do not want to
208
+ // point at unverified bytes.
209
+ const expected = parseChecksum(await getText(`${url}.sha256`));
210
+ if (!expected)
211
+ throw new Error(`could not read checksum from ${url}.sha256`);
212
+ const actual = sha256(archive);
213
+ if (actual !== expected) {
214
+ throw new Error(
215
+ `checksum mismatch for ${asset}: expected ${expected}, got ${actual}`,
216
+ );
217
+ }
218
+
219
+ const result = extract(archive, tmp, platform.archiveExt);
220
+ if (result.error) throw result.error;
221
+ if (result.status !== 0) {
222
+ throw new Error(
223
+ `could not extract ${asset}: ${(result.stderr || "").toString().trim()}`,
224
+ );
225
+ }
226
+
227
+ const extracted = path.join(tmp, platform.bin);
228
+ if (!fs.existsSync(extracted)) {
229
+ throw new Error(`${platform.bin} not found after extracting ${asset}`);
230
+ }
231
+
232
+ fs.mkdirSync(vendorDir, { recursive: true });
233
+ fs.copyFileSync(extracted, target);
234
+ fs.chmodSync(target, 0o755);
235
+ info(`installed ${target} (sha256 ${actual})`);
236
+ } catch (err) {
237
+ warn(`could not download the bugsee-cli binary: ${err.message}`);
238
+ warn(
239
+ `\`bugsee-cli\` will report this if it is invoked. Re-run without ` +
240
+ `--no-optional, or set BUGSEE_CLI_BASE_URL to an internal mirror of ` +
241
+ `the release assets.`,
242
+ );
243
+ } finally {
244
+ fs.rmSync(tmp, { recursive: true, force: true });
245
+ }
246
+ }
247
+
248
+ // The expected-failure paths are already handled inside main(); this catch is
249
+ // for the unexpected ones. The process-level handlers registered at the top of
250
+ // this file cover anything that escapes even this.
251
+ main().catch((err) => {
252
+ warn(`postinstall failed: ${describe(err)}`);
253
+ });