pakstr 0.29.0 → 0.30.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 +10 -0
- package/android-template/app/src/debug/AndroidManifest.xml +5 -2
- package/android-template/app/src/main/AndroidManifest.xml +1 -0
- package/dist/cli.js +13 -6
- package/dist/commands/artifactPublisher.js +125 -0
- package/dist/commands/build.js +16 -30
- package/dist/commands/publish.js +37 -3
- package/dist/commands/run.js +60 -29
- package/dist/core/apkSignature.js +197 -0
- package/dist/core/nsite.js +49 -0
- package/dist/runners/DockerGradleRunner.js +45 -24
- package/dist/runners/DockerSignRunner.js +58 -19
- package/dist/runners/LocalGradleRunner.js +7 -0
- package/dist/runners/process.js +51 -0
- package/dist/runners/timeouts.js +66 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -95,6 +95,16 @@ containers alike, because all of them are created the same way. Leave it unset o
|
|
|
95
95
|
workstation, where Docker's default network is what you want - host networking is not
|
|
96
96
|
available on Docker Desktop for macOS.
|
|
97
97
|
|
|
98
|
+
**Timeouts.** So a stalled build or an unresponsive Docker daemon fails instead of hanging
|
|
99
|
+
the job, the Gradle build and the builder image pull are stopped after 60 minutes, signing
|
|
100
|
+
after 15, file copies into and out of containers after 10, and other Docker calls after 2.
|
|
101
|
+
Containers left by a stopped or failed run are removed. If a cold build on a slow runner needs
|
|
102
|
+
longer, raise the build limit with `PAKSTR_BUILD_TIMEOUT_MINUTES` (`0` disables it):
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
PAKSTR_BUILD_TIMEOUT_MINUTES=120 npx pakstr run
|
|
106
|
+
```
|
|
107
|
+
|
|
98
108
|
---
|
|
99
109
|
|
|
100
110
|
## Documentation & License
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
-
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
3
|
-
|
|
2
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
3
|
+
xmlns:tools="http://schemas.android.com/tools">
|
|
4
|
+
<application
|
|
5
|
+
android:memtagMode="sync"
|
|
6
|
+
tools:replace="android:memtagMode">
|
|
4
7
|
<activity-alias
|
|
5
8
|
android:name=".debug.DeveloperToolsDebugLauncher"
|
|
6
9
|
android:enabled="true"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
android:fullBackupContent="@xml/backup_rules"
|
|
24
24
|
android:icon="@mipmap/ic_launcher"
|
|
25
25
|
android:label="@string/app_name"
|
|
26
|
+
android:memtagMode="async"
|
|
26
27
|
android:networkSecurityConfig="@xml/network_security_config"
|
|
27
28
|
android:roundIcon="@mipmap/ic_launcher_round"
|
|
28
29
|
android:supportsRtl="true"
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.runCLI = runCLI;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
7
8
|
const build_1 = require("./commands/build");
|
|
8
9
|
const sign_1 = require("./commands/sign");
|
|
9
10
|
const publish_1 = require("./commands/publish");
|
|
@@ -15,9 +16,14 @@ const deletePublisher_1 = require("./commands/deletePublisher");
|
|
|
15
16
|
const dotenv_1 = require("./core/dotenv");
|
|
16
17
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
17
18
|
async function runCLI(argv) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
(
|
|
19
|
+
const command = argv[0];
|
|
20
|
+
const args = argv.slice(1);
|
|
21
|
+
const configFlag = extractFlag(args, "--config");
|
|
22
|
+
// Auto-load the `.env` (gitignored) next to the config file, where
|
|
23
|
+
// `pakstr init` writes it, so `pakstr run` works out of the box. Without
|
|
24
|
+
// --config that is the current directory. Existing env vars (e.g. CI
|
|
25
|
+
// secrets) always win.
|
|
26
|
+
(0, dotenv_1.loadDotEnv)(configDir(configFlag));
|
|
21
27
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
22
28
|
printHelp();
|
|
23
29
|
return;
|
|
@@ -26,13 +32,10 @@ async function runCLI(argv) {
|
|
|
26
32
|
console.log(package_json_1.default.version);
|
|
27
33
|
return;
|
|
28
34
|
}
|
|
29
|
-
const command = argv[0];
|
|
30
|
-
const args = argv.slice(1);
|
|
31
35
|
if (!command) {
|
|
32
36
|
printHelp();
|
|
33
37
|
return;
|
|
34
38
|
}
|
|
35
|
-
const configFlag = extractFlag(args, "--config");
|
|
36
39
|
switch (command) {
|
|
37
40
|
case "init":
|
|
38
41
|
await (0, init_1.initCommand)({ force: args.includes("--force"), out: configFlag });
|
|
@@ -99,6 +102,10 @@ function extractFlag(args, flag) {
|
|
|
99
102
|
return undefined;
|
|
100
103
|
return args[i + 1];
|
|
101
104
|
}
|
|
105
|
+
/** Directory of the config file, resolved the same way as loadPakstrConfig. */
|
|
106
|
+
function configDir(configFlag) {
|
|
107
|
+
return configFlag ? path_1.default.dirname(path_1.default.resolve(configFlag)) : process.cwd();
|
|
108
|
+
}
|
|
102
109
|
function printHelp() {
|
|
103
110
|
console.log(`
|
|
104
111
|
Pakstr — one-step nsec -> build -> sign -> publish.
|
|
@@ -0,0 +1,125 @@
|
|
|
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.stageArtifact = stageArtifact;
|
|
7
|
+
exports.publishArtifact = publishArtifact;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
/**
|
|
11
|
+
* Copy `artifactPath` into a fresh staging directory next to `finalPath`, so
|
|
12
|
+
* publishArtifact can move it into place with a same-filesystem rename.
|
|
13
|
+
* Returns the staged path; the staging directory is removed on failure.
|
|
14
|
+
* `description` names the artifact in errors (e.g. "built APK").
|
|
15
|
+
*/
|
|
16
|
+
function stageArtifact(artifactPath, finalPath, description = "built APK") {
|
|
17
|
+
const parent = path_1.default.dirname(finalPath);
|
|
18
|
+
const stageDir = fs_1.default.mkdtempSync(path_1.default.join(parent, ".pakstr-stage-"));
|
|
19
|
+
const stagedPath = path_1.default.join(stageDir, "app.apk");
|
|
20
|
+
try {
|
|
21
|
+
fs_1.default.copyFileSync(artifactPath, stagedPath, fs_1.default.constants.COPYFILE_EXCL);
|
|
22
|
+
if (fs_1.default.statSync(stagedPath).size !== fs_1.default.statSync(artifactPath).size) {
|
|
23
|
+
throw new Error(`Staged copy of the ${description} is incomplete (size mismatch); ` +
|
|
24
|
+
"the existing output was left unchanged");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
fs_1.default.rmSync(stageDir, { recursive: true, force: true });
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
return stagedPath;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Publish a staged APK at `finalPath` without ever leaving the user without
|
|
35
|
+
* their previous APK.
|
|
36
|
+
*
|
|
37
|
+
* The previous APK is copied into the staging directory, then the staged APK
|
|
38
|
+
* atomically replaces `finalPath` (it is never deleted first). If publication
|
|
39
|
+
* fails, the previous APK is restored from the backup. The staging directory
|
|
40
|
+
* (which holds the backup) is only removed once `finalPath` is known to hold a
|
|
41
|
+
* good APK; if restoring fails it is kept so the backup is never lost.
|
|
42
|
+
*/
|
|
43
|
+
function publishArtifact(stagedPath, finalPath) {
|
|
44
|
+
const stageDir = path_1.default.dirname(stagedPath);
|
|
45
|
+
const backupPath = path_1.default.join(stageDir, "previous.apk");
|
|
46
|
+
if (isInside(path_1.default.resolve(finalPath), path_1.default.resolve(stageDir))) {
|
|
47
|
+
throw new Error(`Staging directory must not contain the output APK: ${stageDir}`);
|
|
48
|
+
}
|
|
49
|
+
let hadPreviousOutput;
|
|
50
|
+
try {
|
|
51
|
+
hadPreviousOutput = existingOutputIsFile(finalPath);
|
|
52
|
+
if (hadPreviousOutput) {
|
|
53
|
+
fs_1.default.copyFileSync(finalPath, backupPath, fs_1.default.constants.COPYFILE_EXCL);
|
|
54
|
+
if (fs_1.default.statSync(backupPath).size !== fs_1.default.statSync(finalPath).size) {
|
|
55
|
+
throw new Error("Backup of the previous APK is incomplete");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// `finalPath` has not been touched yet, so the staging directory holds
|
|
61
|
+
// nothing worth keeping.
|
|
62
|
+
removeStageDir(stageDir);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
let replacedOutput = false;
|
|
66
|
+
try {
|
|
67
|
+
fs_1.default.renameSync(stagedPath, finalPath);
|
|
68
|
+
replacedOutput = true;
|
|
69
|
+
const stat = fs_1.default.lstatSync(finalPath);
|
|
70
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
71
|
+
throw new Error("Final APK publication failed");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
try {
|
|
76
|
+
restorePreviousOutput(finalPath, backupPath, hadPreviousOutput, replacedOutput);
|
|
77
|
+
}
|
|
78
|
+
catch (restoreError) {
|
|
79
|
+
throw new Error(`Failed to publish the new APK and restore the previous APK. ` +
|
|
80
|
+
`Previous APK kept at: ${backupPath}`, { cause: new AggregateError([error, restoreError]) });
|
|
81
|
+
}
|
|
82
|
+
removeStageDir(stageDir);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
removeStageDir(stageDir);
|
|
86
|
+
}
|
|
87
|
+
function restorePreviousOutput(finalPath, backupPath, hadPreviousOutput, replacedOutput) {
|
|
88
|
+
if (!hadPreviousOutput) {
|
|
89
|
+
// Don't leave a rejected artifact behind at the output path.
|
|
90
|
+
if (replacedOutput && fs_1.default.lstatSync(finalPath, { throwIfNoEntry: false })?.isFile()) {
|
|
91
|
+
fs_1.default.unlinkSync(finalPath);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// A failed rename leaves the destination untouched, so the previous APK is
|
|
96
|
+
// usually still in place; only restore when it was replaced or is missing.
|
|
97
|
+
if (!replacedOutput && existingOutputIsFile(finalPath))
|
|
98
|
+
return;
|
|
99
|
+
fs_1.default.renameSync(backupPath, finalPath);
|
|
100
|
+
if (!existingOutputIsFile(finalPath)) {
|
|
101
|
+
throw new Error("Restored APK is not a regular file");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function existingOutputIsFile(finalPath) {
|
|
105
|
+
const stat = fs_1.default.lstatSync(finalPath, { throwIfNoEntry: false });
|
|
106
|
+
if (!stat)
|
|
107
|
+
return false;
|
|
108
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
109
|
+
throw new Error(`Output path must be a regular file: ${finalPath}`);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
function removeStageDir(stageDir) {
|
|
114
|
+
try {
|
|
115
|
+
fs_1.default.rmSync(stageDir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
console.warn("Warning: failed to remove APK staging directory:", error instanceof Error ? error.message : "unknown cleanup error");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isInside(child, parent) {
|
|
122
|
+
const relative = path_1.default.relative(parent, child);
|
|
123
|
+
return (relative === "" ||
|
|
124
|
+
(relative.split(path_1.default.sep)[0] !== ".." && !path_1.default.isAbsolute(relative)));
|
|
125
|
+
}
|
package/dist/commands/build.js
CHANGED
|
@@ -11,6 +11,7 @@ const releaseWorkspace_1 = require("../android/releaseWorkspace");
|
|
|
11
11
|
const pakstrConfig_1 = require("../core/pakstrConfig");
|
|
12
12
|
const androidProject_1 = require("../core/androidProject");
|
|
13
13
|
const createRunner_1 = require("../runners/createRunner");
|
|
14
|
+
const artifactPublisher_1 = require("./artifactPublisher");
|
|
14
15
|
/**
|
|
15
16
|
* `pakstr build` — produce an UNSIGNED release APK at `build.out`.
|
|
16
17
|
* Spec §5.2. No nsec required.
|
|
@@ -24,7 +25,11 @@ async function buildCommand(configPath) {
|
|
|
24
25
|
console.log("📱 Out:", finalPath);
|
|
25
26
|
console.log("🏗️ Builder:", config.build.builder);
|
|
26
27
|
validateOutputParent(finalPath);
|
|
27
|
-
|
|
28
|
+
// Whatever happens next, the APK at finalPath is no longer the signed output
|
|
29
|
+
// of this config: on success it is replaced by a new unsigned APK, and on
|
|
30
|
+
// failure it is kept but may be an older build (e.g. a previous versionCode).
|
|
31
|
+
// Removing its sidecars makes publish refuse it until `pakstr sign` runs.
|
|
32
|
+
removeStaleSigningSidecars(finalPath);
|
|
28
33
|
const templateRoot = await (0, template_1.ensureTemplate)();
|
|
29
34
|
const workspace = (0, releaseWorkspace_1.createReleaseWorkspace)(templateRoot);
|
|
30
35
|
let stagedPath;
|
|
@@ -53,7 +58,7 @@ async function buildCommand(configPath) {
|
|
|
53
58
|
if (!result.unsigned) {
|
|
54
59
|
throw new Error("Build runner did not produce an unsigned release APK");
|
|
55
60
|
}
|
|
56
|
-
stagedPath = stageArtifact(result.artifactPath, finalPath);
|
|
61
|
+
stagedPath = (0, artifactPublisher_1.stageArtifact)(result.artifactPath, finalPath);
|
|
57
62
|
}
|
|
58
63
|
catch (error) {
|
|
59
64
|
primaryError = error;
|
|
@@ -67,7 +72,6 @@ async function buildCommand(configPath) {
|
|
|
67
72
|
}
|
|
68
73
|
if (primaryError || cleanupError) {
|
|
69
74
|
removeStagedArtifact(stagedPath);
|
|
70
|
-
removeExistingOutput(finalPath);
|
|
71
75
|
if (primaryError) {
|
|
72
76
|
if (cleanupError) {
|
|
73
77
|
console.error("Workspace cleanup also failed:", safeErrorMessage(cleanupError));
|
|
@@ -79,7 +83,7 @@ async function buildCommand(configPath) {
|
|
|
79
83
|
if (!stagedPath) {
|
|
80
84
|
throw new Error("Build completed without a staged APK");
|
|
81
85
|
}
|
|
82
|
-
publishArtifact(stagedPath, finalPath);
|
|
86
|
+
(0, artifactPublisher_1.publishArtifact)(stagedPath, finalPath);
|
|
83
87
|
console.log("\n✅ BUILD COMPLETE (unsigned)");
|
|
84
88
|
console.log("📦 Output:", finalPath);
|
|
85
89
|
console.log("➡️ Next: pakstr sign");
|
|
@@ -102,33 +106,15 @@ function validateOutputParent(finalPath) {
|
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
|
-
function
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
return stagedPath;
|
|
115
|
-
}
|
|
116
|
-
function publishArtifact(stagedPath, finalPath) {
|
|
117
|
-
removeExistingOutput(finalPath);
|
|
118
|
-
fs_1.default.renameSync(stagedPath, finalPath);
|
|
119
|
-
fs_1.default.rmSync(path_1.default.dirname(stagedPath), { recursive: true, force: true });
|
|
120
|
-
if (!fs_1.default.existsSync(finalPath) || !fs_1.default.lstatSync(finalPath).isFile()) {
|
|
121
|
-
throw new Error("Final APK publication failed");
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function removeExistingOutput(finalPath) {
|
|
125
|
-
if (!fs_1.default.existsSync(finalPath))
|
|
126
|
-
return;
|
|
127
|
-
const stat = fs_1.default.lstatSync(finalPath);
|
|
128
|
-
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
129
|
-
throw new Error(`Refusing to remove non-regular output path: ${finalPath}`);
|
|
109
|
+
function removeStaleSigningSidecars(apkPath) {
|
|
110
|
+
for (const sidecar of [`${apkPath}.signer-sha256`, `${apkPath}.identity-proof`]) {
|
|
111
|
+
try {
|
|
112
|
+
fs_1.default.rmSync(sidecar, { force: true });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.warn(`Warning: could not remove stale signing sidecar ${sidecar}:`, safeErrorMessage(error));
|
|
116
|
+
}
|
|
130
117
|
}
|
|
131
|
-
fs_1.default.unlinkSync(finalPath);
|
|
132
118
|
}
|
|
133
119
|
function removeStagedArtifact(stagedPath) {
|
|
134
120
|
if (!stagedPath)
|
package/dist/commands/publish.js
CHANGED
|
@@ -10,6 +10,7 @@ const artifactUpload_1 = require("../core/artifactUpload");
|
|
|
10
10
|
const blossom_1 = require("../core/blossom");
|
|
11
11
|
const giteaRelease_1 = require("../core/giteaRelease");
|
|
12
12
|
const identityProof_1 = require("../core/identityProof");
|
|
13
|
+
const apkSignature_1 = require("../core/apkSignature");
|
|
13
14
|
const nostr_1 = require("../core/nostr");
|
|
14
15
|
const pakstrConfig_1 = require("../core/pakstrConfig");
|
|
15
16
|
const nsite_1 = require("../core/nsite");
|
|
@@ -56,6 +57,9 @@ async function publishCommand(configPath, options = {}) {
|
|
|
56
57
|
dryRun: options.dryRun,
|
|
57
58
|
fetchImpl,
|
|
58
59
|
});
|
|
60
|
+
reportNsitePartialFailures(nsite);
|
|
61
|
+
if (options.verify && !options.dryRun)
|
|
62
|
+
await (0, nsite_1.verifyNsite)(nsite, transport, fetchImpl);
|
|
59
63
|
const result = { artifactUrl: "", uploadProvider: null, nsite };
|
|
60
64
|
if (options.outJson) {
|
|
61
65
|
fs_1.default.writeFileSync(options.outJson, JSON.stringify({
|
|
@@ -95,6 +99,10 @@ async function publishCommand(configPath, options = {}) {
|
|
|
95
99
|
throw new Error(`Signer certificate sidecar not found: ${certShaPath}. Run \`pakstr sign\` first.`);
|
|
96
100
|
}
|
|
97
101
|
signerCertificateSha256 = fs_1.default.readFileSync(certShaPath, "utf8").trim();
|
|
102
|
+
// The sidecar must describe the APK actually being published: a rebuilt or
|
|
103
|
+
// failed-sign APK left next to sidecars from an earlier sign is refused.
|
|
104
|
+
// The identity proof is bound to this certificate by parseIdentityProofSidecar.
|
|
105
|
+
assertApkSignedWith(apkPath, signerCertificateSha256, certShaPath);
|
|
98
106
|
const proofPath = `${apkPath}.identity-proof`;
|
|
99
107
|
if (zapstoreEnabled && fs_1.default.existsSync(proofPath)) {
|
|
100
108
|
identityProof = (0, identityProof_1.parseIdentityProofSidecar)(fs_1.default.readFileSync(proofPath, "utf8").trim(), proofPath, signerCertificateSha256);
|
|
@@ -300,9 +308,9 @@ async function publishCommand(configPath, options = {}) {
|
|
|
300
308
|
dryRun: options.dryRun,
|
|
301
309
|
fetchImpl,
|
|
302
310
|
});
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
311
|
+
reportNsitePartialFailures(nsiteResult);
|
|
312
|
+
if (options.verify && !options.dryRun)
|
|
313
|
+
await (0, nsite_1.verifyNsite)(nsiteResult, transport, fetchImpl);
|
|
306
314
|
}
|
|
307
315
|
const result = {
|
|
308
316
|
artifactUrl,
|
|
@@ -343,3 +351,29 @@ async function publishCommand(configPath, options = {}) {
|
|
|
343
351
|
}
|
|
344
352
|
return result;
|
|
345
353
|
}
|
|
354
|
+
function reportNsitePartialFailures(result) {
|
|
355
|
+
if (result.partialFailures > 0) {
|
|
356
|
+
console.log(`⚠️ Nsite published with ${result.partialFailures} target failure(s)`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function assertApkSignedWith(apkPath, expectedSha256, certShaPath) {
|
|
360
|
+
if (!/^[0-9a-f]{64}$/.test(expectedSha256)) {
|
|
361
|
+
throw new Error(`Signer certificate sidecar ${certShaPath} is not a lowercase SHA-256. Run \`pakstr sign\` again.`);
|
|
362
|
+
}
|
|
363
|
+
let actual;
|
|
364
|
+
try {
|
|
365
|
+
actual = (0, apkSignature_1.readApkSignerCertificateSha256)(apkPath);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (!(error instanceof apkSignature_1.ApkSignatureError))
|
|
369
|
+
throw error;
|
|
370
|
+
if (error.reason === "unsigned") {
|
|
371
|
+
throw new Error(`APK at ${apkPath} is not signed (${error.message}). Run \`pakstr sign\` first.`);
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`Cannot use the APK at ${apkPath}: ${error.message}. Rebuild and sign it with \`pakstr run\`.`);
|
|
374
|
+
}
|
|
375
|
+
if (actual !== expectedSha256) {
|
|
376
|
+
throw new Error(`APK at ${apkPath} is signed with certificate ${actual}, but ${certShaPath} expects ${expectedSha256}. ` +
|
|
377
|
+
"The sidecar is stale or the APK was replaced; run `pakstr sign` to sign the current APK.");
|
|
378
|
+
}
|
|
379
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -25,19 +25,31 @@ const publish_1 = require("./publish");
|
|
|
25
25
|
async function runCommand(configPath, options = {}) {
|
|
26
26
|
// Step 1: load & validate config, pre-validate nsecs.
|
|
27
27
|
const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
|
|
28
|
-
// Verify PAKSTR_NSEC
|
|
29
|
-
|
|
28
|
+
// Verify PAKSTR_NSEC (fail fast before any work). A dry run skips build and
|
|
29
|
+
// sign, so it only needs the publish key checked below — which is
|
|
30
|
+
// PAKSTR_NSEC itself unless publish.publishKey names a separate one.
|
|
31
|
+
if (!options.dryRun)
|
|
32
|
+
(0, nostr_1.requireSigningNsec)(process.env);
|
|
30
33
|
// Pre-validate the Nostr publish nsec when Zapstore, Blossom, or nsite needs it.
|
|
31
34
|
if (config.publish.zapstoreEnabled || config.publish.nsite.enabled || (config.publish.upload.enabled && config.publish.upload.provider === "blossom")) {
|
|
35
|
+
const envVar = config.publish.publishKey === undefined
|
|
36
|
+
? "PAKSTR_NSEC"
|
|
37
|
+
: config.publish.publishKey === null
|
|
38
|
+
? "PAKSTR_PUBLISH_NSEC"
|
|
39
|
+
: config.publish.publishKey;
|
|
32
40
|
if (!(0, nostr_1.isPublishNsecPresent)(config.publish.publishKey, process.env)) {
|
|
33
|
-
const envVar = config.publish.publishKey === undefined
|
|
34
|
-
? "PAKSTR_NSEC"
|
|
35
|
-
: config.publish.publishKey === null
|
|
36
|
-
? "PAKSTR_PUBLISH_NSEC"
|
|
37
|
-
: config.publish.publishKey;
|
|
38
41
|
throw new Error(`${envVar} is required for publishing and must not be empty. ` +
|
|
39
42
|
`Pre-validation failed before any build work.`);
|
|
40
43
|
}
|
|
44
|
+
// Decode it too, so a malformed key fails here rather than after build
|
|
45
|
+
// and sign (where sign would also silently skip the identity proof).
|
|
46
|
+
try {
|
|
47
|
+
(0, nostr_1.resolvePublishNsec)(config.publish.publishKey, process.env);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error(`${envVar} is not a valid nsec: ${error instanceof Error ? error.message : String(error)}. ` +
|
|
51
|
+
`Pre-validation failed before any build work.`);
|
|
52
|
+
}
|
|
41
53
|
}
|
|
42
54
|
console.log("\n🚀 pakstr run");
|
|
43
55
|
console.log("App:", config.app.appName, `(${config.app.appId})`);
|
|
@@ -62,6 +74,9 @@ async function runCommand(configPath, options = {}) {
|
|
|
62
74
|
console.log("\n✅ pakstr run complete (dry-run)");
|
|
63
75
|
return;
|
|
64
76
|
}
|
|
77
|
+
// Keystores already on disk belong to the user (pakstr never writes one), so
|
|
78
|
+
// only files that appear or change during this run are treated as leaks.
|
|
79
|
+
const keystoresBefore = findKeystores(config);
|
|
65
80
|
// Step 3: build the unsigned APK.
|
|
66
81
|
console.log("\n— Step 1/3: build —");
|
|
67
82
|
await (0, build_1.buildCommand)(config.configPath || undefined);
|
|
@@ -81,36 +96,52 @@ async function runCommand(configPath, options = {}) {
|
|
|
81
96
|
}
|
|
82
97
|
// Step 6: cleanup. The signed APK at build.out is the artifact and is kept.
|
|
83
98
|
// Derived signing material is cleaned up inside the sign runner (tmpfs).
|
|
84
|
-
verifyNoLeakedKeystores(config);
|
|
99
|
+
verifyNoLeakedKeystores(config, keystoresBefore);
|
|
85
100
|
console.log("\n✅ pakstr run complete");
|
|
86
101
|
console.log("Signed APK:", path_1.default.resolve(config.build.out));
|
|
87
102
|
}
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Keystore files under `<configDir>/build`, mapped to a fingerprint that
|
|
105
|
+
* changes when a file is replaced (inode) or written in place (size, mtime,
|
|
106
|
+
* and ctime, which also changes if mtime is reset afterwards).
|
|
107
|
+
*/
|
|
108
|
+
function findKeystores(config) {
|
|
109
|
+
const found = new Map();
|
|
93
110
|
const walk = (dir) => {
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const entry of entries) {
|
|
96
119
|
const p = path_1.default.join(dir, entry.name);
|
|
97
120
|
if (entry.isDirectory())
|
|
98
|
-
|
|
99
|
-
else if (entry.name.endsWith(".p12") || entry.name.endsWith(".keystore"))
|
|
100
|
-
|
|
121
|
+
walk(p);
|
|
122
|
+
else if (entry.name.endsWith(".p12") || entry.name.endsWith(".keystore")) {
|
|
123
|
+
const stat = fs_1.default.lstatSync(p, { throwIfNoEntry: false });
|
|
124
|
+
if (stat) {
|
|
125
|
+
found.set(p, [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
101
128
|
}
|
|
102
|
-
return found;
|
|
103
129
|
};
|
|
104
|
-
|
|
130
|
+
walk(path_1.default.join(config.configDir, "build"));
|
|
131
|
+
return found;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Ensure no derived keystore leaked onto disk during this run (§7). Never
|
|
135
|
+
* deletes anything: a keystore may be the user's own (customer-keystore flow,
|
|
136
|
+
* exported derived keys), and pakstr cannot tell the two apart reliably.
|
|
137
|
+
*/
|
|
138
|
+
function verifyNoLeakedKeystores(config, before) {
|
|
139
|
+
const leaked = [...findKeystores(config)]
|
|
140
|
+
.filter(([file, fingerprint]) => before.get(file) !== fingerprint)
|
|
141
|
+
.map(([file]) => file);
|
|
105
142
|
if (leaked.length > 0) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
/* ignore */
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
throw new Error(`Derived keystore material leaked onto disk: ${leaked.join(", ")}`);
|
|
143
|
+
throw new Error(`Keystore files appeared or changed during this run: ${leaked.join(", ")}. ` +
|
|
144
|
+
"pakstr never writes signing keys to disk, so these may be leaked key material; " +
|
|
145
|
+
"they were left in place. Inspect them and delete any you did not create.");
|
|
115
146
|
}
|
|
116
147
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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.ApkSignatureError = void 0;
|
|
7
|
+
exports.readApkSignerCertificateSha256 = readApkSignerCertificateSha256;
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
/**
|
|
11
|
+
* Reads the signing certificate of an APK from its APK Signing Block (APK
|
|
12
|
+
* Signature Scheme v2/v3, which `pakstr sign` always produces: min SDK 28).
|
|
13
|
+
*
|
|
14
|
+
* This identifies the certificate the APK claims to be signed with; it does not
|
|
15
|
+
* cryptographically verify the signature (Android and apksigner do that on
|
|
16
|
+
* install). It lets `pakstr publish` refuse an APK whose signer does not match
|
|
17
|
+
* the `.signer-sha256` sidecar, e.g. an unsigned rebuild next to stale sidecars.
|
|
18
|
+
*
|
|
19
|
+
* Format: https://source.android.com/docs/security/features/apksigning/v2
|
|
20
|
+
*/
|
|
21
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
22
|
+
const EOCD_MIN_SIZE = 22;
|
|
23
|
+
const MAX_ZIP_COMMENT = 0xffff;
|
|
24
|
+
const SIGNING_BLOCK_MAGIC = "APK Sig Block 42";
|
|
25
|
+
// Signing blocks are a few KB; refuse absurd sizes from a corrupt file.
|
|
26
|
+
const MAX_SIGNING_BLOCK_SIZE = 64 * 1024 * 1024;
|
|
27
|
+
const SCHEMES = [
|
|
28
|
+
["v3.1", 0x1b93ad61],
|
|
29
|
+
["v3", 0xf05368c0],
|
|
30
|
+
["v2", 0x7109871a],
|
|
31
|
+
];
|
|
32
|
+
class ApkSignatureError extends Error {
|
|
33
|
+
reason;
|
|
34
|
+
constructor(reason, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.reason = reason;
|
|
37
|
+
this.name = "ApkSignatureError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.ApkSignatureError = ApkSignatureError;
|
|
41
|
+
/** Lowercase hex SHA-256 of the APK's signing certificate (DER), as apksigner prints it. */
|
|
42
|
+
function readApkSignerCertificateSha256(apkPath) {
|
|
43
|
+
const fd = fs_1.default.openSync(apkPath, "r");
|
|
44
|
+
try {
|
|
45
|
+
const block = readSigningBlock(fd, fs_1.default.fstatSync(fd).size);
|
|
46
|
+
if (!block) {
|
|
47
|
+
throw new ApkSignatureError("unsigned", "it has no APK Signature Scheme v2/v3 signing block");
|
|
48
|
+
}
|
|
49
|
+
const certificates = new Set();
|
|
50
|
+
for (const [scheme, id] of SCHEMES) {
|
|
51
|
+
const value = block.get(id);
|
|
52
|
+
if (value) {
|
|
53
|
+
for (const der of signerCertificates(value, scheme))
|
|
54
|
+
certificates.add(certificateSha256(der, scheme));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (certificates.size === 0) {
|
|
58
|
+
throw new ApkSignatureError("unsigned", "its signing block has no v2/v3 signature");
|
|
59
|
+
}
|
|
60
|
+
if (certificates.size > 1) {
|
|
61
|
+
throw new ApkSignatureError("multiple-certificates", `it is signed with ${certificates.size} different certificates (multiple signers or key rotation are not supported)`);
|
|
62
|
+
}
|
|
63
|
+
return [...certificates][0];
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
fs_1.default.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The signing block's ID → value pairs, or undefined if the APK has none. */
|
|
70
|
+
function readSigningBlock(fd, fileSize) {
|
|
71
|
+
const eocd = findEndOfCentralDirectory(fd, fileSize);
|
|
72
|
+
const centralDirectorySize = eocd.record.readUInt32LE(12);
|
|
73
|
+
const centralDirectoryOffset = eocd.record.readUInt32LE(16);
|
|
74
|
+
if (centralDirectoryOffset === 0xffffffff || centralDirectorySize === 0xffffffff) {
|
|
75
|
+
throw malformed("ZIP64 archives are not supported");
|
|
76
|
+
}
|
|
77
|
+
// APK signing requires the central directory to sit directly before the EOCD.
|
|
78
|
+
if (centralDirectoryOffset + centralDirectorySize !== eocd.offset) {
|
|
79
|
+
throw malformed("the ZIP central directory is not where the end record says");
|
|
80
|
+
}
|
|
81
|
+
const footerSize = 8 + SIGNING_BLOCK_MAGIC.length;
|
|
82
|
+
if (centralDirectoryOffset < footerSize + 8)
|
|
83
|
+
return undefined;
|
|
84
|
+
const footer = read(fd, centralDirectoryOffset - footerSize, footerSize);
|
|
85
|
+
if (footer.toString("latin1", 8) !== SIGNING_BLOCK_MAGIC)
|
|
86
|
+
return undefined;
|
|
87
|
+
const blockSize = toSafeLength(footer.readBigUInt64LE(0));
|
|
88
|
+
if (blockSize < footerSize || blockSize > MAX_SIGNING_BLOCK_SIZE || blockSize + 8 > centralDirectoryOffset) {
|
|
89
|
+
throw malformed("the signing block size is invalid");
|
|
90
|
+
}
|
|
91
|
+
const blockStart = centralDirectoryOffset - blockSize - 8;
|
|
92
|
+
const block = read(fd, blockStart, blockSize + 8);
|
|
93
|
+
if (toSafeLength(block.readBigUInt64LE(0)) !== blockSize) {
|
|
94
|
+
throw malformed("the signing block header and footer sizes differ");
|
|
95
|
+
}
|
|
96
|
+
const pairs = new Map();
|
|
97
|
+
const end = block.length - footerSize;
|
|
98
|
+
for (let offset = 8; offset < end;) {
|
|
99
|
+
if (end - offset < 12)
|
|
100
|
+
throw malformed("a signing block entry is truncated");
|
|
101
|
+
const length = toSafeLength(block.readBigUInt64LE(offset));
|
|
102
|
+
if (length < 4 || length > end - offset - 8)
|
|
103
|
+
throw malformed("a signing block entry has an invalid length");
|
|
104
|
+
const id = block.readUInt32LE(offset + 8);
|
|
105
|
+
if (pairs.has(id))
|
|
106
|
+
throw malformed(`the signing block repeats entry 0x${id.toString(16)}`);
|
|
107
|
+
pairs.set(id, block.subarray(offset + 12, offset + 8 + length));
|
|
108
|
+
offset += 8 + length;
|
|
109
|
+
}
|
|
110
|
+
return pairs;
|
|
111
|
+
}
|
|
112
|
+
function findEndOfCentralDirectory(fd, fileSize) {
|
|
113
|
+
if (fileSize < EOCD_MIN_SIZE)
|
|
114
|
+
throw malformed("it is too small to be a ZIP archive");
|
|
115
|
+
const tailSize = Math.min(fileSize, EOCD_MIN_SIZE + MAX_ZIP_COMMENT);
|
|
116
|
+
const tail = read(fd, fileSize - tailSize, tailSize);
|
|
117
|
+
// Search backwards; the record's comment must run exactly to the end of file.
|
|
118
|
+
for (let i = tail.length - EOCD_MIN_SIZE; i >= 0; i--) {
|
|
119
|
+
if (tail.readUInt32LE(i) !== EOCD_SIGNATURE)
|
|
120
|
+
continue;
|
|
121
|
+
if (i + EOCD_MIN_SIZE + tail.readUInt16LE(i + 20) !== tail.length)
|
|
122
|
+
continue;
|
|
123
|
+
return { record: tail.subarray(i, i + EOCD_MIN_SIZE), offset: fileSize - tailSize + i };
|
|
124
|
+
}
|
|
125
|
+
throw malformed("it is not a ZIP archive (no end of central directory record)");
|
|
126
|
+
}
|
|
127
|
+
/** First certificate (DER) of every signer in a v2/v3 signature scheme block. */
|
|
128
|
+
function signerCertificates(value, scheme) {
|
|
129
|
+
const signers = new Reader(value, `${scheme} block`).lengthPrefixed();
|
|
130
|
+
const certificates = [];
|
|
131
|
+
while (!signers.done()) {
|
|
132
|
+
const signer = signers.lengthPrefixed();
|
|
133
|
+
const signedData = signer.lengthPrefixed();
|
|
134
|
+
signedData.lengthPrefixed(); // digests
|
|
135
|
+
const certs = signedData.lengthPrefixed();
|
|
136
|
+
if (certs.done())
|
|
137
|
+
throw malformed(`a ${scheme} signer has no certificate`);
|
|
138
|
+
certificates.push(certs.lengthPrefixed().rest());
|
|
139
|
+
}
|
|
140
|
+
if (certificates.length === 0)
|
|
141
|
+
throw malformed(`the ${scheme} block has no signers`);
|
|
142
|
+
return certificates;
|
|
143
|
+
}
|
|
144
|
+
function certificateSha256(der, scheme) {
|
|
145
|
+
try {
|
|
146
|
+
new crypto_1.X509Certificate(der);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw malformed(`a ${scheme} signer certificate is not valid X.509`);
|
|
150
|
+
}
|
|
151
|
+
return (0, crypto_1.createHash)("sha256").update(der).digest("hex");
|
|
152
|
+
}
|
|
153
|
+
/** Bounds-checked reader of uint32 length-prefixed values. */
|
|
154
|
+
class Reader {
|
|
155
|
+
buffer;
|
|
156
|
+
what;
|
|
157
|
+
offset = 0;
|
|
158
|
+
constructor(buffer, what) {
|
|
159
|
+
this.buffer = buffer;
|
|
160
|
+
this.what = what;
|
|
161
|
+
}
|
|
162
|
+
done() {
|
|
163
|
+
return this.offset >= this.buffer.length;
|
|
164
|
+
}
|
|
165
|
+
lengthPrefixed() {
|
|
166
|
+
if (this.buffer.length - this.offset < 4)
|
|
167
|
+
throw malformed(`the ${this.what} is truncated`);
|
|
168
|
+
const length = this.buffer.readUInt32LE(this.offset);
|
|
169
|
+
const start = this.offset + 4;
|
|
170
|
+
if (length > this.buffer.length - start)
|
|
171
|
+
throw malformed(`the ${this.what} has an invalid length`);
|
|
172
|
+
this.offset = start + length;
|
|
173
|
+
return new Reader(this.buffer.subarray(start, start + length), this.what);
|
|
174
|
+
}
|
|
175
|
+
rest() {
|
|
176
|
+
return this.buffer.subarray(this.offset);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function read(fd, position, length) {
|
|
180
|
+
const buffer = Buffer.alloc(length);
|
|
181
|
+
let total = 0;
|
|
182
|
+
while (total < length) {
|
|
183
|
+
const bytes = fs_1.default.readSync(fd, buffer, total, length - total, position + total);
|
|
184
|
+
if (bytes === 0)
|
|
185
|
+
throw malformed("it ends unexpectedly");
|
|
186
|
+
total += bytes;
|
|
187
|
+
}
|
|
188
|
+
return buffer;
|
|
189
|
+
}
|
|
190
|
+
function toSafeLength(value) {
|
|
191
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER))
|
|
192
|
+
throw malformed("a length field is out of range");
|
|
193
|
+
return Number(value);
|
|
194
|
+
}
|
|
195
|
+
function malformed(detail) {
|
|
196
|
+
return new ApkSignatureError("malformed", detail);
|
|
197
|
+
}
|
package/dist/core/nsite.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.NSITE_MANIFEST_KIND = void 0;
|
|
7
7
|
exports.publishNsite = publishNsite;
|
|
8
|
+
exports.verifyNsite = verifyNsite;
|
|
8
9
|
exports.inspectNsiteDirectory = inspectNsiteDirectory;
|
|
9
10
|
exports.buildNsiteEventTemplates = buildNsiteEventTemplates;
|
|
10
11
|
const crypto_1 = require("crypto");
|
|
@@ -90,6 +91,54 @@ async function publishNsite(options) {
|
|
|
90
91
|
partialFailures,
|
|
91
92
|
};
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* `--verify` for nsite: the manifest must be readable back from a relay that
|
|
96
|
+
* accepted it, and every file downloadable from a server that accepted its
|
|
97
|
+
* upload. Throws listing what could not be verified.
|
|
98
|
+
*/
|
|
99
|
+
async function verifyNsite(result, transport, fetchImpl = fetch) {
|
|
100
|
+
if (!transport.query) {
|
|
101
|
+
throw new Error("Nsite verification is not supported by this relay transport (it cannot query events)");
|
|
102
|
+
}
|
|
103
|
+
const query = transport.query.bind(transport);
|
|
104
|
+
const accepted = (targets) => targets.filter(t => t.success && !t.skipped);
|
|
105
|
+
const problems = [];
|
|
106
|
+
for (const { event, relays } of result.events.filter(e => e.event.kind === exports.NSITE_MANIFEST_KIND)) {
|
|
107
|
+
let found = false;
|
|
108
|
+
for (const relay of accepted(relays)) {
|
|
109
|
+
try {
|
|
110
|
+
found = (await query({ ids: [event.id], limit: 1 }, relay.target))?.id === event.id;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Try the next relay.
|
|
114
|
+
}
|
|
115
|
+
if (found)
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (!found)
|
|
119
|
+
problems.push(`manifest ${event.id} was not found on any relay that accepted it`);
|
|
120
|
+
}
|
|
121
|
+
for (const file of result.files) {
|
|
122
|
+
let found = false;
|
|
123
|
+
for (const server of accepted(file.servers)) {
|
|
124
|
+
try {
|
|
125
|
+
found = (await fetchImpl(`${server.target}/${file.sha256}`, { method: "HEAD" })).ok;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Try the next server.
|
|
129
|
+
}
|
|
130
|
+
if (found)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (!found)
|
|
134
|
+
problems.push(`${file.path} is not downloadable from any server that accepted it`);
|
|
135
|
+
}
|
|
136
|
+
if (problems.length > 0) {
|
|
137
|
+
const shown = problems.slice(0, 5).join("; ");
|
|
138
|
+
const more = problems.length > 5 ? `; and ${problems.length - 5} more` : "";
|
|
139
|
+
throw new Error(`Nsite verification failed: ${shown}${more}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
93
142
|
function inspectNsiteDirectory(directory) {
|
|
94
143
|
const root = path_1.default.resolve(directory);
|
|
95
144
|
if (!fs_1.default.existsSync(root) || !fs_1.default.lstatSync(root).isDirectory()) {
|
|
@@ -9,6 +9,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
9
9
|
const crypto_1 = require("crypto");
|
|
10
10
|
const process_1 = require("./process");
|
|
11
11
|
const dockerNetwork_1 = require("./dockerNetwork");
|
|
12
|
+
const timeouts_1 = require("./timeouts");
|
|
12
13
|
const CONTAINER_RELEASE_APK_SIGNED = "/workspace/app/build/outputs/apk/release/app-release.apk";
|
|
13
14
|
const CONTAINER_RELEASE_APK_UNSIGNED = "/workspace/app/build/outputs/apk/release/app-release-unsigned.apk";
|
|
14
15
|
const CONTAINER_DEBUG_APK = "/workspace/app/build/outputs/apk/debug/app-debug.apk";
|
|
@@ -22,16 +23,17 @@ class DockerGradleRunner {
|
|
|
22
23
|
this.execute = execute;
|
|
23
24
|
}
|
|
24
25
|
async build(request) {
|
|
26
|
+
const timeoutMs = (0, timeouts_1.buildTimeoutMs)();
|
|
25
27
|
this.checkDockerAvailable();
|
|
26
|
-
this.ensureDockerImage();
|
|
28
|
+
this.ensureDockerImage(timeoutMs);
|
|
27
29
|
const storage = this.createBuildStorage(request.androidRoot);
|
|
28
30
|
let primaryError;
|
|
29
31
|
let result;
|
|
30
32
|
try {
|
|
31
33
|
result =
|
|
32
34
|
request.mode === "release"
|
|
33
|
-
? this.buildRelease(request, storage)
|
|
34
|
-
: this.buildDebug(request, storage);
|
|
35
|
+
? this.buildRelease(request, storage, timeoutMs)
|
|
36
|
+
: this.buildDebug(request, storage, timeoutMs);
|
|
35
37
|
}
|
|
36
38
|
catch (error) {
|
|
37
39
|
primaryError = error;
|
|
@@ -55,10 +57,8 @@ class DockerGradleRunner {
|
|
|
55
57
|
throw new Error("Docker build completed without a result");
|
|
56
58
|
return result;
|
|
57
59
|
}
|
|
58
|
-
buildDebug(request, storage) {
|
|
59
|
-
this.
|
|
60
|
-
"run",
|
|
61
|
-
"--rm",
|
|
60
|
+
buildDebug(request, storage, timeoutMs) {
|
|
61
|
+
this.runBuildContainer([
|
|
62
62
|
...(0, dockerNetwork_1.dockerNetworkArgs)(),
|
|
63
63
|
"-v",
|
|
64
64
|
`${storage.workspaceVolume}:/workspace`,
|
|
@@ -68,17 +68,15 @@ class DockerGradleRunner {
|
|
|
68
68
|
"./gradlew",
|
|
69
69
|
"assembleDebug",
|
|
70
70
|
"-Dorg.gradle.vfs.watch=false",
|
|
71
|
-
], { streamOutput: true });
|
|
71
|
+
], { streamOutput: true, timeoutMs, timeoutHint: (0, timeouts_1.buildTimeoutHint)("Gradle build") });
|
|
72
72
|
const artifactPath = hostArtifactPath(request.androidRoot, "debug");
|
|
73
73
|
this.copyFromVolume(storage.workspaceVolume, CONTAINER_DEBUG_APK, artifactPath);
|
|
74
74
|
return { artifactPath: requireApk(artifactPath), unsigned: false };
|
|
75
75
|
}
|
|
76
|
-
buildRelease(request, storage) {
|
|
76
|
+
buildRelease(request, storage, timeoutMs) {
|
|
77
77
|
// Unsigned release build: no signing config is wired up because
|
|
78
78
|
// PAKSTR_ANDROID_KEYSTORE_PATH is not set in the container environment.
|
|
79
|
-
this.
|
|
80
|
-
"run",
|
|
81
|
-
"--rm",
|
|
79
|
+
this.runBuildContainer([
|
|
82
80
|
...(0, dockerNetwork_1.dockerNetworkArgs)(),
|
|
83
81
|
"-v",
|
|
84
82
|
`${storage.workspaceVolume}:/workspace`,
|
|
@@ -89,11 +87,27 @@ class DockerGradleRunner {
|
|
|
89
87
|
"assembleRelease",
|
|
90
88
|
"--no-daemon",
|
|
91
89
|
"-Dorg.gradle.vfs.watch=false",
|
|
92
|
-
], {
|
|
90
|
+
], {
|
|
91
|
+
env: scrubSigningEnv(process.env),
|
|
92
|
+
streamOutput: true,
|
|
93
|
+
timeoutMs,
|
|
94
|
+
timeoutHint: (0, timeouts_1.buildTimeoutHint)("Gradle build"),
|
|
95
|
+
});
|
|
93
96
|
const artifactPath = hostArtifactPath(request.androidRoot, "release-unsigned");
|
|
94
97
|
this.copyFromVolume(storage.workspaceVolume, CONTAINER_RELEASE_APK_UNSIGNED, artifactPath);
|
|
95
98
|
return { artifactPath: requireApk(artifactPath), unsigned: true };
|
|
96
99
|
}
|
|
100
|
+
runBuildContainer(args, options) {
|
|
101
|
+
const container = `pakstr-build-${(0, crypto_1.randomUUID)()}`;
|
|
102
|
+
try {
|
|
103
|
+
this.execute("docker", ["run", "--rm", "--name", container, ...args], options);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
// Remove it before the workspace volume it may still hold is cleaned up.
|
|
107
|
+
(0, timeouts_1.removeFailedRunContainer)(this.execute, container);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
97
111
|
createBuildStorage(androidRoot) {
|
|
98
112
|
const suffix = (0, crypto_1.randomUUID)();
|
|
99
113
|
const storage = {
|
|
@@ -101,7 +115,7 @@ class DockerGradleRunner {
|
|
|
101
115
|
};
|
|
102
116
|
assertRegularAbsolutePath(androidRoot, "Android workspace");
|
|
103
117
|
try {
|
|
104
|
-
this.execute("docker", ["volume", "create", storage.workspaceVolume]);
|
|
118
|
+
this.execute("docker", ["volume", "create", storage.workspaceVolume], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
105
119
|
this.copyDirectoryToVolume(androidRoot, storage.workspaceVolume);
|
|
106
120
|
return storage;
|
|
107
121
|
}
|
|
@@ -127,8 +141,8 @@ class DockerGradleRunner {
|
|
|
127
141
|
`${volume}:/workspace`,
|
|
128
142
|
this.image,
|
|
129
143
|
"true",
|
|
130
|
-
]);
|
|
131
|
-
this.execute("docker", ["cp", `${source}${path_1.default.sep}.`, `${container}:/workspace`]);
|
|
144
|
+
], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
145
|
+
this.execute("docker", ["cp", `${source}${path_1.default.sep}.`, `${container}:/workspace`], timeouts_1.DOCKER_COPY_OPTIONS);
|
|
132
146
|
}
|
|
133
147
|
finally {
|
|
134
148
|
this.removeContainer(container);
|
|
@@ -148,8 +162,8 @@ class DockerGradleRunner {
|
|
|
148
162
|
`${volume}:/workspace:ro`,
|
|
149
163
|
this.image,
|
|
150
164
|
"true",
|
|
151
|
-
]);
|
|
152
|
-
this.execute("docker", ["cp", `${container}:${source}`, destination]);
|
|
165
|
+
], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
166
|
+
this.execute("docker", ["cp", `${container}:${source}`, destination], timeouts_1.DOCKER_COPY_OPTIONS);
|
|
153
167
|
}
|
|
154
168
|
finally {
|
|
155
169
|
this.removeContainer(container);
|
|
@@ -157,7 +171,7 @@ class DockerGradleRunner {
|
|
|
157
171
|
}
|
|
158
172
|
removeBuildStorage(storage) {
|
|
159
173
|
try {
|
|
160
|
-
this.execute("docker", ["volume", "rm", "--force", storage.workspaceVolume]);
|
|
174
|
+
this.execute("docker", ["volume", "rm", "--force", storage.workspaceVolume], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
161
175
|
}
|
|
162
176
|
catch {
|
|
163
177
|
throw new Error(`Failed to remove temporary Docker volume: ${storage.workspaceVolume}`);
|
|
@@ -165,7 +179,7 @@ class DockerGradleRunner {
|
|
|
165
179
|
}
|
|
166
180
|
removeContainer(container) {
|
|
167
181
|
try {
|
|
168
|
-
this.execute("docker", ["rm", "--force", container]);
|
|
182
|
+
this.execute("docker", ["rm", "--force", container], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
169
183
|
}
|
|
170
184
|
catch {
|
|
171
185
|
// A failed docker create leaves no container to remove.
|
|
@@ -179,14 +193,21 @@ class DockerGradleRunner {
|
|
|
179
193
|
throw new Error("Docker is not installed or not running. Install Docker Desktop to use pakstr.");
|
|
180
194
|
}
|
|
181
195
|
}
|
|
182
|
-
ensureDockerImage() {
|
|
196
|
+
ensureDockerImage(timeoutMs) {
|
|
183
197
|
try {
|
|
184
|
-
this.execute("docker", ["image", "inspect", this.image]);
|
|
198
|
+
this.execute("docker", ["image", "inspect", this.image], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
185
199
|
console.log(`🐳 Docker image ready: ${this.image}`);
|
|
186
200
|
}
|
|
187
|
-
catch {
|
|
201
|
+
catch (error) {
|
|
202
|
+
// An unresponsive daemon is not a missing image; don't wait on a pull too.
|
|
203
|
+
if (error instanceof process_1.ProcessTimeoutError)
|
|
204
|
+
throw error;
|
|
188
205
|
console.log(`⬇️ Pulling Docker image: ${this.image}`);
|
|
189
|
-
this.execute("docker", ["pull", this.image], {
|
|
206
|
+
this.execute("docker", ["pull", this.image], {
|
|
207
|
+
streamOutput: true,
|
|
208
|
+
timeoutMs,
|
|
209
|
+
timeoutHint: (0, timeouts_1.buildTimeoutHint)("image pull"),
|
|
210
|
+
});
|
|
190
211
|
}
|
|
191
212
|
}
|
|
192
213
|
}
|
|
@@ -11,6 +11,8 @@ const os_1 = __importDefault(require("os"));
|
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const process_1 = require("./process");
|
|
13
13
|
const dockerNetwork_1 = require("./dockerNetwork");
|
|
14
|
+
const timeouts_1 = require("./timeouts");
|
|
15
|
+
const artifactPublisher_1 = require("../commands/artifactPublisher");
|
|
14
16
|
class DockerSignRunner {
|
|
15
17
|
image;
|
|
16
18
|
execute;
|
|
@@ -29,11 +31,13 @@ class DockerSignRunner {
|
|
|
29
31
|
// (mirroring how DockerGradleRunner transports the workspace).
|
|
30
32
|
const apkVolume = `pakstr-sign-apk-${(0, crypto_2.randomUUID)()}`;
|
|
31
33
|
const outVolume = `pakstr-sign-out-${(0, crypto_2.randomUUID)()}`;
|
|
34
|
+
const signContainer = `pakstr-sign-${(0, crypto_2.randomUUID)()}`;
|
|
35
|
+
let hostSigned;
|
|
32
36
|
let primaryError;
|
|
33
37
|
let result;
|
|
34
38
|
try {
|
|
35
|
-
this.execute("docker", ["volume", "create", apkVolume]);
|
|
36
|
-
this.execute("docker", ["volume", "create", outVolume]);
|
|
39
|
+
this.execute("docker", ["volume", "create", apkVolume], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
40
|
+
this.execute("docker", ["volume", "create", outVolume], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
37
41
|
this.copyFileToVolume(request.unsignedApkPath, apkVolume, "/work/unsigned.apk");
|
|
38
42
|
const password = (0, crypto_1.randomBytes)(32).toString("base64url");
|
|
39
43
|
const nsecString = nsecToString(request.nsec);
|
|
@@ -51,9 +55,11 @@ class DockerSignRunner {
|
|
|
51
55
|
if (request.identityProofPubkey) {
|
|
52
56
|
signingEnvNames.push("IDENTITY_PROOF_PUBKEY");
|
|
53
57
|
}
|
|
54
|
-
const stdout = this.
|
|
58
|
+
const stdout = this.runSignContainer(signContainer, [
|
|
55
59
|
"run",
|
|
56
60
|
"--rm",
|
|
61
|
+
"--name",
|
|
62
|
+
signContainer,
|
|
57
63
|
...(0, dockerNetwork_1.dockerNetworkArgs)(),
|
|
58
64
|
"--tmpfs",
|
|
59
65
|
"/signing:mode=0700,uid=0",
|
|
@@ -67,7 +73,13 @@ class DockerSignRunner {
|
|
|
67
73
|
request.appId,
|
|
68
74
|
"/work/unsigned.apk",
|
|
69
75
|
"/out/signed.apk",
|
|
70
|
-
], {
|
|
76
|
+
], {
|
|
77
|
+
env,
|
|
78
|
+
secrets: [nsecString, password],
|
|
79
|
+
streamOutput: true,
|
|
80
|
+
timeoutMs: timeouts_1.DOCKER_SIGN_TIMEOUT_MS,
|
|
81
|
+
timeoutHint: `The signing container did not finish. ${timeouts_1.DOCKER_DAEMON_HINT}`,
|
|
82
|
+
}).stdout;
|
|
71
83
|
const signerSha256 = parseSignerSha(stdout);
|
|
72
84
|
const identityProof = parseIdentityProof(stdout);
|
|
73
85
|
if (request.identityProofPubkey && !identityProof) {
|
|
@@ -85,33 +97,50 @@ class DockerSignRunner {
|
|
|
85
97
|
}
|
|
86
98
|
}
|
|
87
99
|
// Copy the signed APK out of the output volume to the host.
|
|
88
|
-
|
|
100
|
+
hostSigned = path_1.default.join(os_1.default.tmpdir(), `pakstr-signed-${(0, crypto_2.randomUUID)()}.apk`);
|
|
89
101
|
this.copyFromVolume(outVolume, "/out/signed.apk", hostSigned);
|
|
90
102
|
if (!fs_1.default.existsSync(hostSigned) || !fs_1.default.lstatSync(hostSigned).isFile()) {
|
|
91
103
|
throw new Error("Sign runner did not produce a signed APK");
|
|
92
104
|
}
|
|
93
|
-
fs_1.default.
|
|
94
|
-
|
|
105
|
+
if (fs_1.default.statSync(hostSigned).size === 0) {
|
|
106
|
+
throw new Error("Sign runner produced an empty signed APK");
|
|
107
|
+
}
|
|
95
108
|
const finalPath = path_1.default.resolve(request.signedApkPath);
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
fs_1.default.mkdirSync(path_1.default.dirname(finalPath), { recursive: true });
|
|
110
|
+
// Copy (not rename) across the tmpfs → workspace device boundary into a
|
|
111
|
+
// staging directory next to the output (CI runners put /tmp and the
|
|
112
|
+
// checkout on different filesystems, so fs.rename fails with EXDEV),
|
|
113
|
+
// then replace the output without deleting the previous APK first.
|
|
114
|
+
// `pakstr sign` signs in place, so that previous APK is the unsigned one.
|
|
115
|
+
(0, artifactPublisher_1.publishArtifact)((0, artifactPublisher_1.stageArtifact)(hostSigned, finalPath, "signed APK"), finalPath);
|
|
101
116
|
result = { signedApkPath: finalPath, signerSha256, identityProof: identityProof ?? undefined };
|
|
102
117
|
}
|
|
103
118
|
catch (error) {
|
|
104
119
|
primaryError = error;
|
|
105
120
|
}
|
|
106
121
|
finally {
|
|
107
|
-
|
|
122
|
+
// Also covers a partial signed APK left by a failed or timed-out copy,
|
|
123
|
+
// or a directory if the container produced one. The path is our own
|
|
124
|
+
// random temp name, so a recursive removal is safe.
|
|
125
|
+
if (hostSigned) {
|
|
108
126
|
try {
|
|
109
|
-
|
|
127
|
+
fs_1.default.rmSync(hostSigned, { recursive: true, force: true });
|
|
110
128
|
}
|
|
111
129
|
catch {
|
|
112
130
|
/* best-effort cleanup */
|
|
113
131
|
}
|
|
114
132
|
}
|
|
133
|
+
for (const v of [outVolume, apkVolume]) {
|
|
134
|
+
try {
|
|
135
|
+
this.execute("docker", ["volume", "rm", "--force", v], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
// Best-effort: don't fail (or mask the real error of) the signing run.
|
|
139
|
+
console.warn(`Warning: failed to remove temporary Docker volume ${v} ` +
|
|
140
|
+
`(${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}); ` +
|
|
141
|
+
`remove it with: docker volume rm --force ${v}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
115
144
|
}
|
|
116
145
|
if (primaryError)
|
|
117
146
|
throw primaryError;
|
|
@@ -119,6 +148,16 @@ class DockerSignRunner {
|
|
|
119
148
|
throw new Error("Sign runner completed without a result");
|
|
120
149
|
return result;
|
|
121
150
|
}
|
|
151
|
+
runSignContainer(container, args, options) {
|
|
152
|
+
try {
|
|
153
|
+
return this.execute("docker", args, options);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
// A killed docker client leaves the container (and its secrets) running.
|
|
157
|
+
(0, timeouts_1.removeFailedRunContainer)(this.execute, container);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
122
161
|
copyFileToVolume(source, volume, destination) {
|
|
123
162
|
const container = `pakstr-sign-copy-${(0, crypto_2.randomUUID)()}`;
|
|
124
163
|
try {
|
|
@@ -131,8 +170,8 @@ class DockerSignRunner {
|
|
|
131
170
|
`${volume}:/work`,
|
|
132
171
|
this.image,
|
|
133
172
|
"true",
|
|
134
|
-
]);
|
|
135
|
-
this.execute("docker", ["cp", source, `${container}:${destination}`]);
|
|
173
|
+
], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
174
|
+
this.execute("docker", ["cp", source, `${container}:${destination}`], timeouts_1.DOCKER_COPY_OPTIONS);
|
|
136
175
|
}
|
|
137
176
|
finally {
|
|
138
177
|
this.removeContainer(container);
|
|
@@ -155,8 +194,8 @@ class DockerSignRunner {
|
|
|
155
194
|
`${volume}:${mountDir}:ro`,
|
|
156
195
|
this.image,
|
|
157
196
|
"true",
|
|
158
|
-
]);
|
|
159
|
-
this.execute("docker", ["cp", `${container}:${source}`, destination]);
|
|
197
|
+
], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
198
|
+
this.execute("docker", ["cp", `${container}:${source}`, destination], timeouts_1.DOCKER_COPY_OPTIONS);
|
|
160
199
|
}
|
|
161
200
|
finally {
|
|
162
201
|
this.removeContainer(container);
|
|
@@ -164,7 +203,7 @@ class DockerSignRunner {
|
|
|
164
203
|
}
|
|
165
204
|
removeContainer(container) {
|
|
166
205
|
try {
|
|
167
|
-
this.execute("docker", ["rm", "--force", container]);
|
|
206
|
+
this.execute("docker", ["rm", "--force", container], timeouts_1.DOCKER_COMMAND_OPTIONS);
|
|
168
207
|
}
|
|
169
208
|
catch {
|
|
170
209
|
/* a failed create leaves no container */
|
|
@@ -7,6 +7,7 @@ exports.LocalGradleRunner = void 0;
|
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const process_1 = require("./process");
|
|
10
|
+
const timeouts_1 = require("./timeouts");
|
|
10
11
|
/**
|
|
11
12
|
* Local Gradle runner for users who already have a JDK + Android SDK + Gradle
|
|
12
13
|
* installed. The default and only specified builder is `docker`; this remains
|
|
@@ -15,6 +16,8 @@ const process_1 = require("./process");
|
|
|
15
16
|
class LocalGradleRunner {
|
|
16
17
|
async build(request) {
|
|
17
18
|
const { androidRoot, mode } = request;
|
|
19
|
+
const timeoutMs = (0, timeouts_1.buildTimeoutMs)();
|
|
20
|
+
const timeoutHint = (0, timeouts_1.buildTimeoutHint)("Gradle build");
|
|
18
21
|
const gradlewPath = path_1.default.join(androidRoot, "gradlew");
|
|
19
22
|
if (!fs_1.default.existsSync(gradlewPath)) {
|
|
20
23
|
throw new Error("gradlew not found in Android project root");
|
|
@@ -25,6 +28,8 @@ class LocalGradleRunner {
|
|
|
25
28
|
(0, process_1.runProcess)(gradlewPath, ["assembleDebug"], {
|
|
26
29
|
cwd: androidRoot,
|
|
27
30
|
streamOutput: true,
|
|
31
|
+
timeoutMs,
|
|
32
|
+
timeoutHint,
|
|
28
33
|
});
|
|
29
34
|
return { artifactPath: requireApk(androidRoot, "debug"), unsigned: false };
|
|
30
35
|
}
|
|
@@ -34,6 +39,8 @@ class LocalGradleRunner {
|
|
|
34
39
|
cwd: androidRoot,
|
|
35
40
|
env,
|
|
36
41
|
streamOutput: true,
|
|
42
|
+
timeoutMs,
|
|
43
|
+
timeoutHint,
|
|
37
44
|
});
|
|
38
45
|
const artifactPath = requireApk(androidRoot, "release-unsigned");
|
|
39
46
|
return { artifactPath, unsigned: true };
|
package/dist/runners/process.js
CHANGED
|
@@ -1,15 +1,44 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProcessOutputLimitError = exports.ProcessTimeoutError = void 0;
|
|
3
4
|
exports.runProcess = runProcess;
|
|
4
5
|
exports.redact = redact;
|
|
5
6
|
const child_process_1 = require("child_process");
|
|
7
|
+
class ProcessTimeoutError extends Error {
|
|
8
|
+
timeoutMs;
|
|
9
|
+
constructor(message, timeoutMs) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.timeoutMs = timeoutMs;
|
|
12
|
+
this.name = "ProcessTimeoutError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.ProcessTimeoutError = ProcessTimeoutError;
|
|
16
|
+
/** The process wrote more than MAX_OUTPUT_BYTES to stdout or stderr and was killed. */
|
|
17
|
+
class ProcessOutputLimitError extends Error {
|
|
18
|
+
maxBuffer;
|
|
19
|
+
constructor(message, maxBuffer) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.maxBuffer = maxBuffer;
|
|
22
|
+
this.name = "ProcessOutputLimitError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.ProcessOutputLimitError = ProcessOutputLimitError;
|
|
6
26
|
const MAX_DIAGNOSTIC_LENGTH = 32 * 1024;
|
|
27
|
+
// Node's spawnSync default, stated explicitly so the error can name it.
|
|
28
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
7
29
|
function runProcess(command, args, options = {}) {
|
|
8
30
|
const result = (0, child_process_1.spawnSync)(command, args, {
|
|
9
31
|
cwd: options.cwd,
|
|
10
32
|
env: options.env,
|
|
11
33
|
encoding: "utf8",
|
|
12
34
|
stdio: ["ignore", "pipe", "pipe"],
|
|
35
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
36
|
+
// SIGKILL, not the default SIGTERM: spawnSync waits for the child to exit,
|
|
37
|
+
// so a child that ignores SIGTERM (e.g. PID 1 in a container) would still
|
|
38
|
+
// block forever.
|
|
39
|
+
...(options.timeoutMs
|
|
40
|
+
? { timeout: options.timeoutMs, killSignal: "SIGKILL" }
|
|
41
|
+
: {}),
|
|
13
42
|
});
|
|
14
43
|
const stdout = redact(result.stdout ?? "", options.secrets ?? []);
|
|
15
44
|
const stderr = redact(result.stderr ?? "", options.secrets ?? []);
|
|
@@ -20,6 +49,18 @@ function runProcess(command, args, options = {}) {
|
|
|
20
49
|
process.stderr.write(stderr);
|
|
21
50
|
}
|
|
22
51
|
if (result.error) {
|
|
52
|
+
const code = result.error.code;
|
|
53
|
+
const label = redact([command, args[0]].filter(Boolean).join(" "), options.secrets ?? []);
|
|
54
|
+
const diagnostic = bounded(`${stderr}\n${stdout}`.trim());
|
|
55
|
+
const suffix = diagnostic ? `\n${diagnostic}` : "";
|
|
56
|
+
if (options.timeoutMs && code === "ETIMEDOUT") {
|
|
57
|
+
const hint = options.timeoutHint ? ` ${options.timeoutHint}` : "";
|
|
58
|
+
throw new ProcessTimeoutError(`${label} timed out after ${formatDuration(options.timeoutMs)} and was stopped.${hint}${suffix}`, options.timeoutMs);
|
|
59
|
+
}
|
|
60
|
+
if (code === "ENOBUFS") {
|
|
61
|
+
throw new ProcessOutputLimitError(`${label} wrote more than ${MAX_OUTPUT_BYTES / (1024 * 1024)} MiB of output and was stopped; ` +
|
|
62
|
+
`its output was cut off at that point.${suffix}`, MAX_OUTPUT_BYTES);
|
|
63
|
+
}
|
|
23
64
|
throw new Error(`Failed to run ${command}: ${redact(result.error.message, options.secrets ?? [])}`);
|
|
24
65
|
}
|
|
25
66
|
if (result.status !== 0) {
|
|
@@ -35,6 +76,16 @@ function redact(value, secrets) {
|
|
|
35
76
|
.sort((a, b) => b.length - a.length)
|
|
36
77
|
.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value);
|
|
37
78
|
}
|
|
79
|
+
function formatDuration(ms) {
|
|
80
|
+
if (ms % 60_000 === 0)
|
|
81
|
+
return plural(ms / 60_000, "minute");
|
|
82
|
+
if (ms % 1_000 === 0)
|
|
83
|
+
return plural(ms / 1_000, "second");
|
|
84
|
+
return `${ms} ms`;
|
|
85
|
+
}
|
|
86
|
+
function plural(count, unit) {
|
|
87
|
+
return `${count} ${unit}${count === 1 ? "" : "s"}`;
|
|
88
|
+
}
|
|
38
89
|
function bounded(value) {
|
|
39
90
|
if (value.length <= MAX_DIAGNOSTIC_LENGTH)
|
|
40
91
|
return value;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DOCKER_COPY_OPTIONS = exports.DOCKER_COMMAND_OPTIONS = exports.DOCKER_DAEMON_HINT = exports.DEFAULT_BUILD_TIMEOUT_MINUTES = exports.BUILD_TIMEOUT_ENV = exports.DOCKER_SIGN_TIMEOUT_MS = exports.DOCKER_COPY_TIMEOUT_MS = exports.DOCKER_COMMAND_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.buildTimeoutMs = buildTimeoutMs;
|
|
5
|
+
exports.buildTimeoutHint = buildTimeoutHint;
|
|
6
|
+
exports.removeFailedRunContainer = removeFailedRunContainer;
|
|
7
|
+
/**
|
|
8
|
+
* Upper bounds for the subprocesses pakstr runs, so a wedged Docker daemon, a
|
|
9
|
+
* stalled network or a hung Gradle build fails with a clear error instead of
|
|
10
|
+
* blocking the CLI (and CI jobs) forever. The limits are deliberately generous:
|
|
11
|
+
* they only exist to turn an indefinite hang into a failure.
|
|
12
|
+
*/
|
|
13
|
+
/** Docker daemon calls (inspect, create, rm, volume) that normally take seconds. */
|
|
14
|
+
exports.DOCKER_COMMAND_TIMEOUT_MS = 2 * 60_000;
|
|
15
|
+
/** `docker cp` of the Android workspace or an APK. */
|
|
16
|
+
exports.DOCKER_COPY_TIMEOUT_MS = 10 * 60_000;
|
|
17
|
+
/** The signing container (keystore derivation, apksigner, verification). */
|
|
18
|
+
exports.DOCKER_SIGN_TIMEOUT_MS = 15 * 60_000;
|
|
19
|
+
/** Gradle builds and builder image pulls, which can be slow on a cold cache. */
|
|
20
|
+
exports.BUILD_TIMEOUT_ENV = "PAKSTR_BUILD_TIMEOUT_MINUTES";
|
|
21
|
+
exports.DEFAULT_BUILD_TIMEOUT_MINUTES = 60;
|
|
22
|
+
exports.DOCKER_DAEMON_HINT = "The Docker daemon may be unresponsive; check that Docker is running (`docker info`) and try again.";
|
|
23
|
+
exports.DOCKER_COMMAND_OPTIONS = {
|
|
24
|
+
timeoutMs: exports.DOCKER_COMMAND_TIMEOUT_MS,
|
|
25
|
+
timeoutHint: exports.DOCKER_DAEMON_HINT,
|
|
26
|
+
};
|
|
27
|
+
exports.DOCKER_COPY_OPTIONS = {
|
|
28
|
+
timeoutMs: exports.DOCKER_COPY_TIMEOUT_MS,
|
|
29
|
+
timeoutHint: exports.DOCKER_DAEMON_HINT,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Build/pull timeout from PAKSTR_BUILD_TIMEOUT_MINUTES (default 60). `0`
|
|
33
|
+
* disables the limit and returns undefined.
|
|
34
|
+
*/
|
|
35
|
+
function buildTimeoutMs(env = process.env) {
|
|
36
|
+
const raw = env[exports.BUILD_TIMEOUT_ENV]?.trim();
|
|
37
|
+
if (!raw)
|
|
38
|
+
return exports.DEFAULT_BUILD_TIMEOUT_MINUTES * 60_000;
|
|
39
|
+
const minutes = Number(raw);
|
|
40
|
+
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(minutes * 60_000)) {
|
|
41
|
+
throw new Error(`${exports.BUILD_TIMEOUT_ENV} must be a whole number of minutes (0 disables the limit), got "${raw}"`);
|
|
42
|
+
}
|
|
43
|
+
return minutes === 0 ? undefined : minutes * 60_000;
|
|
44
|
+
}
|
|
45
|
+
function buildTimeoutHint(step) {
|
|
46
|
+
return `If the ${step} needs longer, raise ${exports.BUILD_TIMEOUT_ENV} (0 disables the limit).`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove the named container after its `docker run` failed. When the client is
|
|
50
|
+
* killed (timeout, output limit) the container keeps running, holding its
|
|
51
|
+
* volumes and any secrets in its environment; after other failures it is
|
|
52
|
+
* usually gone already, which counts as removed. Never throws, so the caller
|
|
53
|
+
* can rethrow the original failure.
|
|
54
|
+
*/
|
|
55
|
+
function removeFailedRunContainer(execute, container) {
|
|
56
|
+
try {
|
|
57
|
+
execute("docker", ["rm", "--force", container], exports.DOCKER_COMMAND_OPTIONS);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// Older Docker versions fail `rm --force` for a container that is gone.
|
|
61
|
+
if (error instanceof Error && error.message.includes("No such container"))
|
|
62
|
+
return;
|
|
63
|
+
console.error(`Failed to remove container ${container} after docker run failed; ` +
|
|
64
|
+
`if it is still running, remove it with: docker rm --force ${container}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pakstr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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
|
"README.md"
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
|
+
"prebuild": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
30
31
|
"build": "tsc",
|
|
31
32
|
"type-test": "tsc -p tsconfig.type-tests.json",
|
|
32
33
|
"test": "npm run build && npm run type-test && node --test test/*.test.js",
|