pakstr 0.7.0 → 0.8.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.
@@ -1,5 +1,4 @@
1
1
  import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2
- import java.util.Properties
3
2
  import java.io.File
4
3
 
5
4
  plugins {
@@ -7,49 +6,32 @@ plugins {
7
6
  alias(libs.plugins.kotlin.compose)
8
7
  }
9
8
 
10
- val keystoreProperties =
11
-
12
- Properties()
13
-
14
- val keystorePath =
15
- providers.gradleProperty(
16
- "releaseKeystoreConfig"
17
- ).orNull
18
-
19
- val keystorePropertiesFile =
20
- keystorePath?.let { rootProject.file(it) }
21
- if (keystorePropertiesFile?.exists() == true) {
22
-
23
- keystorePropertiesFile.inputStream()
24
- .use {
25
- keystoreProperties.load(it)
26
- }
9
+ val releaseRequested = gradle.startParameter.taskNames.any {
10
+ it.contains("release", ignoreCase = true)
27
11
  }
28
12
 
13
+ fun requireReleaseSigningValue(name: String): String =
14
+ System.getenv(name)
15
+ ?.takeIf { it.isNotEmpty() }
16
+ ?: throw GradleException("Missing required release signing environment variable: $name")
17
+
29
18
  android {
30
19
  namespace = "com.pakstr.app"
31
20
  compileSdk = 36
32
21
 
33
22
  signingConfigs {
34
-
35
23
  create("release") {
36
-
37
- if (keystorePropertiesFile?.exists() == true) {
38
-
39
- storeFile =
40
- File(
41
- keystorePropertiesFile.parentFile,
42
- keystoreProperties["storeFile"] as String
43
- )
44
-
45
- storePassword =
46
- keystoreProperties["storePassword"] as String
47
-
48
- keyAlias =
49
- keystoreProperties["keyAlias"] as String
50
-
51
- keyPassword =
52
- keystoreProperties["keyPassword"] as String
24
+ if (releaseRequested) {
25
+ val keystorePath = requireReleaseSigningValue("PAKSTR_ANDROID_KEYSTORE_PATH")
26
+ val keystoreFile = File(keystorePath)
27
+ if (!keystoreFile.isFile) {
28
+ throw GradleException("Release signing keystore path is not a file")
29
+ }
30
+
31
+ storeFile = keystoreFile
32
+ storePassword = requireReleaseSigningValue("PAKSTR_ANDROID_KEYSTORE_PASSWORD")
33
+ keyAlias = requireReleaseSigningValue("PAKSTR_ANDROID_KEY_ALIAS")
34
+ keyPassword = requireReleaseSigningValue("PAKSTR_ANDROID_KEY_PASSWORD")
53
35
  }
54
36
  }
55
37
  }
@@ -65,25 +47,22 @@ android {
65
47
  }
66
48
 
67
49
  buildTypes {
68
-
69
50
  debug {
70
51
  applicationIdSuffix = ".debug"
71
52
  versionNameSuffix = "-debug"
72
53
  }
73
54
 
74
55
  release {
75
- signingConfig =
76
- signingConfigs.getByName("release")
56
+ signingConfig = signingConfigs.getByName("release")
77
57
  isMinifyEnabled = true
78
58
  isShrinkResources = true
79
59
  proguardFiles(
80
- getDefaultProguardFile(
81
- "proguard-android-optimize.txt"
82
- ),
60
+ getDefaultProguardFile("proguard-android-optimize.txt"),
83
61
  "proguard-rules.pro"
84
62
  )
85
63
  }
86
64
  }
65
+
87
66
  compileOptions {
88
67
  sourceCompatibility = JavaVersion.VERSION_17
89
68
  targetCompatibility = JavaVersion.VERSION_17
@@ -94,6 +73,7 @@ android {
94
73
  buildConfig = true
95
74
  }
96
75
  }
76
+
97
77
  tasks.withType<KotlinCompile>()
98
78
  .configureEach {
99
79
  compilerOptions {
@@ -121,9 +101,7 @@ dependencies {
121
101
  debugImplementation(libs.androidx.compose.ui.test.manifest)
122
102
  implementation(libs.nanohttpd)
123
103
  implementation(libs.androidx.appcompat)
124
-
125
104
  implementation(libs.material)
126
105
  implementation(libs.androidx.core.splashscreen)
127
-
128
106
  implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
129
- }
107
+ }
@@ -12,5 +12,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
12
12
  # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13
13
  # org.gradle.parallel=true
14
14
  # Kotlin code style for this project: "official" or "obsolete":
15
- kotlin.code.style=official
16
- releaseKeystoreConfig=../../../keystore.properties
15
+ kotlin.code.style=official
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyReleaseApk = verifyReleaseApk;
4
+ exports.parseApkSignerFingerprints = parseApkSignerFingerprints;
5
+ exports.parseKeystoreFingerprints = parseKeystoreFingerprints;
6
+ const crypto_1 = require("crypto");
7
+ const releaseSigning_1 = require("../core/releaseSigning");
8
+ function verifyReleaseApk(input) {
9
+ const signerResult = input.executor.run("apksigner", [
10
+ "verify",
11
+ "--print-certs",
12
+ input.artifactPath,
13
+ ]);
14
+ const signerFingerprints = parseApkSignerFingerprints(signerResult.stdout);
15
+ if (signerFingerprints.length !== 1) {
16
+ throw new Error(`Release APK must have exactly one current signer; found ${signerFingerprints.length}`);
17
+ }
18
+ const keytoolEnv = {
19
+ [releaseSigning_1.PUBLIC_SIGNING_ENV.keystorePassword]: input.signing.storePassword,
20
+ };
21
+ const keytoolResult = input.executor.run("keytool", [
22
+ "-list",
23
+ "-rfc",
24
+ "-keystore",
25
+ input.toolKeystorePath ?? input.signing.keystorePath,
26
+ `-storepass:env`,
27
+ releaseSigning_1.PUBLIC_SIGNING_ENV.keystorePassword,
28
+ ], keytoolEnv);
29
+ const keystoreFingerprints = parseKeystoreFingerprints(keytoolResult.stdout);
30
+ const signerFingerprint = signerFingerprints[0];
31
+ const matchingEntries = keystoreFingerprints.filter(fingerprint => fingerprint === signerFingerprint);
32
+ if (matchingEntries.length !== 1) {
33
+ throw new Error(`Release APK signer must match exactly one certificate in the supplied keystore; found ${matchingEntries.length}`);
34
+ }
35
+ const identityResult = input.executor.run("apkanalyzer", [
36
+ "manifest",
37
+ "application-id",
38
+ input.artifactPath,
39
+ ]);
40
+ const applicationIds = identityResult.stdout
41
+ .split(/\r?\n/)
42
+ .map(value => value.trim())
43
+ .filter(Boolean);
44
+ if (applicationIds.length !== 1) {
45
+ throw new Error("Unable to derive one unambiguous Application ID from release APK");
46
+ }
47
+ const actualApplicationId = applicationIds[0];
48
+ if (actualApplicationId !== input.expectedApplicationId) {
49
+ throw new Error(`Release APK Application ID mismatch: expected ${input.expectedApplicationId}, received ${actualApplicationId}`);
50
+ }
51
+ return {
52
+ signatureVerified: true,
53
+ signerMatched: true,
54
+ expectedApplicationId: input.expectedApplicationId,
55
+ actualApplicationId,
56
+ tools: {
57
+ signature: "apksigner",
58
+ signer: "keytool",
59
+ identity: "apkanalyzer",
60
+ },
61
+ };
62
+ }
63
+ function parseApkSignerFingerprints(output) {
64
+ const fingerprints = output
65
+ .split(/\r?\n/)
66
+ .map(line => line.match(/Signer #\d+ certificate SHA-256 digest:\s*([0-9a-fA-F]+)/)?.[1])
67
+ .filter((value) => value !== undefined)
68
+ .map(normalizeFingerprint);
69
+ return [...new Set(fingerprints)];
70
+ }
71
+ function parseKeystoreFingerprints(output) {
72
+ const certificates = output.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g);
73
+ if (!certificates?.length) {
74
+ throw new Error("No certificate-bearing entries found in supplied keystore");
75
+ }
76
+ return certificates.map(pem => {
77
+ const certificate = new crypto_1.X509Certificate(pem);
78
+ return (0, crypto_1.createHash)("sha256").update(certificate.raw).digest("hex");
79
+ });
80
+ }
81
+ function normalizeFingerprint(value) {
82
+ return value.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
83
+ }
@@ -0,0 +1,44 @@
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.createReleaseWorkspace = createReleaseWorkspace;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const template_1 = require("./template");
10
+ function createReleaseWorkspace(templateRoot) {
11
+ const workspaceRoot = path_1.default.join((0, template_1.getCacheDir)(), "workspaces");
12
+ fs_1.default.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
13
+ const workspaceParent = fs_1.default.mkdtempSync(path_1.default.join(workspaceRoot, "pakstr-release-workspace-"));
14
+ const androidRoot = path_1.default.join(workspaceParent, "android");
15
+ let cleaned = false;
16
+ try {
17
+ fs_1.default.chmodSync(workspaceParent, 0o700);
18
+ fs_1.default.cpSync(templateRoot, androidRoot, { recursive: true });
19
+ const gradlewPath = path_1.default.join(androidRoot, "gradlew");
20
+ if (fs_1.default.existsSync(gradlewPath)) {
21
+ fs_1.default.chmodSync(gradlewPath, 0o755);
22
+ }
23
+ }
24
+ catch (error) {
25
+ fs_1.default.rmSync(workspaceParent, { recursive: true, force: true });
26
+ throw error;
27
+ }
28
+ return {
29
+ androidRoot,
30
+ cleanup() {
31
+ if (cleaned)
32
+ return;
33
+ fs_1.default.rmSync(workspaceParent, {
34
+ recursive: true,
35
+ force: true,
36
+ maxRetries: 2,
37
+ });
38
+ if (fs_1.default.existsSync(workspaceParent)) {
39
+ throw new Error(`Failed to remove temporary release workspace: ${workspaceParent}`);
40
+ }
41
+ cleaned = true;
42
+ },
43
+ };
44
+ }
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getCacheDir = getCacheDir;
6
7
  exports.ensureTemplate = ensureTemplate;
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
@@ -54,10 +55,14 @@ function fixGradlewPermissions(root) {
54
55
  // -----------------------------
55
56
  function createLocalProperties(root) {
56
57
  const sdkPath = process.env.ANDROID_HOME ??
58
+ process.env.ANDROID_SDK_ROOT ??
57
59
  path_1.default.join(os_1.default.homedir(), "Library", "Android", "sdk");
58
- const content = `sdk.dir=${sdkPath}\n`;
59
60
  const file = path_1.default.join(root, "local.properties");
60
- fs_1.default.writeFileSync(file, content);
61
+ if (!fs_1.default.existsSync(sdkPath)) {
62
+ fs_1.default.rmSync(file, { force: true });
63
+ return;
64
+ }
65
+ fs_1.default.writeFileSync(file, `sdk.dir=${sdkPath}\n`);
61
66
  console.log("📍 local.properties created:", sdkPath);
62
67
  }
63
68
  async function ensureTemplate() {
@@ -8,140 +8,177 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const child_process_1 = require("child_process");
10
10
  const gradle_1 = require("../android/gradle");
11
- const validateProject_1 = require("../core/validateProject");
12
- const buildContext_1 = require("../core/buildContext");
13
- const template_1 = require("../android/template");
14
- const createRunner_1 = require("../runners/createRunner");
15
- const config_1 = require("../core/config");
16
11
  const branding_1 = require("../android/branding");
17
- const version_1 = require("../android/version");
18
12
  const icon_1 = require("../android/icon");
19
- const splash_1 = require("../android/splash");
20
13
  const permissions_1 = require("../android/permissions");
14
+ const releaseWorkspace_1 = require("../android/releaseWorkspace");
15
+ const splash_1 = require("../android/splash");
16
+ const template_1 = require("../android/template");
17
+ const version_1 = require("../android/version");
18
+ const buildContext_1 = require("../core/buildContext");
19
+ const config_1 = require("../core/config");
20
+ const releaseSigning_1 = require("../core/releaseSigning");
21
+ const validateProject_1 = require("../core/validateProject");
22
+ const createRunner_1 = require("../runners/createRunner");
21
23
  const runtimeConfig_1 = require("../runtime/runtimeConfig");
22
24
  async function buildCommand(args) {
23
25
  const ctx = (0, buildContext_1.createBuildContext)(args);
24
26
  const config = (0, config_1.loadConfig)(process.cwd());
25
27
  const manifest = ctx.manifest;
26
- console.log("📄 Manifest loaded");
27
- const distPath = ctx.dist;
28
- const androidRoot = await (0, template_1.ensureTemplate)();
29
28
  const mode = ctx.mode;
30
- const apkOutput = ctx.out;
31
- const assetsTarget = path_1.default.join(androidRoot, "app/src/main/assets/www");
32
29
  const appName = manifest?.app?.name || manifest?.appName || "Pakstr App";
30
+ const finalPath = path_1.default.resolve(ctx.out);
33
31
  console.log("\n🚀 Pakstr CLI");
34
32
  console.log("📦 App:", appName);
35
- console.log("🌐 Web:", distPath);
36
- console.log("📱 Out:", apkOutput);
37
- console.log("📱 Android:", androidRoot);
33
+ console.log("🌐 Web:", ctx.dist);
34
+ console.log("📱 Out:", finalPath);
38
35
  console.log("🏗️ Builder:", config.builder);
39
36
  console.log("🚀 BUILD MODE:", mode.toUpperCase());
40
- // -----------------------------
41
- // 1. VALIDATE PROJECT
42
- // -----------------------------
43
- (0, validateProject_1.validateProject)(distPath, manifest);
37
+ (0, validateProject_1.validateProject)(ctx.dist, manifest);
38
+ validateOutputParent(finalPath);
39
+ if (mode === "release") {
40
+ removeExistingOutput(finalPath);
41
+ }
42
+ const signingInput = mode === "release" ? (0, releaseSigning_1.readReleaseSigningInput)() : undefined;
43
+ const templateRoot = await (0, template_1.ensureTemplate)();
44
+ let workspace;
45
+ let signing;
46
+ let stagedPath;
47
+ let primaryError;
48
+ let cleanupError;
49
+ try {
50
+ const androidRoot = mode === "release"
51
+ ? (workspace = (0, releaseWorkspace_1.createReleaseWorkspace)(templateRoot)).androidRoot
52
+ : templateRoot;
53
+ console.log("📱 Android:", androidRoot);
54
+ await prepareAndroidProject(androidRoot, ctx.dist, manifest, appName);
55
+ if (signingInput) {
56
+ signing = (0, releaseSigning_1.createReleaseSigningContext)(signingInput);
57
+ signingInput.keystoreBase64 = "";
58
+ }
59
+ console.log("\n⚙️ Running build runner...");
60
+ const result = await (0, createRunner_1.createRunner)(config).build({
61
+ androidRoot,
62
+ mode,
63
+ expectedApplicationId: manifest.appId,
64
+ signing,
65
+ });
66
+ if (mode === "release" && !result.verification) {
67
+ throw new Error("Release runner returned no verification evidence");
68
+ }
69
+ stagedPath = stageArtifact(result.artifactPath, finalPath);
70
+ }
71
+ catch (error) {
72
+ primaryError = error;
73
+ }
74
+ try {
75
+ signing?.cleanup();
76
+ }
77
+ catch (error) {
78
+ cleanupError = error;
79
+ }
80
+ try {
81
+ workspace?.cleanup();
82
+ }
83
+ catch (error) {
84
+ cleanupError ??= error;
85
+ }
86
+ if (primaryError || cleanupError) {
87
+ removeStagedArtifact(stagedPath);
88
+ removeExistingOutput(finalPath);
89
+ if (primaryError) {
90
+ if (cleanupError) {
91
+ console.error("Cleanup also failed:", safeErrorMessage(cleanupError));
92
+ }
93
+ throw primaryError;
94
+ }
95
+ throw cleanupError;
96
+ }
97
+ if (!stagedPath) {
98
+ throw new Error("Build completed without a staged APK");
99
+ }
100
+ publishArtifact(stagedPath, finalPath);
101
+ console.log("\n🚀 BUILD COMPLETE");
102
+ console.log("App:", appName);
103
+ console.log("Mode:", mode.toUpperCase());
104
+ console.log("📦 Output:", finalPath);
105
+ openFolder(finalPath);
106
+ }
107
+ async function prepareAndroidProject(androidRoot, distPath, manifest, appName) {
108
+ const assetsTarget = path_1.default.join(androidRoot, "app/src/main/assets/www");
44
109
  if (!fs_1.default.existsSync(distPath)) {
45
- throw new Error("Web assets not found");
46
- }
47
- // -----------------------------
48
- // 2. COPY WEB ANDROID ASSETS
49
- // -----------------------------
50
- console.log("\n📦 Copying web → Android assets...");
51
- fs_1.default.rmSync(assetsTarget, {
52
- recursive: true,
53
- force: true,
54
- });
55
- fs_1.default.mkdirSync(assetsTarget, {
56
- recursive: true,
57
- });
110
+ throw new Error("Web assets not found");
111
+ }
112
+ fs_1.default.rmSync(assetsTarget, { recursive: true, force: true });
113
+ fs_1.default.mkdirSync(assetsTarget, { recursive: true });
58
114
  copyFolder(distPath, assetsTarget);
59
- console.log("📦 Web assets copied successfully");
60
115
  (0, runtimeConfig_1.generateRuntimeConfig)(assetsTarget, manifest);
61
- console.log("runtime files:", fs_1.default.readdirSync(assetsTarget)
62
- .filter(f => f.includes("pakstr")));
63
- console.log(fs_1.default.existsSync(path_1.default.join(assetsTarget, "pakstr-runtime.json")));
64
- console.log(fs_1.default.readdirSync(assetsTarget));
65
- // -----------------------------
66
- // 3. PATCH ANDROID PROJECT
67
- // -----------------------------
68
116
  (0, gradle_1.patchGradle)(androidRoot, manifest);
69
- console.log("⚙️ Gradle patched");
70
- // -----------------------------
71
- // 3.1 PATCH BRANDING
72
- // -----------------------------
73
117
  (0, branding_1.patchAppName)(androidRoot, appName);
74
118
  (0, branding_1.patchPackageName)(androidRoot, manifest.appId);
75
119
  (0, version_1.patchVersion)(androidRoot, manifest.versionCode, manifest.versionName);
76
- const iconPath = manifest.ui?.icon;
77
- if (iconPath) {
78
- await (0, icon_1.patchIcon)(androidRoot, iconPath, manifest.backgroundColor ?? "#FFFFFF");
120
+ if (manifest.ui?.icon) {
121
+ await (0, icon_1.patchIcon)(androidRoot, manifest.ui.icon, manifest.backgroundColor ?? "#FFFFFF");
79
122
  }
80
- const splash = manifest.ui?.splash;
81
- if (splash) {
82
- console.log("SPLASH IMAGE:", splash.image);
83
- console.log("SPLASH BACKGROUND:", splash.background);
84
- await (0, splash_1.patchSplash)(androidRoot, splash.image, splash.background ?? "#FFFFFF");
123
+ if (manifest.ui?.splash) {
124
+ await (0, splash_1.patchSplash)(androidRoot, manifest.ui.splash.image, manifest.ui.splash.background ?? "#FFFFFF");
85
125
  }
86
126
  (0, permissions_1.patchPermissions)(androidRoot, manifest);
87
- // -----------------------------
88
- // 4. RUN BUILD RUNNER
89
- // -----------------------------
90
- let apkPath;
91
- try {
92
- console.log("\n⚙️ Running build runner...");
93
- const runner = (0, createRunner_1.createRunner)(config);
94
- apkPath = await runner.build(androidRoot, mode);
95
- console.log("✅ APK build successful");
96
- console.log("\n📦 APK GENERATED:");
97
- console.log(apkPath);
98
- }
99
- catch (err) {
100
- console.error("❌ Build failed");
101
- console.error(err);
102
- throw err;
103
- }
104
- // -----------------------------
105
- // 5. COPY APK TO OUTPUT
106
- // -----------------------------
107
- const finalPath = path_1.default.resolve(apkOutput);
108
- fs_1.default.mkdirSync(path_1.default.dirname(finalPath), {
109
- recursive: true,
110
- });
127
+ }
128
+ function validateOutputParent(finalPath) {
129
+ const parent = path_1.default.dirname(finalPath);
130
+ if (!fs_1.default.existsSync(parent) || !fs_1.default.lstatSync(parent).isDirectory()) {
131
+ throw new Error(`Output directory must already exist: ${parent}`);
132
+ }
133
+ if (fs_1.default.lstatSync(parent).isSymbolicLink()) {
134
+ throw new Error(`Output directory must not be a symbolic link: ${parent}`);
135
+ }
111
136
  if (fs_1.default.existsSync(finalPath)) {
112
137
  const stat = fs_1.default.lstatSync(finalPath);
113
- if (stat.isDirectory()) {
114
- fs_1.default.rmSync(finalPath, {
115
- recursive: true,
116
- force: true,
117
- });
138
+ if (!stat.isFile() || stat.isSymbolicLink()) {
139
+ throw new Error(`Output path must be a regular file: ${finalPath}`);
118
140
  }
119
141
  }
120
- fs_1.default.copyFileSync(apkPath, finalPath);
121
- console.log("\n📦 CLEAN OUTPUT:");
122
- console.log(finalPath);
123
- openFolder(finalPath);
124
- // -----------------------------
125
- // 6. FINAL OUTPUT
126
- // -----------------------------
127
- console.log("\n🚀 BUILD COMPLETE");
128
- console.log("App:", appName);
129
- console.log("Mode:", mode.toUpperCase());
130
142
  }
131
- // -----------------------------
132
- // COPY FOLDER
133
- // -----------------------------
143
+ function stageArtifact(artifactPath, finalPath) {
144
+ const parent = path_1.default.dirname(finalPath);
145
+ const stageDir = fs_1.default.mkdtempSync(path_1.default.join(parent, ".pakstr-stage-"));
146
+ const stagedPath = path_1.default.join(stageDir, "app.apk");
147
+ fs_1.default.copyFileSync(artifactPath, stagedPath, fs_1.default.constants.COPYFILE_EXCL);
148
+ if (fs_1.default.statSync(stagedPath).size !== fs_1.default.statSync(artifactPath).size) {
149
+ fs_1.default.rmSync(stageDir, { recursive: true, force: true });
150
+ throw new Error("Staged APK size does not match verified artifact");
151
+ }
152
+ return stagedPath;
153
+ }
154
+ function publishArtifact(stagedPath, finalPath) {
155
+ removeExistingOutput(finalPath);
156
+ fs_1.default.renameSync(stagedPath, finalPath);
157
+ fs_1.default.rmSync(path_1.default.dirname(stagedPath), { recursive: true, force: true });
158
+ if (!fs_1.default.existsSync(finalPath) || !fs_1.default.lstatSync(finalPath).isFile()) {
159
+ throw new Error("Final APK publication failed");
160
+ }
161
+ }
162
+ function removeExistingOutput(finalPath) {
163
+ if (!fs_1.default.existsSync(finalPath))
164
+ return;
165
+ const stat = fs_1.default.lstatSync(finalPath);
166
+ if (!stat.isFile() || stat.isSymbolicLink()) {
167
+ throw new Error(`Refusing to remove non-regular output path: ${finalPath}`);
168
+ }
169
+ fs_1.default.unlinkSync(finalPath);
170
+ }
171
+ function removeStagedArtifact(stagedPath) {
172
+ if (!stagedPath)
173
+ return;
174
+ fs_1.default.rmSync(path_1.default.dirname(stagedPath), { recursive: true, force: true });
175
+ }
134
176
  function copyFolder(src, dest) {
135
- const entries = fs_1.default.readdirSync(src, {
136
- withFileTypes: true,
137
- });
138
- for (const entry of entries) {
177
+ for (const entry of fs_1.default.readdirSync(src, { withFileTypes: true })) {
139
178
  const srcPath = path_1.default.join(src, entry.name);
140
179
  const destPath = path_1.default.join(dest, entry.name);
141
180
  if (entry.isDirectory()) {
142
- fs_1.default.mkdirSync(destPath, {
143
- recursive: true,
144
- });
181
+ fs_1.default.mkdirSync(destPath, { recursive: true });
145
182
  copyFolder(srcPath, destPath);
146
183
  }
147
184
  else {
@@ -149,17 +186,16 @@ function copyFolder(src, dest) {
149
186
  }
150
187
  }
151
188
  }
152
- // -----------------------------
153
- // OPEN FOLDER (macOS)
154
- // -----------------------------
155
189
  function openFolder(filePath) {
156
- if (process.platform !== "darwin") {
190
+ if (process.platform !== "darwin" || process.env.CI)
157
191
  return;
158
- }
159
192
  try {
160
- (0, child_process_1.execSync)(`open "${path_1.default.dirname(filePath)}"`);
193
+ (0, child_process_1.execFileSync)("open", [path_1.default.dirname(filePath)], { stdio: "ignore" });
161
194
  }
162
195
  catch {
163
- // ignore
196
+ // Opening the output folder is optional.
164
197
  }
165
198
  }
199
+ function safeErrorMessage(error) {
200
+ return error instanceof Error ? error.message : "unknown cleanup error";
201
+ }
@@ -16,6 +16,16 @@ function loadManifest(path) {
16
16
  function validateManifest(m) {
17
17
  if (!m.appId)
18
18
  throw new Error("❌ appId is required");
19
+ const appIdPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
20
+ if (m.appId.length < 3 ||
21
+ m.appId.length > 255 ||
22
+ !appIdPattern.test(m.appId)) {
23
+ throw new Error("❌ appId must be a lowercase reverse-domain Android Application ID");
24
+ }
25
+ const debugAppId = `${m.appId}.debug`;
26
+ if (debugAppId.length > 255 || !appIdPattern.test(debugAppId)) {
27
+ throw new Error("❌ appId is too long to derive the debug Application ID");
28
+ }
19
29
  if (!m.appName)
20
30
  throw new Error("❌ appName is required");
21
31
  if (!m.versionName)
@@ -0,0 +1,134 @@
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.INTERNAL_KEYSTORE_PATH_ENV = exports.PUBLIC_SIGNING_ENV = void 0;
7
+ exports.readReleaseSigningInput = readReleaseSigningInput;
8
+ exports.createReleaseSigningContext = createReleaseSigningContext;
9
+ exports.removeSigningVariables = removeSigningVariables;
10
+ exports.createGradleSigningEnvironment = createGradleSigningEnvironment;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const os_1 = __importDefault(require("os"));
13
+ const path_1 = __importDefault(require("path"));
14
+ exports.PUBLIC_SIGNING_ENV = {
15
+ keystoreBase64: "PAKSTR_ANDROID_KEYSTORE_BASE64",
16
+ keystorePassword: "PAKSTR_ANDROID_KEYSTORE_PASSWORD",
17
+ keyAlias: "PAKSTR_ANDROID_KEY_ALIAS",
18
+ keyPassword: "PAKSTR_ANDROID_KEY_PASSWORD",
19
+ };
20
+ exports.INTERNAL_KEYSTORE_PATH_ENV = "PAKSTR_ANDROID_KEYSTORE_PATH";
21
+ const MAX_KEYSTORE_BYTES = 16 * 1024 * 1024;
22
+ const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
23
+ function readReleaseSigningInput(env = process.env) {
24
+ const keystoreBase64 = requireValue(env, exports.PUBLIC_SIGNING_ENV.keystoreBase64);
25
+ const storePassword = requireValue(env, exports.PUBLIC_SIGNING_ENV.keystorePassword);
26
+ const keyAlias = requireValue(env, exports.PUBLIC_SIGNING_ENV.keyAlias);
27
+ const keyPassword = requireValue(env, exports.PUBLIC_SIGNING_ENV.keyPassword);
28
+ validateCanonicalBase64(keystoreBase64);
29
+ return {
30
+ keystoreBase64,
31
+ storePassword,
32
+ keyAlias,
33
+ keyPassword,
34
+ };
35
+ }
36
+ function createReleaseSigningContext(input, temporaryRoot = os_1.default.tmpdir()) {
37
+ let decoded = decodeKeystore(input.keystoreBase64);
38
+ fs_1.default.mkdirSync(temporaryRoot, { recursive: true, mode: 0o700 });
39
+ const signingDir = fs_1.default.mkdtempSync(path_1.default.join(temporaryRoot, "pakstr-signing-"));
40
+ const keystorePath = path_1.default.join(signingDir, "release.keystore");
41
+ let cleaned = false;
42
+ try {
43
+ fs_1.default.chmodSync(signingDir, 0o700);
44
+ fs_1.default.writeFileSync(keystorePath, decoded, {
45
+ flag: "wx",
46
+ mode: 0o600,
47
+ });
48
+ verifyProtectedKeystore(signingDir, keystorePath, decoded.length);
49
+ }
50
+ catch (error) {
51
+ fs_1.default.rmSync(signingDir, { recursive: true, force: true });
52
+ throw error;
53
+ }
54
+ finally {
55
+ decoded.fill(0);
56
+ decoded = Buffer.alloc(0);
57
+ }
58
+ return Object.freeze({
59
+ keystorePath,
60
+ storePassword: input.storePassword,
61
+ keyAlias: input.keyAlias,
62
+ keyPassword: input.keyPassword,
63
+ cleanup() {
64
+ if (cleaned)
65
+ return;
66
+ fs_1.default.rmSync(signingDir, { recursive: true, force: true, maxRetries: 2 });
67
+ if (fs_1.default.existsSync(signingDir)) {
68
+ throw new Error(`Failed to remove temporary signing directory: ${signingDir}`);
69
+ }
70
+ cleaned = true;
71
+ },
72
+ });
73
+ }
74
+ function removeSigningVariables(env) {
75
+ const sanitized = { ...env };
76
+ delete sanitized[exports.PUBLIC_SIGNING_ENV.keystoreBase64];
77
+ delete sanitized[exports.PUBLIC_SIGNING_ENV.keystorePassword];
78
+ delete sanitized[exports.PUBLIC_SIGNING_ENV.keyAlias];
79
+ delete sanitized[exports.PUBLIC_SIGNING_ENV.keyPassword];
80
+ delete sanitized[exports.INTERNAL_KEYSTORE_PATH_ENV];
81
+ return sanitized;
82
+ }
83
+ function createGradleSigningEnvironment(context, env = process.env, keystorePath = context.keystorePath) {
84
+ return {
85
+ ...removeSigningVariables(env),
86
+ [exports.INTERNAL_KEYSTORE_PATH_ENV]: keystorePath,
87
+ [exports.PUBLIC_SIGNING_ENV.keystorePassword]: context.storePassword,
88
+ [exports.PUBLIC_SIGNING_ENV.keyAlias]: context.keyAlias,
89
+ [exports.PUBLIC_SIGNING_ENV.keyPassword]: context.keyPassword,
90
+ };
91
+ }
92
+ function requireValue(env, name) {
93
+ const value = env[name];
94
+ if (value === undefined || value.length === 0) {
95
+ throw new Error(`${name} is required and must not be empty for release builds`);
96
+ }
97
+ return value;
98
+ }
99
+ function validateCanonicalBase64(value) {
100
+ if (!CANONICAL_BASE64.test(value)) {
101
+ throw new Error(`${exports.PUBLIC_SIGNING_ENV.keystoreBase64} must be canonical single-line RFC 4648 base64`);
102
+ }
103
+ const decodedLength = Buffer.byteLength(value, "base64");
104
+ if (decodedLength === 0) {
105
+ throw new Error(`${exports.PUBLIC_SIGNING_ENV.keystoreBase64} decodes to an empty keystore`);
106
+ }
107
+ if (decodedLength > MAX_KEYSTORE_BYTES) {
108
+ throw new Error(`${exports.PUBLIC_SIGNING_ENV.keystoreBase64} exceeds the 16 MiB decoded size limit`);
109
+ }
110
+ }
111
+ function decodeKeystore(value) {
112
+ const decoded = Buffer.from(value, "base64");
113
+ if (decoded.toString("base64") !== value) {
114
+ decoded.fill(0);
115
+ throw new Error(`${exports.PUBLIC_SIGNING_ENV.keystoreBase64} is not canonical base64`);
116
+ }
117
+ return decoded;
118
+ }
119
+ function verifyProtectedKeystore(signingDir, keystorePath, expectedSize) {
120
+ const dirStat = fs_1.default.lstatSync(signingDir);
121
+ const fileStat = fs_1.default.lstatSync(keystorePath);
122
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
123
+ throw new Error("Temporary signing directory is not a real directory");
124
+ }
125
+ if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
126
+ throw new Error("Temporary keystore is not a regular file");
127
+ }
128
+ if ((dirStat.mode & 0o777) !== 0o700 || (fileStat.mode & 0o777) !== 0o600) {
129
+ throw new Error("Temporary signing storage does not have owner-only permissions");
130
+ }
131
+ if (fileStat.size !== expectedSize) {
132
+ throw new Error("Temporary keystore size does not match decoded input");
133
+ }
134
+ }
@@ -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
+ expectedApplicationId: request.expectedApplicationId,
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,108 @@ 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
+ expectedApplicationId: request.expectedApplicationId,
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 signer = versions
84
+ .map(version => path_1.default.join(buildToolsDir, version, "apksigner"))
85
+ .find(candidate => fs_1.default.existsSync(candidate));
86
+ if (!signer) {
87
+ throw new Error(`Required Android verification tool apksigner not found under ${buildToolsDir}`);
38
88
  }
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;
89
+ return signer;
90
+ }
91
+ function requireApk(androidRoot, mode) {
92
+ const artifactPath = path_1.default.join(androidRoot, "app", "build", "outputs", "apk", mode, `app-${mode}.apk`);
93
+ if (!fs_1.default.existsSync(artifactPath) || !fs_1.default.lstatSync(artifactPath).isFile()) {
94
+ throw new Error(`APK not found after Gradle build: ${artifactPath}`);
95
+ }
96
+ return artifactPath;
97
+ }
98
+ function requireSigning(signing) {
99
+ if (!signing) {
100
+ throw new Error("Release build requires an internal signing context");
44
101
  }
45
- return path_1.default.join(apkDir, apk);
102
+ return signing;
46
103
  }
47
- function fsExists(file) {
48
- const fs = require("fs");
49
- return fs.existsSync(file);
104
+ function signingSecrets(signing) {
105
+ return [
106
+ signing.storePassword,
107
+ signing.keyAlias,
108
+ signing.keyPassword,
109
+ signing.keystorePath,
110
+ ];
50
111
  }
@@ -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.0",
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"