pakstr 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +10 -0
  2. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +65 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/local/LocalAccountStoreTest.kt +78 -0
  4. package/android-template/app/src/androidTest/java/com/pakstr/app/utils/SignerPickerBottomSheetTest.kt +40 -6
  5. package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +124 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip19Codec.kt +181 -0
  7. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +25 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountImportCandidate.kt +42 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountStore.kt +17 -2
  10. package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +80 -4
  11. package/android-template/app/src/main/res/layout/activity_developer_tools.xml +15 -0
  12. package/android-template/app/src/main/res/layout/dialog_picker.xml +62 -0
  13. package/android-template/app/src/main/res/values/strings.xml +15 -2
  14. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip19CodecTest.kt +178 -0
  15. package/dist/cli.js +13 -6
  16. package/dist/commands/artifactPublisher.js +125 -0
  17. package/dist/commands/build.js +16 -30
  18. package/dist/commands/publish.js +37 -3
  19. package/dist/commands/run.js +60 -29
  20. package/dist/core/apkSignature.js +197 -0
  21. package/dist/core/nsite.js +49 -0
  22. package/dist/runners/DockerGradleRunner.js +45 -24
  23. package/dist/runners/DockerSignRunner.js +58 -19
  24. package/dist/runners/LocalGradleRunner.js +7 -0
  25. package/dist/runners/process.js +51 -0
  26. package/dist/runners/timeouts.js +66 -0
  27. package/package.json +4 -3
@@ -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.execute("docker", [
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.execute("docker", [
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
- ], { env: scrubSigningEnv(process.env), streamOutput: true });
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], { streamOutput: true });
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.execute("docker", [
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
- ], { env, secrets: [nsecString, password], streamOutput: true }).stdout;
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
- const hostSigned = path_1.default.join(os_1.default.tmpdir(), `pakstr-signed-${(0, crypto_2.randomUUID)()}.apk`);
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.mkdirSync(path_1.default.dirname(request.signedApkPath), { recursive: true });
94
- fs_1.default.rmSync(request.signedApkPath, { force: true });
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
- // Copy (not rename) across the tmpfs → workspace device boundary; CI
97
- // runners put /tmp and the checkout on different filesystems, so
98
- // fs.rename fails with EXDEV. COPYFILE_FICLONE is a harmless hint.
99
- fs_1.default.copyFileSync(hostSigned, finalPath, fs_1.default.constants.COPYFILE_FICLONE);
100
- fs_1.default.rmSync(hostSigned, { force: true });
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
- for (const v of [outVolume, apkVolume]) {
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
- this.execute("docker", ["volume", "rm", "--force", v]);
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 };
@@ -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.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -27,6 +27,7 @@
27
27
  "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",
@@ -54,9 +55,9 @@
54
55
  "applesauce-core": "^6.2.0",
55
56
  "applesauce-signers": "^6.2.2",
56
57
  "fs-extra": "^11.3.6",
57
- "js-yaml": "^5.3.0",
58
+ "js-yaml": "^5.4.1",
58
59
  "node-fetch": "^3.3.2",
59
- "sharp": "^0.35.3",
60
+ "sharp": "^0.35.5",
60
61
  "unzipper": "^0.12.5"
61
62
  },
62
63
  "repository": {