dsh-apk-vm 0.6.2 → 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.
package/lib/aab.js ADDED
@@ -0,0 +1,736 @@
1
+ /**
2
+ * lib/aab.js — Android App Bundle (.aab) support for the APK VM preview panel.
3
+ *
4
+ * Bundletool is the supported way to turn an Android App Bundle into a
5
+ * device-installable `.apks` archive. This module is self-contained and
6
+ * dependency-free (only `node:` built-ins):
7
+ *
8
+ * detectBundletool(options) find a bundletool jar/executable and Java
9
+ * buildApksFromAab(options) run `build-apks --mode=<mode>` with argv only
10
+ * aabInfo(aabPath) stat-only metadata for one .aab
11
+ *
12
+ * Tool discovery order:
13
+ * 1. explicit `options.bundletoolPath` / `options.jarPath`
14
+ * 2. `BUNDLETOOL_JAR` / `BUNDLETOOL_PATH` from the environment
15
+ * 3. a `bundletool` executable on PATH
16
+ * 4. common `bundletool*.jar` names in the repository/tools directory
17
+ *
18
+ * Safety: every external program is launched as
19
+ * `child_process.spawn(file, argvArray)`; no command string is assembled, so
20
+ * paths and passwords cannot inject a shell. Passwords only ever travel in
21
+ * argv and are redacted from the returned `output` text. Windows `.bat`/`.cmd`
22
+ * shims are the one OS-mandated exception: cmd.exe receives a fixed argv
23
+ * array (the shim path and every argument stay separate entries), never a
24
+ * concatenated command string. An AAB is a ZIP container, but metadata
25
+ * collection here is intentionally stat-only: this module never extracts or
26
+ * parses the archive.
27
+ *
28
+ * Export contract:
29
+ * detectBundletool(options = {}) -> { available, kind, path, javaPath }
30
+ * `kind` is 'jar' | 'exe' | 'missing'; `path` is null when missing.
31
+ * Synchronous and read-only: it only inspects the filesystem/PATH.
32
+ *
33
+ * buildApksFromAab(options) -> Promise<{ ok, apksPath, output, error? }>
34
+ * Invalid input (missing/wrong extension, unsupported mode, bad signing
35
+ * options) rejects with TypeError/Error. Expected tool/runtime failures
36
+ * resolve with `ok: false`, `apksPath: null` and an `error` message.
37
+ *
38
+ * aabInfo(aabPath) -> Promise<{ ok, path, name, size, available, tool }>
39
+ * `tool` is `{ kind, path, javaPath }` from `detectBundletool()`.
40
+ *
41
+ * `buildApksFromAab()` extras (not required by the minimal contract):
42
+ * `timeoutMs`, `env` (environment map), and `exec` (injectable runner used
43
+ * by tests; same shape as lib/setup.js: `(file, args, options) => result`).
44
+ */
45
+ import { spawn } from 'node:child_process';
46
+ import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs';
47
+ import { promises as fsp } from 'node:fs';
48
+ import path from 'node:path';
49
+
50
+ const IS_WIN = process.platform === 'win32';
51
+ // Deterministic repo/tools lookup without node:url; fall back to cwd on Node
52
+ // versions that predate import.meta.dirname.
53
+ const MODULE_ROOT = path.resolve(import.meta.dirname ? path.join(import.meta.dirname, '..') : process.cwd());
54
+
55
+ // AAB -> .apks builds are CPU/IO heavy; allow a cold JVM and large bundles.
56
+ const DEFAULT_BUILD_TIMEOUT_MS = 15 * 60 * 1000;
57
+ const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
58
+ const MAX_RETURN_OUTPUT_CHARS = 64 * 1024;
59
+
60
+ const BUNDLETOOL_MODES = new Set([
61
+ 'default',
62
+ 'system',
63
+ 'universal',
64
+ 'system-compressed',
65
+ 'instant',
66
+ 'archive',
67
+ 'persistent',
68
+ ]);
69
+
70
+ const COMMON_JAR_NAMES = ['bundletool.jar', 'bundletool-all.jar'];
71
+ // bundletool.jar, bundletool-all.jar, bundletool-all-1.17.2.jar, ...
72
+ const JAR_NAME_RE = /^bundletool(?:-all)?(?:[-.][0-9][A-Za-z0-9._-]*)?\.jar$/i;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Small filesystem/path helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function toText(value) {
79
+ if (value === undefined || value === null) return '';
80
+ if (Buffer.isBuffer(value)) return value.toString('utf8');
81
+ return String(value);
82
+ }
83
+
84
+ /** Trim surrounding whitespace/quotes; reject empty and NUL-containing values. */
85
+ function cleanPath(value) {
86
+ if (typeof value !== 'string') return null;
87
+ let text = value.trim();
88
+ if (!text || text.includes('\0')) return null;
89
+ if (
90
+ (text.startsWith('"') && text.endsWith('"')) ||
91
+ (text.startsWith("'") && text.endsWith("'"))
92
+ ) {
93
+ text = text.slice(1, -1).trim();
94
+ }
95
+ return text || null;
96
+ }
97
+
98
+ function requirePath(value, name) {
99
+ if (typeof value !== 'string') throw new TypeError(`${name} must be a non-empty path string`);
100
+ const text = cleanPath(value);
101
+ if (!text) throw new TypeError(`${name} must be a non-empty path string`);
102
+ return path.resolve(text);
103
+ }
104
+
105
+ function isFile(file) {
106
+ try {
107
+ return statSync(file).isFile();
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ function isDirectory(file) {
114
+ try {
115
+ return statSync(file).isDirectory();
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function isExecutable(file, platform) {
122
+ try {
123
+ if (!statSync(file).isFile()) return false;
124
+ if (platform !== 'win32') accessSync(file, fsConstants.X_OK);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ function exeSuffix(platform) {
132
+ return platform === 'win32' ? '.exe' : '';
133
+ }
134
+
135
+ function samePath(left, right) {
136
+ const a = path.resolve(left);
137
+ const b = path.resolve(right);
138
+ return IS_WIN ? a.toLowerCase() === b.toLowerCase() : a === b;
139
+ }
140
+
141
+ function envValue(env, name, platform) {
142
+ if (!env || typeof env !== 'object') return undefined;
143
+ if (platform !== 'win32') return env[name];
144
+ const wanted = name.toUpperCase();
145
+ for (const key of Object.keys(env)) {
146
+ if (key.toUpperCase() === wanted) return env[key];
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function pathEntries(env, platform) {
152
+ const raw = envValue(env, 'PATH', platform);
153
+ if (!raw) return [];
154
+ const entries = [];
155
+ for (const part of String(raw).split(path.delimiter)) {
156
+ const cleaned = cleanPath(part);
157
+ if (cleaned) entries.push(path.resolve(cleaned));
158
+ }
159
+ return entries;
160
+ }
161
+
162
+ function executableNames(name, env, platform) {
163
+ if (platform !== 'win32') return [name];
164
+ const raw = envValue(env, 'PATHEXT', platform) || '.COM;.EXE;.BAT;.CMD';
165
+ const suffixes = [];
166
+ for (const part of String(raw).split(';')) {
167
+ const trimmed = part.trim();
168
+ if (!trimmed) continue;
169
+ const suffix = trimmed.startsWith('.') ? trimmed : `.${trimmed}`;
170
+ // Windows filenames are case-insensitive; probing both spellings keeps
171
+ // tests and unusual PATHEXT values predictable.
172
+ for (const variant of [suffix.toLowerCase(), suffix.toUpperCase()]) {
173
+ if (!suffixes.includes(variant)) suffixes.push(variant);
174
+ }
175
+ }
176
+ return suffixes.map((suffix) => `${name}${suffix}`);
177
+ }
178
+
179
+ function findExecutableOnPath(name, env, platform) {
180
+ const names = executableNames(name, env, platform);
181
+ for (const dir of pathEntries(env, platform)) {
182
+ for (const candidateName of names) {
183
+ const candidate = path.join(dir, candidateName);
184
+ if (isExecutable(candidate, platform)) return candidate;
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Bundletool / Java discovery
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function inspectToolCandidate(value, kindHint, { platform }) {
195
+ const text = cleanPath(value);
196
+ if (!text) return null;
197
+ const file = path.resolve(text);
198
+ const kind = kindHint === 'jar' || /\.jar$/i.test(file) ? 'jar' : 'exe';
199
+ if (kind === 'jar') {
200
+ return isFile(file) ? { kind, path: file } : null;
201
+ }
202
+ return isExecutable(file, platform) ? { kind, path: file } : null;
203
+ }
204
+
205
+ function toolSearchRoots(options) {
206
+ const roots = [];
207
+ const add = (value) => {
208
+ const text = cleanPath(value);
209
+ if (text) roots.push(path.resolve(text));
210
+ };
211
+ const explicitTools = cleanPath(options.toolsDir);
212
+ const explicitRepo = cleanPath(options.repoRoot);
213
+
214
+ if (explicitTools) add(explicitTools);
215
+ if (explicitRepo) {
216
+ const repo = path.resolve(explicitRepo);
217
+ roots.push(repo, path.join(repo, 'tools'));
218
+ } else if (!explicitTools) {
219
+ roots.push(MODULE_ROOT, path.join(MODULE_ROOT, 'tools'));
220
+ const cwd = path.resolve(process.cwd());
221
+ if (!samePath(cwd, MODULE_ROOT)) roots.push(cwd, path.join(cwd, 'tools'));
222
+ }
223
+
224
+ const unique = [];
225
+ for (const root of roots) {
226
+ if (!unique.some((existing) => samePath(existing, root))) unique.push(root);
227
+ }
228
+ return unique;
229
+ }
230
+
231
+ function findRepoJar(options) {
232
+ for (const root of toolSearchRoots(options)) {
233
+ if (!isDirectory(root)) continue;
234
+ for (const name of COMMON_JAR_NAMES) {
235
+ const candidate = path.join(root, name);
236
+ if (isFile(candidate)) return candidate;
237
+ }
238
+ let entries = [];
239
+ try {
240
+ entries = readdirSync(root, { withFileTypes: true });
241
+ } catch {
242
+ continue;
243
+ }
244
+ const versioned = entries
245
+ .filter((entry) => entry.isFile() && JAR_NAME_RE.test(entry.name))
246
+ .map((entry) => entry.name)
247
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
248
+ for (const name of versioned) {
249
+ const candidate = path.join(root, name);
250
+ if (isFile(candidate)) return candidate;
251
+ }
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function resolveJavaPath(options, env, platform) {
257
+ const explicit = cleanPath(options.javaPath);
258
+ if (explicit) {
259
+ return /[\\/]/.test(explicit) ? path.resolve(explicit) : explicit;
260
+ }
261
+
262
+ for (const key of ['JAVA_HOME', 'ANDROID_JAVA_HOME', 'JDK_HOME']) {
263
+ const home = cleanPath(envValue(env, key, platform));
264
+ if (!home) continue;
265
+ const base = path.resolve(home);
266
+ const candidates = [
267
+ path.join(base, 'bin', `java${exeSuffix(platform)}`),
268
+ path.join(base, 'Contents', 'Home', 'bin', `java${exeSuffix(platform)}`),
269
+ ];
270
+ if (platform === 'win32') {
271
+ candidates.push(path.join(base, 'bin', 'java'), path.join(base, 'bin', 'java.cmd'));
272
+ }
273
+ for (const candidate of candidates) {
274
+ if (isFile(candidate)) return candidate;
275
+ }
276
+ }
277
+
278
+ const onPath = findExecutableOnPath('java', env, platform);
279
+ if (onPath) return onPath;
280
+ // Last resort: rely on PATH inside spawn; a missing JDK surfaces as ok:false.
281
+ return `java${exeSuffix(platform)}`;
282
+ }
283
+
284
+ /**
285
+ * Find bundletool without downloading or executing anything.
286
+ *
287
+ * @param {object} [options]
288
+ * @param {string} [options.bundletoolPath] explicit jar or executable
289
+ * @param {string} [options.jarPath] explicit jar (forces jar mode)
290
+ * @param {string} [options.javaPath] explicit Java executable
291
+ * @param {string} [options.repoRoot] repository root to search (tests)
292
+ * @param {string} [options.toolsDir] tools directory to search (tests)
293
+ * @param {object} [options.env] complete environment map (defaults process.env)
294
+ * @param {string} [options.platform] OS override used by tests
295
+ * @returns {{ available: boolean, kind: 'jar'|'exe'|'missing', path: string|null, javaPath: string }}
296
+ */
297
+ export function detectBundletool(options = {}) {
298
+ const opts = options && typeof options === 'object' ? options : {};
299
+ const platform = typeof opts.platform === 'string' && opts.platform ? opts.platform : process.platform;
300
+ const env = opts.env && typeof opts.env === 'object' ? opts.env : process.env;
301
+ const javaPath = resolveJavaPath(opts, env, platform);
302
+
303
+ let match = null;
304
+
305
+ for (const candidate of [
306
+ { value: opts.bundletoolPath, hint: null },
307
+ { value: opts.jarPath, hint: 'jar' },
308
+ ]) {
309
+ if (match) break;
310
+ match = inspectToolCandidate(candidate.value, candidate.hint, { platform });
311
+ }
312
+
313
+ if (!match) {
314
+ match = inspectToolCandidate(envValue(env, 'BUNDLETOOL_JAR', platform), 'jar', { platform });
315
+ }
316
+ if (!match) {
317
+ match = inspectToolCandidate(envValue(env, 'BUNDLETOOL_PATH', platform), null, { platform });
318
+ }
319
+ if (!match) {
320
+ const exe = findExecutableOnPath('bundletool', env, platform);
321
+ if (exe) match = { kind: 'exe', path: exe };
322
+ }
323
+ if (!match) {
324
+ const jar = findRepoJar(opts);
325
+ if (jar) match = { kind: 'jar', path: jar };
326
+ }
327
+
328
+ return {
329
+ available: Boolean(match),
330
+ kind: match ? match.kind : 'missing',
331
+ path: match ? match.path : null,
332
+ javaPath,
333
+ };
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Bounded argv-only process execution (no shell)
338
+ // ---------------------------------------------------------------------------
339
+
340
+ function spawnTarget(file, args, env) {
341
+ // Windows cannot CreateProcess() a .bat/.cmd directly (spawn throws EINVAL).
342
+ // The OS requires cmd.exe for batch shims; keep the shim path and every
343
+ // argument as separate argv entries so nothing is concatenated into a shell
344
+ // command string. The default shell remains disabled.
345
+ if (process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file)) {
346
+ const comspec = (env && (env.ComSpec || env.COMSPEC)) || process.env.ComSpec || 'cmd.exe';
347
+ return { file: comspec, args: ['/d', '/s', '/c', file, ...args] };
348
+ }
349
+ return { file, args };
350
+ }
351
+
352
+ function errorText(error) {
353
+ if (!error) return null;
354
+ return error.message ? String(error.message) : String(error);
355
+ }
356
+
357
+ function runProcess(file, args, options = {}) {
358
+ const timeoutMs =
359
+ Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
360
+ ? Math.floor(options.timeoutMs)
361
+ : DEFAULT_BUILD_TIMEOUT_MS;
362
+ const maxBytes =
363
+ Number.isFinite(options.maxBytes) && options.maxBytes > 0
364
+ ? Math.floor(options.maxBytes)
365
+ : DEFAULT_MAX_OUTPUT_BYTES;
366
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env;
367
+
368
+ return new Promise((resolve) => {
369
+ if (typeof file !== 'string' || file.trim() === '') {
370
+ resolve({
371
+ code: null,
372
+ signal: null,
373
+ timedOut: false,
374
+ truncated: false,
375
+ stdout: '',
376
+ stderr: '',
377
+ error: 'missing executable path',
378
+ });
379
+ return;
380
+ }
381
+
382
+ const target = spawnTarget(file, args, env);
383
+ let child;
384
+ try {
385
+ child = spawn(target.file, target.args, {
386
+ windowsHide: true,
387
+ stdio: ['ignore', 'pipe', 'pipe'],
388
+ env,
389
+ cwd: options.cwd,
390
+ });
391
+ } catch (error) {
392
+ resolve({
393
+ code: null,
394
+ signal: null,
395
+ timedOut: false,
396
+ truncated: false,
397
+ stdout: '',
398
+ stderr: '',
399
+ error: errorText(error) || 'spawn failed',
400
+ });
401
+ return;
402
+ }
403
+
404
+ const collectors = {
405
+ stdout: { chunks: [], bytes: 0 },
406
+ stderr: { chunks: [], bytes: 0 },
407
+ };
408
+ let truncated = false;
409
+ let settled = false;
410
+ let timer = null;
411
+
412
+ const collect = (collector, chunk) => {
413
+ if (!chunk || chunk.length === 0) return;
414
+ if (collector.bytes >= maxBytes) {
415
+ truncated = true;
416
+ return;
417
+ }
418
+ const remaining = maxBytes - collector.bytes;
419
+ const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
420
+ collector.chunks.push(Buffer.from(slice));
421
+ collector.bytes += slice.length;
422
+ if (slice.length < chunk.length) truncated = true;
423
+ };
424
+
425
+ const snapshot = (extra) => ({
426
+ code: null,
427
+ signal: null,
428
+ timedOut: false,
429
+ truncated,
430
+ stdout: Buffer.concat(collectors.stdout.chunks).toString('utf8'),
431
+ stderr: Buffer.concat(collectors.stderr.chunks).toString('utf8'),
432
+ ...extra,
433
+ });
434
+
435
+ const finish = (result) => {
436
+ if (settled) return;
437
+ settled = true;
438
+ if (timer) clearTimeout(timer);
439
+ resolve(result);
440
+ };
441
+
442
+ child.stdout.on('data', (chunk) => collect(collectors.stdout, chunk));
443
+ child.stderr.on('data', (chunk) => collect(collectors.stderr, chunk));
444
+ child.on('error', (error) => finish(snapshot({ error: errorText(error) || 'spawn failed' })));
445
+ child.on('close', (code, signal) => {
446
+ finish(snapshot({ code: typeof code === 'number' ? code : null, signal: signal || null }));
447
+ });
448
+
449
+ timer = setTimeout(() => {
450
+ try {
451
+ child.kill('SIGKILL');
452
+ } catch {
453
+ /* already gone */
454
+ }
455
+ finish(snapshot({ timedOut: true, signal: 'SIGKILL' }));
456
+ }, timeoutMs);
457
+ });
458
+ }
459
+
460
+ function normalizeExecResult(raw) {
461
+ const value = raw && typeof raw === 'object' ? raw : {};
462
+ let code = null;
463
+ if (Number.isFinite(value.code)) code = Number(value.code);
464
+ else if (value.ok === true) code = 0;
465
+ else if (value.ok === false) code = 1;
466
+
467
+ const timedOut = value.timedOut === true;
468
+ const error = value.error === undefined || value.error === null ? null : toText(value.error);
469
+ return {
470
+ code,
471
+ signal: value.signal === undefined || value.signal === null ? null : String(value.signal),
472
+ timedOut,
473
+ truncated: value.truncated === true,
474
+ stdout: toText(value.stdout),
475
+ stderr: toText(value.stderr),
476
+ error,
477
+ ok: code === 0 && !timedOut && !error,
478
+ };
479
+ }
480
+
481
+ async function runExec(exec, file, args, options) {
482
+ try {
483
+ const raw = await exec(file, args, options);
484
+ return normalizeExecResult(raw);
485
+ } catch (error) {
486
+ return normalizeExecResult({ code: null, error: errorText(error) || 'exec failed' });
487
+ }
488
+ }
489
+
490
+ function combineOutput(result, secrets) {
491
+ const parts = [];
492
+ if (result.stdout.trim()) parts.push(result.stdout.trimEnd());
493
+ if (result.stderr.trim()) parts.push(result.stderr.trimEnd());
494
+ let output = parts.join('\n');
495
+ for (const secret of secrets) {
496
+ if (secret) output = output.split(secret).join('<redacted>');
497
+ }
498
+ if (output.length > MAX_RETURN_OUTPUT_CHARS) {
499
+ output = output.slice(-MAX_RETURN_OUTPUT_CHARS);
500
+ }
501
+ return output;
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Validation and .apks building
506
+ // ---------------------------------------------------------------------------
507
+
508
+ async function requireAabFile(aabPath) {
509
+ const file = requirePath(aabPath, 'aabPath');
510
+ if (path.extname(file).toLowerCase() !== '.aab') {
511
+ throw new TypeError(`aabPath must point to a .aab file: ${file}`);
512
+ }
513
+ let stat;
514
+ try {
515
+ stat = await fsp.stat(file);
516
+ } catch (error) {
517
+ if (error && error.code === 'ENOENT') throw new Error(`AAB file not found: ${file}`);
518
+ throw new Error(`cannot stat AAB file ${file}: ${(error && (error.code || error.message)) || 'unknown error'}`);
519
+ }
520
+ if (!stat.isFile()) throw new Error(`AAB path is not a regular file: ${file}`);
521
+ return file;
522
+ }
523
+
524
+ function normalizeMode(value) {
525
+ const mode = value === undefined || value === null ? 'universal' : String(value).trim().toLowerCase();
526
+ if (!BUNDLETOOL_MODES.has(mode)) {
527
+ throw new TypeError(
528
+ `unsupported mode: ${JSON.stringify(String(value))}; expected one of ${[...BUNDLETOOL_MODES].join(', ')}`,
529
+ );
530
+ }
531
+ return mode;
532
+ }
533
+
534
+ async function ensureOutDir(outDirValue, aabPath) {
535
+ const outDir = requirePath(outDirValue, 'outDir');
536
+ if (samePath(outDir, aabPath)) throw new TypeError('outDir must be a directory, not the .aab file');
537
+ let stat = null;
538
+ try {
539
+ stat = await fsp.stat(outDir);
540
+ } catch {
541
+ stat = null;
542
+ }
543
+ if (stat && !stat.isDirectory()) throw new Error(`outDir exists but is not a directory: ${outDir}`);
544
+ if (!stat) {
545
+ try {
546
+ await fsp.mkdir(outDir, { recursive: true });
547
+ } catch (error) {
548
+ throw new Error(`cannot create outDir ${outDir}: ${(error && (error.code || error.message)) || 'unknown error'}`);
549
+ }
550
+ }
551
+ return outDir;
552
+ }
553
+
554
+ function apksStem(aabPath) {
555
+ const raw = path.basename(aabPath, path.extname(aabPath));
556
+ const cleaned = raw
557
+ .replace(/[\u0000-\u001f<>:"/\\|?*]/g, '_')
558
+ .replace(/^[. ]+|[. ]+$/g, '');
559
+ return cleaned || 'app';
560
+ }
561
+
562
+ async function ensureOutputTarget(apksPath) {
563
+ let stat = null;
564
+ try {
565
+ stat = await fsp.stat(apksPath);
566
+ } catch {
567
+ stat = null;
568
+ }
569
+ if (stat && stat.isDirectory()) throw new Error(`output path is a directory: ${apksPath}`);
570
+ }
571
+
572
+ function secretText(value) {
573
+ if (value === undefined || value === null) return null;
574
+ const text = String(value);
575
+ if (text.includes('\0')) throw new TypeError('signing password contains a NUL byte');
576
+ return text;
577
+ }
578
+
579
+ async function signingArguments(options) {
580
+ const keystoreProvided =
581
+ options.keystore !== undefined &&
582
+ options.keystore !== null &&
583
+ String(options.keystore).trim() !== '';
584
+ const password = secretText(options.ksPassword);
585
+ const keyPassword = secretText(options.ksKeyPassword);
586
+ let alias = null;
587
+ if (options.ksAlias !== undefined && options.ksAlias !== null) {
588
+ alias = String(options.ksAlias).trim();
589
+ if (alias.includes('\0')) throw new TypeError('ksAlias contains a NUL byte');
590
+ if (!alias) throw new TypeError('ksAlias must not be empty when provided');
591
+ }
592
+ const hasExtraSigningOptions = password !== null || keyPassword !== null || alias !== null;
593
+
594
+ if (!keystoreProvided) {
595
+ if (hasExtraSigningOptions) {
596
+ throw new TypeError('keystore is required when ksPassword, ksAlias or ksKeyPassword is provided');
597
+ }
598
+ return { args: [], secrets: [] };
599
+ }
600
+
601
+ const keystore = requirePath(options.keystore, 'keystore');
602
+ let stat;
603
+ try {
604
+ stat = await fsp.stat(keystore);
605
+ } catch (error) {
606
+ if (error && error.code === 'ENOENT') throw new Error(`keystore not found: ${keystore}`);
607
+ throw new Error(`cannot stat keystore ${keystore}: ${(error && (error.code || error.message)) || 'unknown error'}`);
608
+ }
609
+ if (!stat.isFile()) throw new Error(`keystore is not a regular file: ${keystore}`);
610
+
611
+ const args = [`--ks=${keystore}`];
612
+ if (password !== null) args.push(`--ks-pass=pass:${password}`);
613
+ if (alias) args.push(`--ks-key-alias=${alias}`);
614
+ if (keyPassword !== null) args.push(`--key-pass=pass:${keyPassword}`);
615
+ return { args, secrets: [password, keyPassword].filter(Boolean) };
616
+ }
617
+
618
+ function failure(error, output = '') {
619
+ return { ok: false, apksPath: null, output, error };
620
+ }
621
+
622
+ function buildFailureMessage(result, tool) {
623
+ if (result.timedOut) return 'bundletool timed out while building the .apks';
624
+ if (result.error) {
625
+ const label = tool.kind === 'jar' ? 'Java/bundletool' : 'bundletool';
626
+ return `failed to start ${label}: ${result.error}`;
627
+ }
628
+ if (result.code !== null && result.code !== 0) return `bundletool exited with code ${result.code}`;
629
+ return 'bundletool build-apks failed';
630
+ }
631
+
632
+ /**
633
+ * Build a device-installable `.apks` from an `.aab`.
634
+ *
635
+ * @param {object} options
636
+ * @param {string} options.aabPath path to an existing `.aab` file
637
+ * @param {string} options.outDir directory created if missing; output is
638
+ * `<aab stem>.apks` inside it
639
+ * @param {string} [options.bundletoolPath] explicit tool jar/executable
640
+ * @param {string} [options.javaPath] Java executable used for jar mode
641
+ * @param {string} [options.keystore] signing keystore (optional)
642
+ * @param {string} [options.ksPassword] keystore password
643
+ * @param {string} [options.ksAlias] key alias
644
+ * @param {string} [options.ksKeyPassword] key password
645
+ * @param {string} [options.mode='universal'] bundletool --mode value
646
+ * @param {number} [options.timeoutMs] build timeout
647
+ * @param {object} [options.env] environment map used for discovery/spawn
648
+ * @param {Function} [options.exec] injectable runner (tests)
649
+ * @returns {Promise<{ ok: boolean, apksPath: string|null, output: string, error?: string }>}
650
+ */
651
+ export async function buildApksFromAab(options = {}) {
652
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
653
+ throw new TypeError('buildApksFromAab options must be an object');
654
+ }
655
+
656
+ const aabPath = await requireAabFile(options.aabPath);
657
+ const mode = normalizeMode(options.mode);
658
+ const outDir = await ensureOutDir(options.outDir, aabPath);
659
+ const apksPath = path.join(outDir, `${apksStem(aabPath)}.apks`);
660
+ await ensureOutputTarget(apksPath);
661
+
662
+ const signing = await signingArguments(options);
663
+ const tool = detectBundletool(options);
664
+ if (!tool.available) {
665
+ return failure(
666
+ 'bundletool not found: set bundletoolPath/jarPath, BUNDLETOOL_JAR/BUNDLETOOL_PATH, ' +
667
+ 'put a bundletool executable on PATH, or place bundletool*.jar in the repository/tools directory',
668
+ );
669
+ }
670
+
671
+ const javaPath = tool.javaPath || `java${exeSuffix(process.platform)}`;
672
+ const commonArgs = [
673
+ `--bundle=${aabPath}`,
674
+ `--output=${apksPath}`,
675
+ `--mode=${mode}`,
676
+ ...signing.args,
677
+ ];
678
+ const command = tool.kind === 'jar' ? javaPath : tool.path;
679
+ const commandArgs =
680
+ tool.kind === 'jar'
681
+ ? ['-jar', tool.path, 'build-apks', ...commonArgs]
682
+ : ['build-apks', ...commonArgs];
683
+
684
+ const spawnEnv =
685
+ options.env && typeof options.env === 'object' ? { ...process.env, ...options.env } : process.env;
686
+ const timeoutMs =
687
+ Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
688
+ ? Math.floor(options.timeoutMs)
689
+ : DEFAULT_BUILD_TIMEOUT_MS;
690
+ const exec = typeof options.exec === 'function' ? options.exec : runProcess;
691
+ const result = await runExec(exec, command, commandArgs, {
692
+ timeoutMs,
693
+ maxBytes: DEFAULT_MAX_OUTPUT_BYTES,
694
+ env: spawnEnv,
695
+ cwd: outDir,
696
+ });
697
+ const output = combineOutput(result, signing.secrets);
698
+
699
+ if (!result.ok) {
700
+ return failure(buildFailureMessage(result, tool), output);
701
+ }
702
+
703
+ let built = null;
704
+ try {
705
+ built = await fsp.stat(apksPath);
706
+ } catch {
707
+ built = null;
708
+ }
709
+ if (!built || !built.isFile()) {
710
+ return failure(`bundletool reported success but did not create ${apksPath}`, output);
711
+ }
712
+
713
+ return { ok: true, apksPath, output };
714
+ }
715
+
716
+ /**
717
+ * Lightweight `.aab` metadata: stat size and base name, plus tool availability.
718
+ * Deliberately does not unzip or parse the App Bundle.
719
+ *
720
+ * @param {string} aabPath
721
+ * @returns {Promise<{ ok: true, path: string, name: string, size: number,
722
+ * available: boolean, tool: { kind: string, path: string|null, javaPath: string } }>}
723
+ */
724
+ export async function aabInfo(aabPath) {
725
+ const file = await requireAabFile(aabPath);
726
+ const stat = await fsp.stat(file);
727
+ const tool = detectBundletool();
728
+ return {
729
+ ok: true,
730
+ path: file,
731
+ name: path.basename(file),
732
+ size: stat.size,
733
+ available: tool.available,
734
+ tool: { kind: tool.kind, path: tool.path, javaPath: tool.javaPath },
735
+ };
736
+ }