pakstr 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,57 +4,311 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.DockerGradleRunner = void 0;
7
- const child_process_1 = require("child_process");
8
- const path_1 = __importDefault(require("path"));
9
7
  const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = require("crypto");
10
+ const releaseVerification_1 = require("../android/releaseVerification");
11
+ const releaseSigning_1 = require("../core/releaseSigning");
12
+ const process_1 = require("./process");
13
+ const CONTAINER_KEYSTORE_PATH = "/run/secrets/pakstr/release.keystore";
14
+ const CONTAINER_RELEASE_APK = "/workspace/app/build/outputs/apk/release/app-release.apk";
15
+ const CONTAINER_DEBUG_APK = "/workspace/app/build/outputs/apk/debug/app-debug.apk";
10
16
  class DockerGradleRunner {
11
17
  image;
18
+ execute;
12
19
  constructor(image = process.env.PAKSTR_DOCKER_IMAGE ??
13
- "git.nostrdev.com/stuff/pakstr/android-builder:latest") {
20
+ "git.nostrdev.com/stuff/pakstr/android-builder:latest", execute = process_1.runProcess) {
14
21
  this.image = image;
22
+ this.execute = execute;
23
+ }
24
+ async build(request) {
25
+ this.checkDockerAvailable();
26
+ this.ensureDockerImage();
27
+ const signing = request.mode === "release" ? requireSigning(request.signing) : undefined;
28
+ const storage = this.createBuildStorage(request.androidRoot, signing);
29
+ let primaryError;
30
+ let result;
31
+ try {
32
+ result =
33
+ request.mode === "release"
34
+ ? this.buildRelease(request, signing, storage)
35
+ : this.buildDebug(request, storage);
36
+ }
37
+ catch (error) {
38
+ primaryError = error;
39
+ }
40
+ let cleanupError;
41
+ try {
42
+ this.removeBuildStorage(storage);
43
+ }
44
+ catch (error) {
45
+ cleanupError = error;
46
+ }
47
+ if (primaryError) {
48
+ if (cleanupError) {
49
+ console.error("Docker signing storage cleanup also failed:", safeErrorMessage(cleanupError));
50
+ }
51
+ throw primaryError;
52
+ }
53
+ if (cleanupError)
54
+ throw cleanupError;
55
+ if (!result)
56
+ throw new Error("Docker build completed without a result");
57
+ return result;
58
+ }
59
+ buildDebug(request, storage) {
60
+ this.execute("docker", [
61
+ "run",
62
+ "--rm",
63
+ "-v",
64
+ `${storage.workspaceVolume}:/workspace`,
65
+ this.image,
66
+ "./gradlew",
67
+ "assembleDebug",
68
+ "-Dorg.gradle.vfs.watch=false",
69
+ ], {
70
+ env: (0, releaseSigning_1.removeSigningVariables)(process.env),
71
+ streamOutput: true,
72
+ });
73
+ const artifactPath = hostArtifactPath(request.androidRoot, "debug");
74
+ this.copyFromVolume(storage.workspaceVolume, CONTAINER_DEBUG_APK, artifactPath);
75
+ return { artifactPath: requireApk(artifactPath) };
15
76
  }
16
- async build(androidRoot, mode) {
17
- console.log("🐳 Using Docker Gradle");
18
- checkDockerAvailable();
19
- ensureDockerImage(this.image);
20
- const task = mode === "release" ? "assembleRelease" : "assembleDebug";
21
- (0, child_process_1.execSync)(`docker run --rm \
22
- --platform linux/amd64 \
23
- -v "${androidRoot}:/workspace" \
24
- ${this.image} \
25
- ./gradlew ${task} -Dorg.gradle.vfs.watch=false`, {
26
- stdio: "inherit",
77
+ buildRelease(request, signing, storage) {
78
+ if (!storage.signingVolume) {
79
+ throw new Error("Docker release build requires signing storage");
80
+ }
81
+ const secrets = signingSecrets(signing);
82
+ const dockerEnv = (0, releaseSigning_1.createGradleSigningEnvironment)(signing, process.env, CONTAINER_KEYSTORE_PATH);
83
+ const environmentNames = [
84
+ releaseSigning_1.INTERNAL_KEYSTORE_PATH_ENV,
85
+ releaseSigning_1.PUBLIC_SIGNING_ENV.keystorePassword,
86
+ releaseSigning_1.PUBLIC_SIGNING_ENV.keyAlias,
87
+ releaseSigning_1.PUBLIC_SIGNING_ENV.keyPassword,
88
+ ];
89
+ this.execute("docker", [
90
+ "run",
91
+ "--rm",
92
+ "-v",
93
+ `${storage.workspaceVolume}:/workspace`,
94
+ "-v",
95
+ `${storage.signingVolume}:/run/secrets/pakstr:ro`,
96
+ ...environmentNames.flatMap(name => ["--env", name]),
97
+ this.image,
98
+ "./gradlew",
99
+ "assembleRelease",
100
+ "--no-daemon",
101
+ "-Dorg.gradle.vfs.watch=false",
102
+ ], {
103
+ env: dockerEnv,
104
+ secrets,
105
+ streamOutput: true,
106
+ });
107
+ const verification = (0, releaseVerification_1.verifyReleaseApk)({
108
+ artifactPath: CONTAINER_RELEASE_APK,
109
+ expectedMetadata: request.expectedMetadata,
110
+ signing,
111
+ toolKeystorePath: CONTAINER_KEYSTORE_PATH,
112
+ executor: this.createVerificationExecutor(storage, signing),
27
113
  });
28
- const apkPath = path_1.default.join(androidRoot, "app", "build", "outputs", "apk", mode, mode === "debug" ? "app-debug.apk" : "app-release.apk");
29
- if (!fs_1.default.existsSync(apkPath)) {
30
- throw new Error(`APK not found: ${apkPath}`);
114
+ const artifactPath = hostArtifactPath(request.androidRoot, "release");
115
+ this.copyFromVolume(storage.workspaceVolume, CONTAINER_RELEASE_APK, artifactPath);
116
+ return { artifactPath: requireApk(artifactPath), verification };
117
+ }
118
+ createBuildStorage(androidRoot, signing) {
119
+ const suffix = (0, crypto_1.randomUUID)();
120
+ const storage = {
121
+ workspaceVolume: `pakstr-workspace-${suffix}`,
122
+ signingVolume: signing ? `pakstr-signing-${suffix}` : undefined,
123
+ };
124
+ assertRegularAbsolutePath(androidRoot, "Android workspace");
125
+ if (signing) {
126
+ assertRegularAbsolutePath(signing.keystorePath, "Temporary keystore", true);
127
+ }
128
+ try {
129
+ this.execute("docker", ["volume", "create", storage.workspaceVolume]);
130
+ if (storage.signingVolume) {
131
+ this.execute("docker", ["volume", "create", storage.signingVolume]);
132
+ }
133
+ this.copyDirectoryToVolume(androidRoot, storage.workspaceVolume);
134
+ if (signing && storage.signingVolume) {
135
+ this.copyFileToVolume(signing.keystorePath, storage.signingVolume, "/run/secrets/pakstr/release.keystore", signingSecrets(signing));
136
+ }
137
+ return storage;
138
+ }
139
+ catch (error) {
140
+ try {
141
+ this.removeBuildStorage(storage);
142
+ }
143
+ catch (cleanupError) {
144
+ console.error("Docker signing storage cleanup also failed:", safeErrorMessage(cleanupError));
145
+ }
146
+ throw error;
147
+ }
148
+ }
149
+ copyDirectoryToVolume(source, volume) {
150
+ const container = `pakstr-workspace-copy-${(0, crypto_1.randomUUID)()}`;
151
+ try {
152
+ this.execute("docker", [
153
+ "create",
154
+ "--name",
155
+ container,
156
+ "-v",
157
+ `${volume}:/workspace`,
158
+ this.image,
159
+ "true",
160
+ ]);
161
+ this.execute("docker", ["cp", `${source}${path_1.default.sep}.`, `${container}:/workspace`]);
162
+ }
163
+ finally {
164
+ this.removeContainer(container);
165
+ }
166
+ }
167
+ copyFileToVolume(source, volume, destination, secrets) {
168
+ const container = `pakstr-signing-copy-${(0, crypto_1.randomUUID)()}`;
169
+ try {
170
+ this.execute("docker", [
171
+ "create",
172
+ "--name",
173
+ container,
174
+ "-v",
175
+ `${volume}:/run/secrets/pakstr`,
176
+ this.image,
177
+ "true",
178
+ ]);
179
+ this.execute("docker", ["cp", "-a", source, `${container}:${destination}`], {
180
+ secrets,
181
+ });
182
+ }
183
+ finally {
184
+ this.removeContainer(container);
185
+ }
186
+ }
187
+ copyFromVolume(volume, source, destination) {
188
+ const container = `pakstr-output-copy-${(0, crypto_1.randomUUID)()}`;
189
+ fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
190
+ fs_1.default.rmSync(destination, { force: true });
191
+ try {
192
+ this.execute("docker", [
193
+ "create",
194
+ "--name",
195
+ container,
196
+ "-v",
197
+ `${volume}:/workspace:ro`,
198
+ this.image,
199
+ "true",
200
+ ]);
201
+ this.execute("docker", ["cp", `${container}:${source}`, destination]);
202
+ }
203
+ finally {
204
+ this.removeContainer(container);
205
+ }
206
+ }
207
+ createVerificationExecutor(storage, signing) {
208
+ const secrets = signingSecrets(signing);
209
+ return {
210
+ run: (tool, args, env = {}) => {
211
+ const volumeArgs = ["-v", `${storage.workspaceVolume}:/workspace:ro`];
212
+ if (tool === "keytool") {
213
+ if (!storage.signingVolume) {
214
+ throw new Error("Signer verification requires Docker signing storage");
215
+ }
216
+ volumeArgs.push("-v", `${storage.signingVolume}:/run/secrets/pakstr:ro`);
217
+ }
218
+ return this.execute("docker", [
219
+ "run",
220
+ "--rm",
221
+ ...volumeArgs,
222
+ ...Object.keys(env).flatMap(name => ["--env", name]),
223
+ this.image,
224
+ tool,
225
+ ...args,
226
+ ], { env: { ...(0, releaseSigning_1.removeSigningVariables)(process.env), ...env }, secrets });
227
+ },
228
+ };
229
+ }
230
+ removeBuildStorage(storage) {
231
+ const failures = [];
232
+ for (const volume of [storage.signingVolume, storage.workspaceVolume]) {
233
+ if (!volume)
234
+ continue;
235
+ try {
236
+ this.execute("docker", ["volume", "rm", "--force", volume]);
237
+ }
238
+ catch {
239
+ failures.push(volume);
240
+ }
241
+ }
242
+ if (failures.length) {
243
+ throw new Error(`Failed to remove temporary Docker volume(s): ${failures.join(", ")}`);
244
+ }
245
+ }
246
+ removeContainer(container) {
247
+ try {
248
+ this.execute("docker", ["rm", "--force", container]);
249
+ }
250
+ catch {
251
+ // A failed docker create leaves no container to remove.
252
+ }
253
+ }
254
+ checkDockerAvailable() {
255
+ try {
256
+ this.execute("docker", ["--version"]);
257
+ }
258
+ catch {
259
+ throw new Error("Docker is not installed or not running. Please install Docker Desktop first.");
260
+ }
261
+ }
262
+ ensureDockerImage() {
263
+ try {
264
+ this.execute("docker", ["image", "inspect", this.image]);
265
+ console.log(`🐳 Docker image ready: ${this.image}`);
266
+ }
267
+ catch {
268
+ console.log(`⬇️ Pulling Docker image: ${this.image}`);
269
+ this.execute("docker", ["pull", this.image], { streamOutput: true });
31
270
  }
32
- return apkPath;
33
271
  }
34
272
  }
35
273
  exports.DockerGradleRunner = DockerGradleRunner;
36
- function checkDockerAvailable() {
37
- try {
38
- (0, child_process_1.execSync)("docker --version", {
39
- stdio: "ignore",
40
- });
274
+ function hostArtifactPath(androidRoot, mode) {
275
+ return path_1.default.join(androidRoot, "app", "build", "outputs", "apk", mode, `app-${mode}.apk`);
276
+ }
277
+ function requireApk(artifactPath) {
278
+ if (!fs_1.default.existsSync(artifactPath) || !fs_1.default.lstatSync(artifactPath).isFile()) {
279
+ throw new Error(`APK not found after Docker build: ${artifactPath}`);
41
280
  }
42
- catch (err) {
43
- throw new Error("🐳 Docker is not installed or not running. Please install Docker Desktop first.");
281
+ return artifactPath;
282
+ }
283
+ function requireSigning(signing) {
284
+ if (!signing) {
285
+ throw new Error("Release build requires an internal signing context");
44
286
  }
287
+ return signing;
45
288
  }
46
- function ensureDockerImage(image) {
47
- try {
48
- (0, child_process_1.execSync)(`docker image inspect ${image}`, {
49
- stdio: "ignore",
50
- });
51
- console.log(`🐳 Docker image ready: ${image}`);
289
+ function signingSecrets(signing) {
290
+ return [
291
+ signing.storePassword,
292
+ signing.keyAlias,
293
+ signing.keyPassword,
294
+ signing.keystorePath,
295
+ ];
296
+ }
297
+ function assertRegularAbsolutePath(candidate, label, requireFile = false) {
298
+ if (!path_1.default.isAbsolute(candidate)) {
299
+ throw new Error(`${label} path must be absolute`);
52
300
  }
53
- catch {
54
- console.log(`📦 Docker image missing: ${image}`);
55
- console.log(`⬇️ Pulling ${image}...`);
56
- (0, child_process_1.execSync)(`docker pull ${image}`, {
57
- stdio: "inherit",
58
- });
301
+ if (!fs_1.default.existsSync(candidate)) {
302
+ throw new Error(`${label} path does not exist`);
303
+ }
304
+ const stat = fs_1.default.lstatSync(candidate);
305
+ if (stat.isSymbolicLink()) {
306
+ throw new Error(`${label} path must not be a symbolic link`);
59
307
  }
308
+ if (requireFile ? !stat.isFile() : !stat.isDirectory()) {
309
+ throw new Error(`${label} path has an unexpected filesystem type`);
310
+ }
311
+ }
312
+ function safeErrorMessage(error) {
313
+ return error instanceof Error ? error.message : "unknown Docker cleanup error";
60
314
  }
@@ -4,47 +4,109 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.LocalGradleRunner = void 0;
7
- const child_process_1 = require("child_process");
8
- const path_1 = __importDefault(require("path"));
9
7
  const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const releaseVerification_1 = require("../android/releaseVerification");
11
+ const releaseSigning_1 = require("../core/releaseSigning");
12
+ const process_1 = require("./process");
10
13
  class LocalGradleRunner {
11
- async build(androidRoot, mode) {
12
- const gradleTask = mode === "release" ? "assembleRelease" : "assembleDebug";
14
+ async build(request) {
15
+ const { androidRoot, mode } = request;
16
+ const gradlewPath = path_1.default.join(androidRoot, "gradlew");
17
+ if (!fs_1.default.existsSync(gradlewPath)) {
18
+ throw new Error("gradlew not found in Android project root");
19
+ }
13
20
  console.log("⚙️ Local Gradle build");
14
21
  console.log("📂 Android:", androidRoot);
15
- const gradlewFile = path_1.default.join(androidRoot, "gradlew");
16
- if (!fsExists(gradlewFile)) {
17
- throw new Error("❌ gradlew not found in Android project root");
22
+ if (mode === "debug") {
23
+ (0, process_1.runProcess)(gradlewPath, ["assembleDebug"], {
24
+ cwd: androidRoot,
25
+ env: (0, releaseSigning_1.removeSigningVariables)(process.env),
26
+ streamOutput: true,
27
+ });
28
+ return { artifactPath: requireApk(androidRoot, "debug") };
18
29
  }
19
- const gradlewPath = "./gradlew";
20
- console.log("📍 Using:", gradlewPath);
21
- (0, child_process_1.execSync)(`bash "${gradlewPath}" ${gradleTask}`, {
30
+ const signing = requireSigning(request.signing);
31
+ const secrets = signingSecrets(signing);
32
+ const gradleEnv = (0, releaseSigning_1.createGradleSigningEnvironment)(signing);
33
+ (0, process_1.runProcess)(gradlewPath, ["assembleRelease", "--no-daemon"], {
22
34
  cwd: androidRoot,
23
- stdio: "inherit",
35
+ env: gradleEnv,
36
+ secrets,
37
+ streamOutput: true,
24
38
  });
25
- const apkPath = findApk(androidRoot, mode);
26
- if (!apkPath) {
27
- throw new Error("❌ APK not found after Gradle build");
28
- }
29
- console.log("✅ APK created:", apkPath);
30
- return apkPath;
39
+ const artifactPath = requireApk(androidRoot, "release");
40
+ const executor = createLocalVerificationExecutor(signing);
41
+ const verification = (0, releaseVerification_1.verifyReleaseApk)({
42
+ artifactPath,
43
+ expectedMetadata: request.expectedMetadata,
44
+ signing,
45
+ executor,
46
+ });
47
+ return { artifactPath, verification };
31
48
  }
32
49
  }
33
50
  exports.LocalGradleRunner = LocalGradleRunner;
34
- function findApk(androidRoot, mode) {
35
- const apkDir = path_1.default.join(androidRoot, "app", "build", "outputs", "apk", mode);
36
- if (!fs_1.default.existsSync(apkDir)) {
37
- return null;
51
+ function createLocalVerificationExecutor(signing) {
52
+ const androidHome = process.env.ANDROID_HOME ??
53
+ process.env.ANDROID_SDK_ROOT ??
54
+ path_1.default.join(os_1.default.homedir(), "Library", "Android", "sdk");
55
+ const secrets = signingSecrets(signing);
56
+ return {
57
+ run(tool, args, env = {}) {
58
+ const command = resolveTool(tool, androidHome);
59
+ return (0, process_1.runProcess)(command, args, {
60
+ env: { ...process.env, ...env },
61
+ secrets,
62
+ });
63
+ },
64
+ };
65
+ }
66
+ function resolveTool(tool, androidHome) {
67
+ if (tool === "keytool")
68
+ return "keytool";
69
+ if (tool === "apkanalyzer") {
70
+ const analyzer = path_1.default.join(androidHome, "cmdline-tools", "latest", "bin", "apkanalyzer");
71
+ if (!fs_1.default.existsSync(analyzer)) {
72
+ throw new Error(`Required Android verification tool not found: ${analyzer}`);
73
+ }
74
+ return analyzer;
75
+ }
76
+ const buildToolsDir = path_1.default.join(androidHome, "build-tools");
77
+ if (!fs_1.default.existsSync(buildToolsDir)) {
78
+ throw new Error(`Android build-tools directory not found: ${buildToolsDir}`);
79
+ }
80
+ const versions = fs_1.default
81
+ .readdirSync(buildToolsDir)
82
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
83
+ const executable = tool === "aapt" ? "aapt" : "apksigner";
84
+ const resolved = versions
85
+ .map(version => path_1.default.join(buildToolsDir, version, executable))
86
+ .find(candidate => fs_1.default.existsSync(candidate));
87
+ if (!resolved) {
88
+ throw new Error(`Required Android verification tool ${executable} not found under ${buildToolsDir}`);
38
89
  }
39
- const files = fs_1.default.readdirSync(apkDir);
40
- const apk = files.find(file => file.endsWith(".apk") &&
41
- !file.includes("metadata"));
42
- if (!apk) {
43
- return null;
90
+ return resolved;
91
+ }
92
+ function requireApk(androidRoot, mode) {
93
+ const artifactPath = path_1.default.join(androidRoot, "app", "build", "outputs", "apk", mode, `app-${mode}.apk`);
94
+ if (!fs_1.default.existsSync(artifactPath) || !fs_1.default.lstatSync(artifactPath).isFile()) {
95
+ throw new Error(`APK not found after Gradle build: ${artifactPath}`);
96
+ }
97
+ return artifactPath;
98
+ }
99
+ function requireSigning(signing) {
100
+ if (!signing) {
101
+ throw new Error("Release build requires an internal signing context");
44
102
  }
45
- return path_1.default.join(apkDir, apk);
103
+ return signing;
46
104
  }
47
- function fsExists(file) {
48
- const fs = require("fs");
49
- return fs.existsSync(file);
105
+ function signingSecrets(signing) {
106
+ return [
107
+ signing.storePassword,
108
+ signing.keyAlias,
109
+ signing.keyPassword,
110
+ signing.keystorePath,
111
+ ];
50
112
  }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runProcess = runProcess;
4
+ exports.redact = redact;
5
+ const child_process_1 = require("child_process");
6
+ const MAX_DIAGNOSTIC_LENGTH = 32 * 1024;
7
+ function runProcess(command, args, options = {}) {
8
+ const result = (0, child_process_1.spawnSync)(command, args, {
9
+ cwd: options.cwd,
10
+ env: options.env,
11
+ encoding: "utf8",
12
+ stdio: ["ignore", "pipe", "pipe"],
13
+ });
14
+ const stdout = redact(result.stdout ?? "", options.secrets ?? []);
15
+ const stderr = redact(result.stderr ?? "", options.secrets ?? []);
16
+ if (options.streamOutput) {
17
+ if (stdout)
18
+ process.stdout.write(stdout);
19
+ if (stderr)
20
+ process.stderr.write(stderr);
21
+ }
22
+ if (result.error) {
23
+ throw new Error(`Failed to run ${command}: ${redact(result.error.message, options.secrets ?? [])}`);
24
+ }
25
+ if (result.status !== 0) {
26
+ const diagnostic = bounded(`${stderr}\n${stdout}`.trim());
27
+ const suffix = diagnostic ? `\n${diagnostic}` : "";
28
+ throw new Error(`${command} exited with status ${result.status}${result.signal ? ` (${result.signal})` : ""}${suffix}`);
29
+ }
30
+ return { stdout, stderr };
31
+ }
32
+ function redact(value, secrets) {
33
+ return secrets
34
+ .filter(secret => secret.length > 0)
35
+ .sort((a, b) => b.length - a.length)
36
+ .reduce((output, secret) => output.split(secret).join("[REDACTED]"), value);
37
+ }
38
+ function bounded(value) {
39
+ if (value.length <= MAX_DIAGNOSTIC_LENGTH)
40
+ return value;
41
+ return `[diagnostic truncated]\n${value.slice(-MAX_DIAGNOSTIC_LENGTH)}`;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -27,6 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "build": "tsc",
30
+ "test": "npm run build && node --test test/*.test.js",
30
31
  "start": "node dist/index.js",
31
32
  "dev": "ts-node src/index.ts",
32
33
  "pack:test": "npm pack"
@@ -1,16 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.patchVersion = patchVersion;
7
- const fs_1 = __importDefault(require("fs"));
8
- const path_1 = __importDefault(require("path"));
9
- function patchVersion(androidRoot, versionCode, versionName) {
10
- const gradlePath = path_1.default.join(androidRoot, "app", "build.gradle.kts");
11
- let gradle = fs_1.default.readFileSync(gradlePath, "utf8");
12
- gradle = gradle.replace(/versionCode\s*=\s*[^\r\n]+/, `versionCode = ${versionCode}`);
13
- gradle = gradle.replace(/versionName\s*=\s*"[^"]+"/, `versionName = "${versionName}"`);
14
- fs_1.default.writeFileSync(gradlePath, gradle);
15
- console.log("✅ Version patched:", versionCode, versionName);
16
- }