zipy-mobile-cli 1.0.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/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # zipy-mobile-cli
2
+
3
+ CLI to integrate [Zipy](https://zipy.ai) with mobile apps. When you build your app (e.g. React Native Android), it uploads sourcemaps so Zipy can symbolicate errors.
4
+
5
+ ## Install
6
+
7
+ **Recommended (for Gradle apply-from, like Sentry):** add as a devDependency in your React Native project so `require.resolve('zipy-mobile-cli/package.json')` finds it when Gradle runs:
8
+
9
+ ```bash
10
+ npm install zipy-mobile-cli --save-dev
11
+ ```
12
+
13
+ Then run init with `npx zipy-mobile-cli -i react-native`.
14
+
15
+ **Alternatively**, install globally:
16
+
17
+ ```bash
18
+ npm install -g zipy-mobile-cli
19
+ ```
20
+
21
+ If you install globally, the init still adds the same `apply from: ... zipy.gradle` line; Gradle will resolve the package from `node_modules` when building, so you should also have `zipy-mobile-cli` as a devDependency for the apply to work.
22
+
23
+ ## Usage
24
+
25
+ ### 1. Initialize in your React Native project
26
+
27
+ From your **React Native project root** (where `package.json` and `android/` live):
28
+
29
+ ```bash
30
+ zipy-mobile-cli -i react-native
31
+ ```
32
+
33
+ or:
34
+
35
+ ```bash
36
+ zipy-mobile-cli init react-native
37
+ ```
38
+
39
+ This will:
40
+
41
+ - **Ask for the path to your sourcemaps** (with sensible defaults):
42
+ - Release: `android/app/build/generated/sourcemaps/react/release/index.android.bundle.map`
43
+ - Debug: `android/app/build/generated/sourcemaps/react/debug/index.android.bundle.map`
44
+ - Create a config file **`.zipy-mobile.json`** in the project root with those paths.
45
+ - **Add an `apply from:` line to `android/app/build.gradle`** (Sentry-style) that applies `zipy.gradle`. That script hooks into **`react/release`** (the task that bundles JS and generates the source map). When that task finishes, it runs `npx zipy-mobile-cli upload-sourcemaps release`; the paths in `.zipy-mobile.json` match this task’s outputs. Debug is not hooked.
46
+
47
+ So whenever a new build is made, sourcemaps are uploaded automatically; you don’t need to run a separate command.
48
+
49
+ ### 2. Configure upload (from init)
50
+
51
+ Init prompts for (or use env): **Upload API base URL**, **API key**, **Customer ID**, **Framework**. These are stored in `.zipy-mobile.json` and used to call:
52
+
53
+ `POST {uploadBaseUrl}/v1/upload/{apiKey}/{customerId}` with multipart form: `bundle_id`, `release_version`, `framework`, `platform`, `files`. When the upload runs from `gradlew assembleRelease`, `platform` is set to `android` and `bundle_id`/`release_version` come from the build.
54
+
55
+ ### 3. Build as usual
56
+
57
+ From project root:
58
+
59
+ ```bash
60
+ cd android && ./gradlew assembleRelease
61
+ ```
62
+
63
+ or:
64
+
65
+ ```bash
66
+ ./gradlew -p android assembleRelease
67
+ ```
68
+
69
+ After the build finishes, the CLI will upload the sourcemap for that variant.
70
+
71
+ ### 4. Zipy bundle id (release builds)
72
+
73
+ Each release build generates a **new Zipy bundle id** (UUID) when the **source map** is new or changed, injects it into the release (and packager) source map files under `zipyBundleId` / `zipy_bundle_id`, then uploads. No extra folder or assets are created; the ID lives only in the map files and in the upload payload.
74
+
75
+ - **When it runs:** The inject task uses the source map as input, so it re-runs whenever the bundle produces a new or changed map (e.g. after code changes). You get a new ID for each such build.
76
+ - **BuildConfig.ZIPY_BUNDLE_ID** is still set to the application ID for use in the app.
77
+
78
+ ## Config file (`.zipy-mobile.json`)
79
+
80
+ Example (created by init; API key and customer ID are set at init):
81
+
82
+ ```json
83
+ {
84
+ "projectRoot": "/path/to/your/rn-app",
85
+ "framework": "react-native",
86
+ "uploadBaseUrl": "http://localhost:8900",
87
+ "apiKey": "abc123key",
88
+ "customerId": "99",
89
+ "sourcemaps": {
90
+ "android": {
91
+ "release": "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map",
92
+ "debug": "android/app/build/generated/sourcemaps/react/debug/index.android.bundle.map"
93
+ }
94
+ },
95
+ "bundle": {
96
+ "android": {
97
+ "release": "android/app/build/generated/assets/react/release/index.android.bundle"
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ Upload is sent as: `POST {uploadBaseUrl}/sourcemaps-mobile-service//v1/upload/{apiKey}/{customerId}` with form fields `bundle_id` (app id from build), `release_version` (version name), `framework`, `platform` (android when from Gradle), and `files` (sourcemap + bundle).
104
+
105
+ ## Manual upload
106
+
107
+ You can also trigger upload manually (e.g. for CI):
108
+
109
+ ```bash
110
+ zipy-mobile-cli upload-sourcemaps release
111
+ zipy-mobile-cli upload-sourcemaps debug
112
+ ```
113
+
114
+ Run this from the project root (or any directory under it that has `.zipy-mobile.json` above it).
115
+
116
+ ## Requirements
117
+
118
+ - Node.js >= 14
119
+ - React Native project with Android (`android/app/build.gradle`).
120
+ - `zipy-mobile-cli` either as a project devDependency or installed globally (`npm install -g zipy-mobile-cli`). Gradle resolves `zipy.gradle` from node_modules first, then from global npm root.
121
+
122
+ ## Command to add in your RN app (manual)
123
+
124
+ If you prefer to add the Zipy Gradle integration by hand, add this at the end of **`android/app/build.gradle`** (after the `android` and `dependencies` blocks):
125
+
126
+ ```groovy
127
+ def zipyProjectRoot = project.rootProject.projectDir.parentFile
128
+ def zipyGradleFile = null
129
+ try {
130
+ def zipyPackageJson = ["node", "--print", "require.resolve('zipy-mobile-cli/package.json')"].execute(null, zipyProjectRoot).text.trim()
131
+ if (zipyPackageJson) {
132
+ def dir = new File(zipyPackageJson).parentFile
133
+ if (dir != null) zipyGradleFile = new File(dir, "zipy.gradle")
134
+ }
135
+ } catch (Throwable ignored) {}
136
+ if (zipyGradleFile == null || !zipyGradleFile.exists()) {
137
+ def npmGlobalRoot = ["npm", "root", "-g"].execute(null, zipyProjectRoot).text.trim()
138
+ if (npmGlobalRoot) zipyGradleFile = new File(npmGlobalRoot, "zipy-mobile-cli/zipy.gradle")
139
+ }
140
+ if (zipyGradleFile != null && zipyGradleFile.exists()) {
141
+ apply from: zipyGradleFile
142
+ }
143
+ ```
144
+
145
+ This resolves `zipy.gradle` from **project node_modules** (if `zipy-mobile-cli` is a devDependency) or from **global install** (`npm root -g` + `zipy-mobile-cli`). It then applies `zipy.gradle`, which:
146
+
147
+ - Hooks into release bundle tasks (`createBundle*JsAndAssets`, non-debug)
148
+ - For each such task: registers **ZipyUpload** (runs `npx zipy-mobile-cli upload-sourcemaps release` with `ZIPY_RELEASE`, `ZIPY_DIST`, `ZIPY_BUILD_ID` from the build) and **ZipyCleanup**
149
+ - Chain: bundle task → ZipyUpload → ZipyCleanup
150
+
151
+ Set `ZIPY_DISABLE_AUTO_UPLOAD=true` in the environment to skip upload (e.g. for local builds).
152
+
153
+ ## Uninstall / remove integration
154
+
155
+ 1. Remove the `apply from: ... zipy.gradle` line from `android/app/build.gradle` (search for `zipy-mobile-cli`).
156
+ 2. Delete `.zipy-mobile.json`.
157
+ 3. Optionally: `npm uninstall zipy-mobile-cli` (or `npm uninstall -g zipy-mobile-cli` if you installed globally).
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Global entry point for zipy-mobile-cli.
5
+ * Forwards to the main CLI so the package can be installed globally (npm i -g zipy-mobile-cli).
6
+ */
7
+ require('../dist/cli').run(process.argv.slice(2));
package/dist/cli.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function run(argv: string[]): void;
2
+ /**
3
+ * Find directory containing .zipy-mobile.json (walk up from cwd).
4
+ */
5
+ export declare function findProjectRoot(cwd: string, configName: string): string | null;
package/dist/cli.js ADDED
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.run = run;
37
+ exports.findProjectRoot = findProjectRoot;
38
+ /**
39
+ * Main CLI router for zipy-mobile-cli.
40
+ * Supports: zipy-mobile-cli -i react-native | init react-native | upload-sourcemaps <variant>
41
+ */
42
+ const fs = __importStar(require("fs"));
43
+ const path = __importStar(require("path"));
44
+ const init_react_native_1 = require("./init-react-native");
45
+ const upload_sourcemaps_1 = require("./upload-sourcemaps");
46
+ const types_1 = require("./types");
47
+ function run(argv) {
48
+ const args = argv.slice();
49
+ let command = null;
50
+ let platform = null;
51
+ // Parse -i / --init (e.g. zipy-mobile-cli -i react-native)
52
+ const initIndex = args.findIndex((a) => a === '-i' || a === '--init');
53
+ if (initIndex !== -1) {
54
+ command = 'init';
55
+ platform = args[initIndex + 1] ?? null;
56
+ }
57
+ // Parse init <platform> (e.g. zipy-mobile-cli init react-native)
58
+ if (!command && (args[0] === 'init' || args[0] === 'initialize')) {
59
+ command = 'init';
60
+ platform = args[1] ?? null;
61
+ }
62
+ // Internal command used by Gradle after assembleRelease/assembleDebug
63
+ if (args[0] === 'upload-sourcemaps') {
64
+ command = 'upload-sourcemaps';
65
+ platform = null;
66
+ }
67
+ if (command === 'init') {
68
+ if (platform !== 'react-native') {
69
+ console.error('zipy-mobile-cli: Only "react-native" is supported. Use: zipy-mobile-cli -i react-native');
70
+ process.exit(1);
71
+ }
72
+ const initOptions = parseInitOptions(args);
73
+ (0, init_react_native_1.initReactNative)(process.cwd(), initOptions).then(() => process.exit(0), (err) => {
74
+ console.error('zipy-mobile-cli:', err.message || err);
75
+ process.exit(1);
76
+ });
77
+ return;
78
+ }
79
+ if (command === 'upload-sourcemaps') {
80
+ const variant = args[1]; // release | debug
81
+ if (!variant || !['release', 'debug'].includes(variant)) {
82
+ console.error('zipy-mobile-cli: Usage: zipy-mobile-cli upload-sourcemaps <release|debug>');
83
+ process.exit(1);
84
+ }
85
+ const projectRoot = findProjectRoot(process.cwd(), types_1.CONFIG_FILENAME);
86
+ if (!projectRoot) {
87
+ console.error('zipy-mobile-cli: No .zipy-mobile.json found. Run zipy-mobile-cli -i react-native first.');
88
+ process.exit(1);
89
+ }
90
+ (0, upload_sourcemaps_1.uploadSourcemaps)(projectRoot, variant).then(() => process.exit(0), (err) => {
91
+ console.error('zipy-mobile-cli:', err.message || err);
92
+ process.exit(1);
93
+ });
94
+ return;
95
+ }
96
+ // No recognized command → show help
97
+ printHelp();
98
+ process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);
99
+ }
100
+ /** Parse --apikey, --customerId, --authkey from argv for init command. */
101
+ function parseInitOptions(args) {
102
+ const options = {};
103
+ const getVal = (name) => {
104
+ const i = args.findIndex((a) => a === name || a.startsWith(name + '='));
105
+ if (i === -1)
106
+ return undefined;
107
+ const arg = args[i];
108
+ if (arg == null)
109
+ return undefined;
110
+ if (arg.includes('='))
111
+ return arg.split('=')[1]?.trim();
112
+ return args[i + 1];
113
+ };
114
+ const apiKey = getVal('--apikey');
115
+ const customerId = getVal('--customerId');
116
+ const authKey = getVal('--authkey');
117
+ if (apiKey)
118
+ options.apiKey = apiKey;
119
+ if (customerId)
120
+ options.customerId = customerId;
121
+ if (authKey)
122
+ options.authKey = authKey;
123
+ return options;
124
+ }
125
+ /**
126
+ * Find directory containing .zipy-mobile.json (walk up from cwd).
127
+ */
128
+ function findProjectRoot(cwd, configName) {
129
+ let dir = path.resolve(cwd);
130
+ const root = path.parse(dir).root;
131
+ while (dir !== root) {
132
+ const configPath = path.join(dir, configName);
133
+ try {
134
+ fs.accessSync(configPath);
135
+ return dir;
136
+ }
137
+ catch {
138
+ dir = path.dirname(dir);
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ function printHelp() {
144
+ console.log(`
145
+ zipy-mobile-cli — Zipy mobile integration (sourcemap uploads on build)
146
+
147
+ Usage:
148
+ zipy-mobile-cli -i react-native [--apikey KEY] [--customerId ID] [--authkey KEY]
149
+ zipy-mobile-cli init react-native [--apikey KEY] [--customerId ID] [--authkey KEY]
150
+
151
+ If --apikey and --customerId are provided, init will not ask for them.
152
+ Only sourcemap/bundle paths are prompted (or use defaults).
153
+
154
+ Options:
155
+ --apikey KEY API key (skips prompt if set)
156
+ --customerId ID Customer ID (skips prompt if set)
157
+ --authkey KEY Auth key, sent in upload payload (skips prompt if set)
158
+ -h, --help Show this help
159
+ `);
160
+ }
@@ -0,0 +1,32 @@
1
+ /** Default paths: outputs of react/release (same task Sentry hooks into). */
2
+ export declare const DEFAULT_SOURCEMAP_PATHS: {
3
+ readonly release: "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map";
4
+ readonly debug: "android/app/build/generated/sourcemaps/react/debug/index.android.bundle.map";
5
+ };
6
+ /** Default release bundle path: react/release output. */
7
+ export declare const DEFAULT_BUNDLE_PATH_RELEASE = "android/app/build/generated/assets/react/release/index.android.bundle";
8
+ /** Fixed upload API base URL (not prompted). */
9
+ export declare const FIXED_UPLOAD_BASE_URL = "https://app.zipy.ai/api";
10
+ export interface InitOptions {
11
+ sourcemapPathRelease?: string;
12
+ sourcemapPathDebug?: string;
13
+ bundlePathRelease?: string;
14
+ apiKey?: string;
15
+ customerId?: string;
16
+ authKey?: string;
17
+ }
18
+ /**
19
+ * Create .zipy-mobile.json with API config and sourcemap/bundle paths. Uses fixed upload URL and framework=react-native.
20
+ */
21
+ export declare function writeConfig(projectRoot: string, options: InitOptions): string;
22
+ /**
23
+ * Add apply from: zipy.gradle to android/app/build.gradle.
24
+ * Resolves zipy.gradle from global npm (npm root -g), with Windows fallback; falls back to projectDir/zipy.gradle.
25
+ */
26
+ export declare function patchGradle(projectRoot: string): void;
27
+ /**
28
+ * Add a Run Script Build Phase to the iOS app target that runs the Zipy iOS upload script on every build/run.
29
+ * Same role as zipy.gradle on Android. Patches ios/*.xcodeproj/project.pbxproj.
30
+ */
31
+ export declare function patchIosProject(projectRoot: string): boolean;
32
+ export declare function initReactNative(projectRoot: string, options?: InitOptions): Promise<void>;
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FIXED_UPLOAD_BASE_URL = exports.DEFAULT_BUNDLE_PATH_RELEASE = exports.DEFAULT_SOURCEMAP_PATHS = void 0;
37
+ exports.writeConfig = writeConfig;
38
+ exports.patchGradle = patchGradle;
39
+ exports.patchIosProject = patchIosProject;
40
+ exports.initReactNative = initReactNative;
41
+ /**
42
+ * Initialize a React Native project for Zipy: create config with sourcemap paths
43
+ * and patch Android Gradle to run sourcemap upload after assembleRelease/assembleDebug.
44
+ */
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const readline = __importStar(require("readline"));
48
+ const types_1 = require("./types");
49
+ const ANDROID_APP_BUILD_GRADLE = 'android/app/build.gradle';
50
+ const IOS_XCODEPROJ_REL = 'ios';
51
+ /** Unique string in the Zipy Gradle block; used to avoid applying twice. */
52
+ const GRADLE_PATCH_MARKER = 'zipy-mobile-cli/zipy.gradle';
53
+ /** Unique string in the iOS Run Script phase; used to avoid adding twice. */
54
+ const IOS_ZIPY_PHASE_MARKER = 'ZipySourcemapsUpload';
55
+ /** Default paths: outputs of react/release (same task Sentry hooks into). */
56
+ exports.DEFAULT_SOURCEMAP_PATHS = {
57
+ release: 'android/app/build/generated/sourcemaps/react/release/index.android.bundle.map',
58
+ debug: 'android/app/build/generated/sourcemaps/react/debug/index.android.bundle.map',
59
+ };
60
+ /** Default release bundle path: react/release output. */
61
+ exports.DEFAULT_BUNDLE_PATH_RELEASE = 'android/app/build/generated/assets/react/release/index.android.bundle';
62
+ /** Fixed upload API base URL (not prompted). */
63
+ exports.FIXED_UPLOAD_BASE_URL = 'https://app.zipy.ai/api';
64
+ function prompt(question, defaultAnswer) {
65
+ return new Promise((resolve) => {
66
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
67
+ const def = defaultAnswer ? ` (${defaultAnswer})` : '';
68
+ rl.question(`${question}${def}: `, (answer) => {
69
+ rl.close();
70
+ resolve(typeof answer === 'string' && answer.trim() !== '' ? answer.trim() : defaultAnswer);
71
+ });
72
+ });
73
+ }
74
+ /** Check if stdin is TTY so we can prompt; otherwise use defaults or env. */
75
+ function isInteractive() {
76
+ return process.stdin.isTTY === true;
77
+ }
78
+ const CONFIG_COMMENT = 'To change sourcemap or bundle paths: edit sourcemaps.android.release, sourcemaps.android.debug, and bundle.android.release (paths are relative to project root).';
79
+ /**
80
+ * Create .zipy-mobile.json with API config and sourcemap/bundle paths. Uses fixed upload URL and framework=react-native.
81
+ */
82
+ function writeConfig(projectRoot, options) {
83
+ const configPath = path.join(projectRoot, types_1.CONFIG_FILENAME);
84
+ const config = {
85
+ _comment: CONFIG_COMMENT,
86
+ projectRoot,
87
+ framework: 'react-native',
88
+ uploadBaseUrl: exports.FIXED_UPLOAD_BASE_URL,
89
+ apiKey: options.apiKey ?? process.env.ZIPY_API_KEY ?? '',
90
+ customerId: options.customerId ?? process.env.ZIPY_CUSTOMER_ID ?? '',
91
+ authKey: options.authKey ?? process.env.ZIPY_AUTH_KEY ?? '',
92
+ sourcemaps: {
93
+ android: {
94
+ release: options.sourcemapPathRelease ?? exports.DEFAULT_SOURCEMAP_PATHS.release,
95
+ debug: options.sourcemapPathDebug ?? exports.DEFAULT_SOURCEMAP_PATHS.debug,
96
+ },
97
+ },
98
+ bundle: {
99
+ android: {
100
+ release: options.bundlePathRelease ?? exports.DEFAULT_BUNDLE_PATH_RELEASE,
101
+ },
102
+ },
103
+ };
104
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
105
+ return configPath;
106
+ }
107
+ /**
108
+ * Add apply from: zipy.gradle to android/app/build.gradle.
109
+ * Resolves zipy.gradle from global npm (npm root -g), with Windows fallback; falls back to projectDir/zipy.gradle.
110
+ */
111
+ function patchGradle(projectRoot) {
112
+ const buildGradlePath = path.join(projectRoot, ANDROID_APP_BUILD_GRADLE);
113
+ if (!fs.existsSync(buildGradlePath)) {
114
+ throw new Error(`Android app build.gradle not found at ${ANDROID_APP_BUILD_GRADLE}. Is this a React Native project?`);
115
+ }
116
+ const content = fs.readFileSync(buildGradlePath, 'utf8');
117
+ if (content.includes(GRADLE_PATCH_MARKER)) {
118
+ return; // Already patched
119
+ }
120
+ const applyBlock = `
121
+ // Apply Zipy Gradle script from current project node_modules (requires zipy-mobile-cli in package.json).
122
+ apply from: new File(project.rootProject.projectDir.parentFile, "node_modules/zipy-mobile-cli/zipy.gradle")
123
+ `;
124
+ fs.appendFileSync(buildGradlePath, applyBlock, 'utf8');
125
+ }
126
+ /**
127
+ * Add a Run Script Build Phase to the iOS app target that runs the Zipy iOS upload script on every build/run.
128
+ * Same role as zipy.gradle on Android. Patches ios/*.xcodeproj/project.pbxproj.
129
+ */
130
+ function patchIosProject(projectRoot) {
131
+ const iosDir = path.join(projectRoot, IOS_XCODEPROJ_REL);
132
+ if (!fs.existsSync(iosDir))
133
+ return false;
134
+ const xcodeprojDir = fs.readdirSync(iosDir).find((f) => f.endsWith('.xcodeproj'));
135
+ if (!xcodeprojDir)
136
+ return false;
137
+ const pbxPath = path.join(iosDir, xcodeprojDir, 'project.pbxproj');
138
+ if (!fs.existsSync(pbxPath))
139
+ return false;
140
+ let content = fs.readFileSync(pbxPath, 'utf8');
141
+ if (content.includes(IOS_ZIPY_PHASE_MARKER))
142
+ return true; // Already patched
143
+ // 24-char hex ID (Xcode style)
144
+ const phaseId = Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('').toUpperCase();
145
+ // Script: run zipy-ios-upload.sh from project root (SRCROOT = ios/)
146
+ const scriptBody = 'cd "${SRCROOT}/.." && [ -f node_modules/zipy-mobile-cli/zipy-ios-upload.sh ] && sh node_modules/zipy-mobile-cli/zipy-ios-upload.sh || true';
147
+ const escapedScript = scriptBody.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
148
+ const phaseBlock = `
149
+ ${phaseId} /* ZipySourcemapsUpload */ = {
150
+ isa = PBXShellScriptBuildPhase;
151
+ buildActionMask = 2147483647;
152
+ files = (
153
+ );
154
+ inputPaths = (
155
+ );
156
+ name = "ZipySourcemapsUpload";
157
+ outputPaths = (
158
+ );
159
+ runOnlyForDeploymentPostprocessing = 0;
160
+ shellPath = /bin/sh;
161
+ shellScript = "${escapedScript}\\n";
162
+ };
163
+ `;
164
+ // Insert phase into PBXShellScriptBuildPhase section
165
+ const shellSectionEnd = '/* End PBXShellScriptBuildPhase section */';
166
+ const shellSectionBegin = '/* Begin PBXShellScriptBuildPhase section */';
167
+ if (content.includes(shellSectionEnd)) {
168
+ content = content.replace(shellSectionEnd, phaseBlock + '\t' + shellSectionEnd);
169
+ }
170
+ else if (content.includes(shellSectionBegin)) {
171
+ content = content.replace(shellSectionBegin, shellSectionBegin + phaseBlock);
172
+ }
173
+ else {
174
+ // No section yet: add before End PBXProject section
175
+ const endProject = '/* End PBXProject section */';
176
+ const newSection = `\n\t/* Begin PBXShellScriptBuildPhase section */\n${phaseBlock}\t/* End PBXShellScriptBuildPhase section */\n`;
177
+ content = content.replace(endProject, newSection + endProject);
178
+ }
179
+ // Add our phase ID to the first target's buildPhases (main app target; first buildPhases = ( in file)
180
+ const buildPhasesRegex = /(buildPhases = \(\s*\n)(\s+)([A-Fa-f0-9]{24})(.*)/;
181
+ const buildPhasesMatch = content.match(buildPhasesRegex);
182
+ if (!buildPhasesMatch)
183
+ return false;
184
+ const indent = buildPhasesMatch[2];
185
+ content = content.replace(buildPhasesRegex, `$1${indent}${phaseId} /* ZipySourcemapsUpload */,\n${indent}$3$4`);
186
+ fs.writeFileSync(pbxPath, content, 'utf8');
187
+ return true;
188
+ }
189
+ /** Resolve project root: directory that contains android/ and package.json (React Native). */
190
+ function ensureReactNativeProject(projectRoot) {
191
+ const androidDir = path.join(projectRoot, 'android');
192
+ const packageJson = path.join(projectRoot, 'package.json');
193
+ if (!fs.existsSync(androidDir) || !fs.existsSync(packageJson)) {
194
+ throw new Error('Current directory does not look like a React Native project (missing android/ or package.json). Run this from the project root.');
195
+ }
196
+ }
197
+ async function initReactNative(projectRoot, options) {
198
+ projectRoot = path.resolve(projectRoot);
199
+ ensureReactNativeProject(projectRoot);
200
+ let sourcemapPathRelease = exports.DEFAULT_SOURCEMAP_PATHS.release;
201
+ let sourcemapPathDebug = exports.DEFAULT_SOURCEMAP_PATHS.debug;
202
+ let bundlePathRelease = exports.DEFAULT_BUNDLE_PATH_RELEASE;
203
+ let apiKey = options?.apiKey ?? process.env.ZIPY_API_KEY ?? '';
204
+ let customerId = options?.customerId ?? process.env.ZIPY_CUSTOMER_ID ?? '';
205
+ let authKey = options?.authKey ?? process.env.ZIPY_AUTH_KEY ?? '';
206
+ if (isInteractive()) {
207
+ console.log('Zipy Mobile CLI — React Native setup\n');
208
+ if (!apiKey)
209
+ apiKey = await prompt('API key (e.g. abc123key)', '');
210
+ if (!customerId)
211
+ customerId = await prompt('Customer ID (e.g. 99)', '');
212
+ if (!authKey)
213
+ authKey = await prompt('Auth key (optional)', '');
214
+ console.log('\nDefault paths (relative to project root):');
215
+ console.log(' sourcemaps.android.release:', sourcemapPathRelease);
216
+ console.log(' sourcemaps.android.debug: ', sourcemapPathDebug);
217
+ console.log(' bundle.android.release: ', bundlePathRelease);
218
+ const useDefaults = await prompt('Use these paths? (Y/n)', 'Y');
219
+ if (useDefaults && useDefaults.toLowerCase() === 'n') {
220
+ sourcemapPathRelease = await prompt('Path to release sourcemap', sourcemapPathRelease);
221
+ sourcemapPathDebug = await prompt('Path to debug sourcemap', sourcemapPathDebug);
222
+ bundlePathRelease = await prompt('Path to release bundle', bundlePathRelease);
223
+ }
224
+ }
225
+ else {
226
+ sourcemapPathRelease = process.env.ZIPY_SOURCEMAP_RELEASE ?? sourcemapPathRelease;
227
+ sourcemapPathDebug = process.env.ZIPY_SOURCEMAP_DEBUG ?? sourcemapPathDebug;
228
+ bundlePathRelease = process.env.ZIPY_BUNDLE_RELEASE ?? bundlePathRelease;
229
+ }
230
+ const configPath = writeConfig(projectRoot, {
231
+ sourcemapPathRelease,
232
+ sourcemapPathDebug,
233
+ bundlePathRelease,
234
+ apiKey,
235
+ customerId,
236
+ authKey,
237
+ });
238
+ console.log('Created config:', configPath);
239
+ patchGradle(projectRoot);
240
+ console.log('Patched', ANDROID_APP_BUILD_GRADLE, '— sourcemaps will upload after assembleRelease (debug not hooked).');
241
+ const iosPatched = patchIosProject(projectRoot);
242
+ if (iosPatched) {
243
+ console.log('Patched iOS project — added "ZipySourcemapsUpload" Run Script phase (runs on every build/run).');
244
+ }
245
+ console.log('\nDone. Next: run ./gradlew assembleRelease from android/ (or from root with -p android); for iOS, build/run from Xcode or react-native run-ios.');
246
+ }
@@ -0,0 +1,34 @@
1
+ /** Config file name in project root. */
2
+ export declare const CONFIG_FILENAME = ".zipy-mobile.json";
3
+ /**
4
+ * Config file (.zipy-mobile.json) shape used by init and upload-sourcemaps.
5
+ * Upload API: POST {uploadBaseUrl}/sourcemaps-mobile-service/v1/upload/{apiKey}/{customerId} (multipart form).
6
+ */
7
+ export interface ZipyConfig {
8
+ projectRoot?: string;
9
+ /** Framework name sent as form field (e.g. react-native, flutter). */
10
+ framework: string;
11
+ /** Base URL for upload API (e.g. http://localhost:8900). */
12
+ uploadBaseUrl: string;
13
+ /** API key; used in URL path. */
14
+ apiKey: string;
15
+ /** Customer ID; used in URL path. */
16
+ customerId: string;
17
+ /** Optional auth key; sent in upload payload as form field "key". */
18
+ authKey?: string;
19
+ sourcemaps: {
20
+ android: {
21
+ release: string;
22
+ debug: string;
23
+ };
24
+ };
25
+ bundle?: {
26
+ android: {
27
+ release: string;
28
+ debug?: string;
29
+ };
30
+ };
31
+ /** Ignored at runtime; explains how to edit paths (JSON does not support comments). */
32
+ _comment?: string;
33
+ }
34
+ export type BuildVariant = 'release' | 'debug';
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONFIG_FILENAME = void 0;
4
+ /** Config file name in project root. */
5
+ exports.CONFIG_FILENAME = '.zipy-mobile.json';
@@ -0,0 +1,5 @@
1
+ import type { ZipyConfig, BuildVariant } from './types';
2
+ export declare function loadConfig(projectRoot: string): ZipyConfig;
3
+ export declare function getSourcemapPath(projectRoot: string, variant: BuildVariant, config: ZipyConfig): string;
4
+ export declare function getBundlePath(projectRoot: string, variant: BuildVariant, config: ZipyConfig): string | null;
5
+ export declare function uploadSourcemaps(projectRoot: string, variant: BuildVariant): Promise<void>;
@@ -0,0 +1,284 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadConfig = loadConfig;
37
+ exports.getSourcemapPath = getSourcemapPath;
38
+ exports.getBundlePath = getBundlePath;
39
+ exports.uploadSourcemaps = uploadSourcemaps;
40
+ /**
41
+ * Upload sourcemaps (and bundle) via multipart form to the Zipy upload API.
42
+ * POST {uploadBaseUrl}/sourcemaps-mobile-service/v1/upload/{apiKey}/{customerId}
43
+ * Form: key (auth), bundle_id, release_version, framework, platform, files (sourcemap + optional bundle).
44
+ */
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const https = __importStar(require("https"));
48
+ const http = __importStar(require("http"));
49
+ const types_1 = require("./types");
50
+ /** Upload path (no leading slash on uploadBaseUrl). Single source of truth for request and logs. */
51
+ const UPLOAD_PATH = '/sourcemaps-mobile-service/v1/upload';
52
+ function loadConfig(projectRoot) {
53
+ const configPath = path.join(projectRoot, types_1.CONFIG_FILENAME);
54
+ if (!fs.existsSync(configPath)) {
55
+ throw new Error(`Config not found: ${configPath}. Run zipy-mobile-cli -i react-native first.`);
56
+ }
57
+ const raw = fs.readFileSync(configPath, 'utf8');
58
+ try {
59
+ const parsedValue = JSON.parse(raw);
60
+ if (parsedValue == null || typeof parsedValue !== 'object') {
61
+ throw new Error(`${types_1.CONFIG_FILENAME} must contain a JSON object`);
62
+ }
63
+ const parsed = parsedValue;
64
+ if (!parsed.uploadBaseUrl && parsed.uploadUrl) {
65
+ parsed.uploadBaseUrl = parsed.uploadUrl;
66
+ }
67
+ return parsed;
68
+ }
69
+ catch (e) {
70
+ const message = e instanceof Error ? e.message : String(e);
71
+ throw new Error(`Invalid ${types_1.CONFIG_FILENAME}: ${message}`);
72
+ }
73
+ }
74
+ function getSourcemapPath(projectRoot, variant, config) {
75
+ const relativePath = config.sourcemaps?.android?.[variant];
76
+ if (!relativePath) {
77
+ throw new Error(`No sourcemap path configured for android variant "${variant}". Check .zipy-mobile.json "sourcemaps.android".`);
78
+ }
79
+ const absolutePath = path.resolve(projectRoot, relativePath);
80
+ if (!fs.existsSync(absolutePath)) {
81
+ throw new Error(`Sourcemap file not found: ${absolutePath}. Build may not have generated it yet.`);
82
+ }
83
+ return absolutePath;
84
+ }
85
+ function getBundlePath(projectRoot, variant, config) {
86
+ const relativePath = config.bundle?.android?.[variant];
87
+ if (!relativePath)
88
+ return null;
89
+ const absolutePath = path.resolve(projectRoot, relativePath);
90
+ if (!fs.existsSync(absolutePath))
91
+ return null;
92
+ return absolutePath;
93
+ }
94
+ /**
95
+ * Extract Bundle ID and Zipy bundle id from source map JSON.
96
+ * Zipy bundle id is our per-build UUID, stored under zipyBundleId/zipy_bundle_id (and legacy debugId keys)
97
+ * so we can reliably correlate uploads even when the app bundle id stays the same.
98
+ */
99
+ function extractIdsFromSourcemap(sourcemapPath) {
100
+ const out = {};
101
+ try {
102
+ const raw = fs.readFileSync(sourcemapPath, 'utf8');
103
+ const parsedValue = JSON.parse(raw);
104
+ if (parsedValue == null || typeof parsedValue !== 'object') {
105
+ return out;
106
+ }
107
+ const map = parsedValue;
108
+ const meta = (map.x_metadata ?? map.metadata);
109
+ if (meta && typeof meta === 'object') {
110
+ const bid = meta.bundleId ?? meta.bundle_id;
111
+ // Prefer new Zipy bundle id keys, but still read legacy debugId keys for older builds.
112
+ const did = meta.zipyBundleId ??
113
+ meta.zipy_bundle_id ??
114
+ meta.zipyDebugId ??
115
+ meta.zipy_debug_id ??
116
+ meta.debugId ??
117
+ meta.debug_id;
118
+ if (typeof bid === 'string')
119
+ out.bundleId = bid;
120
+ if (typeof did === 'string')
121
+ out.debugId = did;
122
+ }
123
+ const m = map;
124
+ if (!out.debugId) {
125
+ out.debugId =
126
+ m.zipyBundleId ??
127
+ m.zipy_bundle_id ??
128
+ m.zipyDebugId ??
129
+ m.zipy_debug_id ??
130
+ m.debugId;
131
+ }
132
+ if (!out.bundleId && typeof m.bundleId === 'string')
133
+ out.bundleId = m.bundleId;
134
+ }
135
+ catch (_) {
136
+ /* ignore */
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * Packager source map path (Metro writes this first; it contains the Debug ID before Hermes/Sentry compose).
142
+ * Derived from the final map path: generated/sourcemaps -> intermediates/sourcemaps, and .map -> .packager.map
143
+ */
144
+ function getPackagerSourcemapPath(finalSourcemapPath) {
145
+ const normalized = finalSourcemapPath.replace(/\\/g, '/');
146
+ if (normalized.includes('/generated/sourcemaps/')) {
147
+ return normalized
148
+ .replace('/generated/sourcemaps/', '/intermediates/sourcemaps/')
149
+ .replace(/\.map$/, '.packager.map');
150
+ }
151
+ const dir = path.dirname(finalSourcemapPath);
152
+ const base = path.basename(finalSourcemapPath, '.map');
153
+ return path.join(dir, base + '.packager.map');
154
+ }
155
+ /**
156
+ * Get Zipy bundle id from our own build output: try final source map first, then packager map (Metro writes it there).
157
+ * This is our primary way to discover the Zipy bundle id when Gradle has already injected it.
158
+ */
159
+ function getZipyBundleIdFromBuild(sourcemapPath) {
160
+ let ids = extractIdsFromSourcemap(sourcemapPath);
161
+ if (ids.debugId)
162
+ return ids.debugId;
163
+ const packagerPath = getPackagerSourcemapPath(sourcemapPath);
164
+ if (fs.existsSync(packagerPath)) {
165
+ ids = extractIdsFromSourcemap(packagerPath);
166
+ if (ids.debugId)
167
+ return ids.debugId;
168
+ }
169
+ return undefined;
170
+ }
171
+ /**
172
+ * Build multipart/form-data body for upload API.
173
+ * Matches: -F "key=..." -F "bundle_id=..." -F "release_version=..." -F "framework=..." -F "platform=..." -F "files=@..."
174
+ */
175
+ function buildMultipartBody(fields, filePaths) {
176
+ const boundary = '----ZipyMobile' + Math.random().toString(36).slice(2) + Date.now();
177
+ const crlf = '\r\n';
178
+ const parts = [];
179
+ const append = (s) => parts.push(Buffer.from(s, 'utf8'));
180
+ const appendField = (name, value) => {
181
+ append(`--${boundary}${crlf}`);
182
+ append(`Content-Disposition: form-data; name="${name}"${crlf}${crlf}`);
183
+ append(`${value}${crlf}`);
184
+ };
185
+ if (fields.key)
186
+ appendField('key', fields.key);
187
+ appendField('bundle_id', fields.bundle_id);
188
+ appendField('release_version', fields.release_version);
189
+ appendField('framework', fields.framework);
190
+ appendField('platform', fields.platform);
191
+ for (const f of filePaths) {
192
+ append(`--${boundary}${crlf}`);
193
+ append(`Content-Disposition: form-data; name="${f.fieldName}"; filename="${f.filename}"${crlf}`);
194
+ append(`Content-Type: application/octet-stream${crlf}${crlf}`);
195
+ parts.push(fs.readFileSync(f.path));
196
+ append(crlf);
197
+ }
198
+ append(`--${boundary}--${crlf}`);
199
+ return { body: Buffer.concat(parts), boundary };
200
+ }
201
+ /**
202
+ * POST multipart form to uploadBaseUrl + UPLOAD_PATH + apiKey/customerId.
203
+ */
204
+ function uploadMultipart(uploadBaseUrl, apiKey, customerId, uploadPath, fields, filePaths) {
205
+ return new Promise((resolve, reject) => {
206
+ if (!uploadBaseUrl || !apiKey || !customerId) {
207
+ console.warn('zipy-mobile-cli: uploadBaseUrl, apiKey and customerId are required in .zipy-mobile.json. Skipping upload.');
208
+ return resolve();
209
+ }
210
+ const pathname = `${uploadPath}/${encodeURIComponent(apiKey)}/${encodeURIComponent(customerId)}`;
211
+ const url = new URL(uploadBaseUrl.startsWith('http') ? uploadBaseUrl : 'http://' + uploadBaseUrl);
212
+ const isHttps = url.protocol === 'https:';
213
+ const mod = isHttps ? https : http;
214
+ const portNum = url.port ? parseInt(url.port, 10) : (isHttps ? 443 : 80);
215
+ const { body, boundary } = buildMultipartBody(fields, filePaths);
216
+ const options = {
217
+ hostname: url.hostname,
218
+ port: portNum,
219
+ path: pathname + (url.search || ''),
220
+ method: 'POST',
221
+ headers: {
222
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
223
+ 'Content-Length': String(body.length),
224
+ },
225
+ };
226
+ const req = mod.request(options, (res) => {
227
+ let data = '';
228
+ res.on('data', (ch) => (data += ch));
229
+ res.on('end', () => {
230
+ const status = res.statusCode ?? 0;
231
+ if (status >= 200 && status < 300)
232
+ resolve();
233
+ else
234
+ reject(new Error(`Upload failed: ${status} ${data || res.statusMessage || ''}`));
235
+ });
236
+ });
237
+ req.on('error', reject);
238
+ req.write(body);
239
+ req.end();
240
+ });
241
+ }
242
+ async function uploadSourcemaps(projectRoot, variant) {
243
+ const config = loadConfig(projectRoot);
244
+ const uploadBaseUrl = (config.uploadBaseUrl ?? process.env.ZIPY_UPLOAD_BASE_URL ?? '').replace(/\/$/, '');
245
+ const apiKey = config.apiKey ?? process.env.ZIPY_API_KEY ?? '';
246
+ const customerId = config.customerId ?? process.env.ZIPY_CUSTOMER_ID ?? '';
247
+ const authKey = config.authKey ?? process.env.ZIPY_AUTH_KEY ?? '';
248
+ const framework = config.framework ?? process.env.ZIPY_FRAMEWORK ?? 'react-native';
249
+ const platform = process.env.ZIPY_PLATFORM ?? 'android';
250
+ const releaseName = process.env.ZIPY_RELEASE ?? process.env.ZIPY_RELEASE_VERSION ?? '';
251
+ const sourcemapPath = getSourcemapPath(projectRoot, variant, config);
252
+ const ids = extractIdsFromSourcemap(sourcemapPath);
253
+ const zipyBundleIdFromGradle = process.env.ZIPY_DEBUG_ID;
254
+ // Prefer the value coming from Gradle (sentinel file), then what we can read from the map(s).
255
+ const zipyBundleId = zipyBundleIdFromGradle ?? ids.debugId;
256
+ if (ids.bundleId)
257
+ console.log('> Bundle ID from map (if present):', ids.bundleId);
258
+ if (zipyBundleId)
259
+ console.log('> Zipy bundle id for the build:', zipyBundleId);
260
+ const filePaths = [
261
+ { path: sourcemapPath, fieldName: 'files', filename: path.basename(sourcemapPath) },
262
+ ];
263
+ const bundlePath = getBundlePath(projectRoot, variant, config);
264
+ if (bundlePath)
265
+ filePaths.push({ path: bundlePath, fieldName: 'files', filename: path.basename(bundlePath) });
266
+ // Form fields matching API: key (auth), bundle_id, release_version, framework, platform; then files
267
+ // We send the Zipy bundle id as bundle_id so errors can be grouped per build on the backend.
268
+ // Older builds used debugId naming, but the semantics are the same (per-build UUID), so we just
269
+ // standardize the name here while still reading legacy keys above for compatibility.
270
+ const fields = {
271
+ ...(authKey ? { key: authKey } : {}),
272
+ bundle_id: zipyBundleId ?? process.env.ZIPY_APP_ID ?? process.env.ZIPY_BUNDLE_ID ?? 'unknown',
273
+ release_version: releaseName || '1.0.0',
274
+ framework,
275
+ platform,
276
+ };
277
+ const baseUrl = uploadBaseUrl.replace(/\/$/, '');
278
+ const fullUrl = `${baseUrl}${UPLOAD_PATH}/${encodeURIComponent(apiKey)}/${encodeURIComponent(customerId)}`;
279
+ console.log('zipy-mobile-cli: Request URL:', fullUrl);
280
+ console.log('zipy-mobile-cli: Form fields:', JSON.stringify(fields, null, 2));
281
+ console.log('zipy-mobile-cli: Files:', filePaths.map((f) => ({ path: f.path, filename: f.filename })));
282
+ await uploadMultipart(uploadBaseUrl, apiKey, customerId, UPLOAD_PATH, fields, filePaths);
283
+ console.log('zipy-mobile-cli: Upload done.');
284
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "zipy-mobile-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI to integrate Zipy with mobile apps — uploads sourcemaps when building (e.g. React Native Android).",
5
+ "main": "dist/cli.js",
6
+ "bin": {
7
+ "zipy-mobile-cli": "bin/zipy-mobile-cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "prepare": "npm run build",
12
+ "prepublishOnly": "npm run build",
13
+ "test": "node -e \"require('./dist/cli').run(['--help'])\""
14
+ },
15
+ "keywords": [
16
+ "zipy",
17
+ "sourcemaps",
18
+ "react-native",
19
+ "android",
20
+ "cli"
21
+ ],
22
+ "author": "",
23
+ "license": "ISC",
24
+ "engines": {
25
+ "node": ">=14.0.0"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "dist",
30
+ "zipy.gradle",
31
+ "zipy-ios-upload.sh"
32
+ ],
33
+ "devDependencies": {
34
+ "@types/node": "^20.10.0",
35
+ "typescript": "^5.3.0"
36
+ },
37
+ "dependencies": {
38
+ "undici-types": "^6.21.0"
39
+ },
40
+ "types": "./dist/cli.d.ts"
41
+ }
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ # Zipy iOS upload hook — runs in Xcode Run Script Build Phase on every build/run.
3
+ # Same role as zipy.gradle on Android: run Zipy task after bundle.
4
+ # For now just logs; later can call: cd "$PROJECT_ROOT" && npx zipy-mobile-cli upload-sourcemaps release
5
+ set -e
6
+ echo "[Zipy] iOS upload task ran (ZipySourcemapsUpload)"
7
+ # Optional: skip when env is set (mirror Android ZIPY_DISABLE_AUTO_UPLOAD)
8
+ if [ -n "$ZIPY_DISABLE_AUTO_UPLOAD" ]; then
9
+ echo "[Zipy] Skipped (ZIPY_DISABLE_AUTO_UPLOAD is set)"
10
+ exit 0
11
+ fi
package/zipy.gradle ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Zipy Mobile CLI — Gradle integration (Sentry-style).
3
+ * Flow: Generate bundle id first -> write it into debugid.cjs (before Metro) -> Metro bundles -> inject id into source maps -> upload.
4
+ */
5
+ import org.apache.tools.ant.taskdefs.condition.Os
6
+ import java.util.UUID
7
+
8
+ def zipyProjectRoot = project.rootProject.projectDir.parentFile
9
+
10
+ project.ext.shouldZipyAutoUpload = { ->
11
+ return System.getenv('ZIPY_DISABLE_AUTO_UPLOAD') != 'true'
12
+ }
13
+
14
+ plugins.withId('com.android.application') {
15
+ try {
16
+ project.android.buildFeatures.buildConfig = true
17
+ } catch (Throwable ignored) {}
18
+
19
+ try {
20
+ project.android.applicationVariants.all { variant ->
21
+ if (variant.buildType.name.equalsIgnoreCase("debug")) return
22
+ variant.buildConfigField "String", "ZIPY_BUNDLE_ID", "\"${variant.applicationId}\""
23
+ }
24
+ } catch (Throwable t) {
25
+ logger.warn("Zipy: could not add BuildConfig.ZIPY_BUNDLE_ID: ${t.message}")
26
+ }
27
+
28
+ def androidComponents = extensions.getByName("androidComponents")
29
+ androidComponents.onVariants(androidComponents.selector().all()) { v ->
30
+ if (v.name.toLowerCase().contains("debug")) return
31
+
32
+ def appId = v.applicationId.get()
33
+
34
+ def bundleTasks = tasks.findAll { task ->
35
+ (task.name.startsWith("createBundle") || task.name.startsWith("bundle")) &&
36
+ task.name.endsWith("JsAndAssets") &&
37
+ !task.name.contains("Debug") &&
38
+ task.enabled
39
+ }
40
+ bundleTasks.each { bundleTask ->
41
+ def reactRoot = zipyProjectRoot
42
+ try {
43
+ def props = bundleTask.getProperties()
44
+ if (props.get("workingDir") != null) {
45
+ reactRoot = props.get("workingDir") as File
46
+ } else if (props.get("root") != null) {
47
+ reactRoot = props.get("root").get() as File
48
+ }
49
+ } catch (Throwable ignored) {}
50
+ def _reactRoot = reactRoot
51
+
52
+ def output = v.outputs.isEmpty() ? null : v.outputs.first()
53
+ if (output == null) return
54
+ def versionCode = output.versionCode.getOrElse(0)
55
+ def versionName = output.versionName.getOrElse("1.0")
56
+ def releaseName = "${appId}@${versionName}+${versionCode}"
57
+ def buildId = System.getenv('ZIPY_BUILD_ID') ?: "${releaseName}_${versionCode}"
58
+ def zipyInjectTaskName = "${bundleTask.name}_ZipyInjectDebugId_${releaseName}_${versionCode}"
59
+ def zipyUploadTaskName = "${bundleTask.name}_ZipySourcemapsUpload_${releaseName}_${versionCode}"
60
+ def zipyCleanupTaskName = "${bundleTask.name}_ZipyFullCleanup_${releaseName}_${versionCode}"
61
+
62
+ try { tasks.named(zipyUploadTaskName); return } catch (Exception e) {}
63
+
64
+ def configFile = new File(_reactRoot, ".zipy-mobile.json")
65
+ def releasePath = "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map"
66
+ if (configFile.exists()) {
67
+ try {
68
+ def config = new groovy.json.JsonSlurper().parse(configFile)
69
+ if (config.sourcemaps?.android?.release) releasePath = config.sourcemaps.android.release
70
+ } catch (Throwable ignored) {}
71
+ }
72
+ def mapFile = new File(_reactRoot, releasePath.replace('\\\\', '/'))
73
+ def zipySentinelFile = project.file("${project.buildDir}/zipy_injected_${v.name}.txt")
74
+
75
+ // Generate bundle id first, then write it into debugid.cjs so Metro inlines it when creating the bundle.
76
+ bundleTask.doFirst {
77
+ def zipyBundleId = UUID.randomUUID().toString()
78
+ zipySentinelFile.text = zipyBundleId
79
+ def nodeModulesRoot = _reactRoot
80
+ def debugIdFiles = [
81
+ new File(nodeModulesRoot, "node_modules/zipy-react-native/lib/commonjs/utils/debugid.cjs"),
82
+ new File(nodeModulesRoot, "node_modules/zipyai-react-native/lib/commonjs/utils/debugid.cjs")
83
+ ]
84
+ debugIdFiles.each { File f ->
85
+ if (f.exists()) {
86
+ try {
87
+ def content = "\"use strict\";Object.defineProperty(exports,\"__esModule\",{value:!0}),exports.BUNDLE_ID=void 0;const BUNDLE_ID=exports.BUNDLE_ID=\"${zipyBundleId}\";"
88
+ f.text = content
89
+ logger.lifecycle("Zipy: wrote bundle id into ${f.name} (before Metro)")
90
+ } catch (Throwable e) {
91
+ logger.warn("Zipy: could not write bundle id into ${f.absolutePath}: ${e.message}")
92
+ }
93
+ } else {
94
+ logger.info("Zipy: debugid.cjs not found at ${f.absolutePath}, skipping")
95
+ }
96
+ }
97
+ logger.lifecycle("Zipy: generated bundle id and set in debugid.cjs; Metro will use it when bundling")
98
+ }
99
+
100
+ def zipyInjectTask = tasks.register(zipyInjectTaskName) { t ->
101
+ t.dependsOn(bundleTask)
102
+ t.inputs.file(mapFile).optional(true)
103
+ t.inputs.file(zipySentinelFile).optional(true)
104
+ t.outputs.file(mapFile).optional(true)
105
+ t.doLast {
106
+ if (!zipySentinelFile.exists()) {
107
+ logger.warn("Zipy: sentinel not found (expected from bundle task doFirst)")
108
+ return
109
+ }
110
+ def zipyBundleId = zipySentinelFile.text.trim()
111
+ if (!zipyBundleId) return
112
+ if (!mapFile.exists()) {
113
+ logger.warn("Zipy: source map not found at ${mapFile.absolutePath}, skipping inject")
114
+ return
115
+ }
116
+ try {
117
+ def json = new groovy.json.JsonSlurper().parse(mapFile)
118
+ if (json.x_metadata == null) json.x_metadata = [:]
119
+ // Store Zipy bundle id under Zipy-specific keys and generic bundle keys for compatibility.
120
+ json.x_metadata.zipyBundleId = zipyBundleId
121
+ json.x_metadata.zipy_bundle_id = zipyBundleId
122
+ json.x_metadata.bundleId = zipyBundleId
123
+ json.x_metadata.bundle_id = zipyBundleId
124
+ if (json.metadata == null) json.metadata = [:]
125
+ json.metadata.zipyBundleId = zipyBundleId
126
+ json.metadata.zipy_bundle_id = zipyBundleId
127
+ json.metadata.bundleId = zipyBundleId
128
+ json.metadata.bundle_id = zipyBundleId
129
+ json.zipyBundleId = zipyBundleId
130
+ json.zipy_bundle_id = zipyBundleId
131
+ mapFile.text = groovy.json.JsonOutput.toJson(json)
132
+ logger.lifecycle("Zipy: injected Zipy bundle id into ${mapFile.name}")
133
+ } catch (Throwable e) {
134
+ logger.warn("Zipy: could not inject into source map: ${e.message}")
135
+ }
136
+ def packagerPath = mapFile.absolutePath.replace("/generated/sourcemaps/", "/intermediates/sourcemaps/").replaceAll(/\\.map$/, ".packager.map")
137
+ def packagerFile = new File(packagerPath)
138
+ if (packagerFile.exists()) {
139
+ try {
140
+ def pjson = new groovy.json.JsonSlurper().parse(packagerFile)
141
+ if (pjson.x_metadata == null) pjson.x_metadata = [:]
142
+ // Mirror the same Zipy bundle id into the packager map so both maps stay in sync.
143
+ pjson.x_metadata.zipyBundleId = zipyBundleId
144
+ pjson.x_metadata.zipy_bundle_id = zipyBundleId
145
+ pjson.x_metadata.bundleId = zipyBundleId
146
+ pjson.x_metadata.bundle_id = zipyBundleId
147
+ if (pjson.metadata == null) pjson.metadata = [:]
148
+ pjson.metadata.zipyBundleId = zipyBundleId
149
+ pjson.metadata.zipy_bundle_id = zipyBundleId
150
+ pjson.metadata.bundleId = zipyBundleId
151
+ pjson.metadata.bundle_id = zipyBundleId
152
+ pjson.zipyBundleId = zipyBundleId
153
+ pjson.zipy_bundle_id = zipyBundleId
154
+ packagerFile.text = groovy.json.JsonOutput.toJson(pjson)
155
+ } catch (Throwable ignored) {}
156
+ }
157
+ }
158
+ }
159
+
160
+ def zipyUploadTask = tasks.register(zipyUploadTaskName, Exec) {
161
+ onlyIf { shouldZipyAutoUpload() }
162
+ dependsOn(zipyInjectTask)
163
+ description = "upload sourcemaps and bundle to Zipy"
164
+ group = "zipy"
165
+ workingDir _reactRoot
166
+ ignoreExitValue = true
167
+ environment("ZIPY_PLATFORM", "android")
168
+ environment("ZIPY_APP_ID", appId)
169
+ environment("ZIPY_RELEASE_VERSION", releaseName)
170
+ environment("ZIPY_BUILD_ID", buildId)
171
+ // When inject task is skipped (same map rebuild), Gradle marks it UP-TO-DATE. The map gets
172
+ // overwritten by bundleTask with raw Metro output (no debugId). Pass ZIPY_DEBUG_ID from
173
+ // zipySentinelFile so the CLI uses it instead of falling back to bundle_id (app id).
174
+ doFirst {
175
+ if (zipySentinelFile.exists()) {
176
+ def sentinelId = zipySentinelFile.text.trim()
177
+ if (sentinelId) {
178
+ environment("ZIPY_DEBUG_ID", sentinelId)
179
+ logger.lifecycle("Zipy: passing ZIPY_DEBUG_ID from sentinel for upload")
180
+ }
181
+ }
182
+ }
183
+ commandLine(Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npx', 'zipy-mobile-cli', 'upload-sourcemaps', 'release'] : ['npx', 'zipy-mobile-cli', 'upload-sourcemaps', 'release'])
184
+ }
185
+
186
+ def zipyCleanupTask = tasks.register(zipyCleanupTaskName) {
187
+ onlyIf { shouldZipyAutoUpload() }
188
+ description = "Zipy post-upload cleanup; resets debugid.cjs to empty after build"
189
+ group = "zipy"
190
+ doLast {
191
+ def nodeModulesRoot = _reactRoot
192
+ def debugIdFiles = [
193
+ new File(nodeModulesRoot, "node_modules/zipy-react-native/lib/commonjs/utils/debugid.cjs"),
194
+ new File(nodeModulesRoot, "node_modules/zipyai-react-native/lib/commonjs/utils/debugid.cjs")
195
+ ]
196
+ def emptyContent = "\"use strict\";Object.defineProperty(exports,\"__esModule\",{value:!0}),exports.BUNDLE_ID=void 0;const BUNDLE_ID=exports.BUNDLE_ID=\"\";"
197
+ debugIdFiles.each { File f ->
198
+ if (f.exists()) {
199
+ try {
200
+ f.text = emptyContent
201
+ logger.lifecycle("Zipy: cleared debugid.cjs (${f.name}) after build")
202
+ } catch (Throwable e) {
203
+ logger.warn("Zipy: could not clear ${f.absolutePath}: ${e.message}")
204
+ }
205
+ }
206
+ }
207
+ logger.lifecycle("Zipy upload done for ${releaseName}")
208
+ }
209
+ }
210
+
211
+ bundleTask.configure { it.finalizedBy(zipyUploadTask) }
212
+ zipyUploadTask.configure { it.finalizedBy(zipyCleanupTask) }
213
+ }
214
+ }
215
+ }