storebound/cli/lib/credentials.ts

375 lines
24 KiB
TypeScript

// Credentials never live in storebound.config.ts or in git. storebound is installed once per machine, and so are the
// store accounts: a credential profile in ~/.storebound/credentials/<name>.json (folder: STOREBOUND_CREDENTIALS_DIR)
// holds one Apple team key and / or one Play service account, shared by the CLI and the desktop app. Everything that
// belongs to one app stays in the project: <project>/.storebound/credentials.json (gitignored) has its Android upload
// keystore, the App Review contact and demo account, and may still hold store keys of its own.
//
// Which profile a project uses (first hit): --account <name> / env STOREBOUND_PROFILE (set but empty = none), the
// config's `account`, "account" in <project>/.storebound/credentials.json (written by `storebound accounts import`),
// else the default: the one `storebound accounts default <name>` set (the pointer file <credentials>/default-profile),
// else the only profile on this machine, else the one named "default".
//
// One value, first hit wins:
// 1. environment: PLAY_SERVICE_ACCOUNT (path to the JSON key, or the JSON itself), ASC_KEY_ID, ASC_ISSUER_ID,
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself), ASC_VENDOR_NUMBER, ANDROID_*, ...
// 2. the project's profile when it names one (--account, config, credentials.json "account"): store keys only
// 3. <project>/.storebound/credentials.json, same names:
// { "account": "<profile>", "ANDROID_KEYSTORE": "...", ...,
// "reviewContact": { "firstName": "", "lastName": "", "phone": "", "email": "" },
// "reviewDemoAccount": { "username": "", "password": "" } }
// The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD.
// 4. the default profile (none named): store keys only
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string }
export interface AscKey { keyId: string; issuerId: string; pem: string }
export interface ReviewContact { firstName: string; lastName: string; phone: string; email: string }
type Store = Record<string, unknown>;
function fileStore(root: string): Store {
const p = join(root, '.storebound', 'credentials.json');
if (!existsSync(p)) return {};
try { return JSON.parse(readFileSync(p, 'utf8')) as Store; } catch (e) { throw new Error(`${p}: ${(e as Error).message}`); }
}
/** One credential by name: env first, then .storebound/credentials.json. Never print the value. */
export function credentialValue(root: string, name: string): string | undefined { return value(root, name); }
/** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number
* for reports too; an app's Android upload keystore stays in the project. */
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID'] as const;
export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials');
const profileEnvName = (profile: string, key: string): string => `STOREBOUND_PROFILE_${profile.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_${key}`;
/**
* The credentials of one profile as env values (never printed). Env STOREBOUND_PROFILE_<NAME>_<KEY> wins over the
* profile file; a relative key path in the file is relative to the file. Throws on a bad name or an unreadable file.
*/
export function profileEnv(profile: string): Record<string, string> {
if (!PROFILE_RE.test(profile)) throw new Error(`credential profile "${profile}": letters, digits, . _ - (max 64)`);
const file = join(profileDir(), `${profile}.json`);
let stored: Store = {};
if (existsSync(file)) {
try { stored = JSON.parse(readFileSync(file, 'utf8')) as Store; } catch (e) { throw new Error(`credential profile ${profile}: ${file}: ${(e as Error).message}`); }
}
const out: Record<string, string> = {};
for (const k of PROFILE_KEYS) {
const env = process.env[profileEnvName(profile, k)];
const v = env || (typeof stored[k] === 'string' ? stored[k] as string : '');
if (!v) continue;
// a path in the file: relative to the profile folder (inline JSON / PEM stays as it is)
const isPath = !env && !/^\s*\{/.test(v) && !/BEGIN PRIVATE KEY/.test(v) && (k === 'PLAY_SERVICE_ACCOUNT' || k === 'ASC_P8');
out[k] = isPath && !isAbsolute(v) && !v.startsWith('~') ? resolve(profileDir(), v) : v;
}
return out;
}
/** The profiles on this machine (the *.json files of the credentials folder), sorted. */
export function profileNames(): string[] {
let names: string[] = [];
try { names = readdirSync(profileDir()).filter(n => n.endsWith('.json')).map(n => n.slice(0, -5)); } catch { /* no folder yet */ }
return names.filter(n => PROFILE_RE.test(n)).sort();
}
/** The pointer file `storebound accounts default <name>` writes: one profile name, nothing else (never a key). */
export const defaultPointerFile = (): string => join(profileDir(), 'default-profile');
/** The profile the pointer file names, when it is a valid name; null without one. Not checked against the folder. */
export function defaultPointer(): string | null {
try {
const n = readFileSync(defaultPointerFile(), 'utf8').trim();
return PROFILE_RE.test(n) ? n : null;
} catch { return null; }
}
/** Sets (or with null clears) the default profile pointer. Throws on a name that is not a profile on this machine. */
export function setDefaultPointer(name: string | null): string {
const f = defaultPointerFile();
if (name === null) { rmSync(f, { force: true }); return f; }
if (!profileNames().includes(name)) throw new Error(`no profile "${name}" on this machine (${profileNames().join(', ') || 'none yet'}; storebound accounts lists them)`);
mkdirSync(profileDir(), { recursive: true, mode: 0o700 });
writeFileSync(f, name + '\n', { mode: 0o600 });
return f;
}
/** The default profile: the one the pointer names (when it exists), else the only one on this machine, else the one
* named "default"; null when that is not clear. */
export function defaultProfile(names = profileNames(), pointer = defaultPointer()): string | null {
if (pointer && names.includes(pointer)) return pointer;
return names.length === 1 ? names[0]! : names.includes('default') ? 'default' : null;
}
/** config.account per project root, set by loadConfig (config.ts) so credentials need not load the config. */
const configAccounts = new Map<string, string>();
export function setConfigAccount(root: string, name: string | undefined): void {
if (name) configAccounts.set(resolve(root), name); else configAccounts.delete(resolve(root));
}
export interface ActiveProfile { name: string; from: 'env' | 'config' | 'project' | 'default' }
/** The profile a project uses (see the header), or null. Throws only on an unreadable project credentials file. */
export function activeProfile(root: string): ActiveProfile | null {
const env = process.env.STOREBOUND_PROFILE;
if (env !== undefined) return env ? { name: env, from: 'env' } : null;
const cfg = configAccounts.get(resolve(root));
if (cfg) return { name: cfg, from: 'config' };
const own = fileStore(root).account;
if (typeof own === 'string' && own) return { name: own, from: 'project' };
const d = defaultProfile();
return d ? { name: d, from: 'default' } : null;
}
/** Which stores a profile can reach, and where it comes from. No values. */
export function profileStatus(profile: string): { profile: string; source: 'file' | 'env' | 'missing' | 'invalid'; apple: boolean; play: boolean; error?: string } {
try {
const env = profileEnv(profile);
const fromEnv = PROFILE_KEYS.some(k => !!process.env[profileEnvName(profile, k)]);
const source = fromEnv ? 'env' : existsSync(join(profileDir(), `${profile}.json`)) ? 'file' : 'missing';
return { profile, source, apple: !!(env.ASC_KEY_ID && env.ASC_ISSUER_ID), play: !!env.PLAY_SERVICE_ACCOUNT };
} catch (e) {
return { profile, source: 'invalid', apple: false, play: false, error: (e as Error).message };
}
}
/**
* A value that only looks like a credential: a template placeholder ("your-…", "YOUR_KEY_ID", "<KEY_ID>", "xxx",
* "/absolute/path/to/…", the all-zero Apple Ads client id, "example" as a word unless it is a file that exists). Treated as
* missing everywhere (check, apps check, doctor and every command), never sent to a store. `example: false` for the
* review contact and demo account (an app's own backend can have a real demo@example.com account).
*/
export function isPlaceholder(v: string, o: { example?: boolean } = {}): boolean {
const s = v.trim();
if (!s || /BEGIN PRIVATE KEY/.test(s) || /^\s*\{/.test(s)) return false;
if (/(^|[_\-\s/])your([_\-\s]|$)/i.test(s) || /<[^<>\n]+>/.test(s) || /(^|[^a-z0-9])x{3,}([^a-z0-9]|$)/i.test(s) || /(^|\/)path\/to\//.test(s)) return true;
if (/^SEARCHADS\.0{8}-0{4}-0{4}-0{4}-0{12}$/i.test(s)) return true;
if (o.example !== false && /(^|[^a-z0-9])example([^a-z0-9]|$)/i.test(s)) {
try { return !existsSync(s.replace(/^~(?=\/)/, homedir())); } catch { return true; }
}
return false;
}
/** Credential names storebound reads (env, .storebound/credentials.json, a profile), for status reports. */
export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID',
'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY', 'APPLE_ADS_ACCOUNT_ID', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD'] as const;
/** Where each known credential stands: set, missing, or placeholder (a template value, treated as missing). Never
* the values. */
export function credentialStatus(root: string): { key: string; status: 'set' | 'missing' | 'placeholder' }[] {
let file: Store = {};
try { file = fileStore(root); } catch { /* unreadable: reported by the commands that read it */ }
let prof: Record<string, string> = {};
try { const p = activeProfile(root); if (p) prof = profileEnv(p.name); } catch { /* invalid profile */ }
return CREDENTIAL_KEYS.map(key => {
const raw = [prof[key], process.env[key], typeof file[key] === 'string' ? file[key] as string : undefined].filter((x): x is string => !!x && !!x.trim());
return { key, status: raw.some(x => !isPlaceholder(x)) ? 'set' as const : raw.length ? 'placeholder' as const : 'missing' as const };
});
}
/** One value by the order in the header: env, a named profile, the project file, the default profile. */
function value(root: string, name: string): string | undefined {
const real = (v: unknown): v is string => typeof v === 'string' && !!v && !isPlaceholder(v);
const env = process.env[name];
if (real(env)) return env;
const p = activeProfile(root);
const fromProfile = () => (p && (PROFILE_KEYS as readonly string[]).includes(name) ? profileEnv(p.name)[name] : undefined);
if (p && p.from !== 'default') { const v = fromProfile(); if (real(v)) return v; }
const v = fileStore(root)[name];
if (real(v)) return v;
if (p?.from === 'default') { const d = fromProfile(); if (real(d)) return d; }
return undefined;
}
/** A path or the inline content itself. */
export const inline = (v: string, marker: RegExp, root?: string): string => (marker.test(v) ? v : readFileSync(credPath(v, root), 'utf8'));
/** A credential path: ~ expanded; a relative one is taken from the project folder, not the working directory. */
export function credPath(v: string, root?: string): string {
const h = v.replace(/^~(?=[/\\])/, homedir());
return isAbsolute(h) || !root ? h : resolve(root, h);
}
export function parseServiceAccount(text: string): ServiceAccount {
const j = JSON.parse(text) as Partial<ServiceAccount>;
if (typeof j.client_email !== 'string' || typeof j.private_key !== 'string' || !j.private_key.includes('PRIVATE KEY'))
throw new Error('not a Google service account JSON key (client_email / private_key missing)');
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri };
}
export function playServiceAccount(root: string): ServiceAccount {
const v = value(root, 'PLAY_SERVICE_ACCOUNT');
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles');
return parseServiceAccount(inline(v, /^\s*\{/, root));
}
export function ascKey(root: string): AscKey {
const keyId = value(root, 'ASC_KEY_ID');
const issuerId = value(root, 'ASC_ISSUER_ID');
if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json); `storebound accounts` lists the profiles');
const p8 = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${keyId}.p8`);
if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(credPath(p8, root))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`);
return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/, root) };
}
/** The keys and nestings a review contact is read from (first hit): the documented one, then shapes seen in real files. */
export const REVIEW_CONTACT_KEYS = ['reviewContact', 'apple.reviewContact', 'appReview.contact', 'review.contact', 'reviewDetails.contact', 'contact'] as const;
const pickStr = (o: Record<string, unknown>, ...ks: string[]): string => { for (const k of ks) { const v = o[k]; if (typeof v === 'string' && v.trim()) return v.trim(); } return ''; };
/** A contact object in any accepted field spelling (firstName / first_name / contactFirstName; a "name" is split). */
export function contactOf(raw: unknown): ReviewContact | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const email = pickStr(o, 'email', 'contactEmail', 'emailAddress');
if (!email || isPlaceholder(email, { example: false })) return null;
let firstName = pickStr(o, 'firstName', 'first_name', 'contactFirstName', 'givenName'), lastName = pickStr(o, 'lastName', 'last_name', 'contactLastName', 'familyName');
const name = pickStr(o, 'name', 'fullName');
if (!firstName && !lastName && name) { const parts = name.split(/\s+/); firstName = parts[0] ?? ''; lastName = parts.slice(1).join(' '); }
return { firstName, lastName, phone: pickStr(o, 'phone', 'contactPhone', 'phoneNumber'), email };
}
const atPath = (o: Store, path: string): unknown => path.split('.').reduce<unknown>((x, k) => (x && typeof x === 'object' ? (x as Store)[k] : undefined), o);
/** Where a review contact is looked up, in order: the project's .storebound/credentials.json, the same file in each
* parent folder up to the repository root (a config in a subfolder), then the project's credential profile file. */
function contactFiles(root: string): string[] {
const out: string[] = [];
let d = resolve(root);
for (let i = 0; i < 6; i++) {
out.push(join(d, '.storebound', 'credentials.json'));
if (existsSync(join(d, '.git'))) break;
const up = resolve(d, '..');
if (up === d || up === homedir()) break;
d = up;
}
try { const p = activeProfile(root); if (p && PROFILE_RE.test(p.name)) out.push(join(profileDir(), `${p.name}.json`)); } catch { /* unreadable project file: listed below */ }
return out;
}
/** App Review contact (personal data, never in the config), the file and key it came from, and every place checked. */
export function findReviewContact(root: string): { contact: ReviewContact | null; from: string | null; checked: string[] } {
const checked: string[] = [];
for (const f of contactFiles(root)) {
if (!existsSync(f)) { checked.push(`${f} (missing)`); continue; }
let st: Store;
try { st = JSON.parse(readFileSync(f, 'utf8')) as Store; } catch (e) { checked.push(`${f} (unreadable: ${(e as Error).message})`); continue; }
let present = '';
for (const k of REVIEW_CONTACT_KEYS) {
const raw = atPath(st, k);
const c = contactOf(raw);
if (c) return { contact: c, from: `${f} ${k}`, checked };
// a key that is there but holds no usable email (empty strings copied from a template): say exactly that
if (!present && raw && typeof raw === 'object' && !Array.isArray(raw)) {
const vals = Object.values(raw as Record<string, unknown>).filter(v => typeof v === 'string') as string[];
present = vals.every(v => !v.trim()) ? `${k} found but all its fields are empty` : `${k} found but its email is missing or a placeholder`;
}
}
checked.push(present ? `${f} (${present}: fill firstName, lastName, phone, email)` : `${f} (keys ${REVIEW_CONTACT_KEYS.join(', ')}: none with an email)`);
}
return { contact: null, from: null, checked };
}
/** The error when no review contact is found: every file and key that was checked, and the fix. */
export function noContactMessage(root: string, checked: string[] = findReviewContact(root).checked): string {
return `no review contact. Checked: ${checked.join('; ') || '(nothing)'}. Add "reviewContact": { "firstName", "lastName", "phone", "email" } to ${join(resolve(root), '.storebound', 'credentials.json')}, or pass --contact-from <Apple ID of another app>`;
}
/** App Review contact from .storebound/credentials.json (personal data, so never in the config). */
export function reviewContact(root: string): ReviewContact | null {
return findReviewContact(root).contact;
}
export interface DemoAccount { username: string; password: string }
/** App Review demo account (sign-in for the reviewer): env REVIEW_DEMO_USER + REVIEW_DEMO_PASSWORD, else
* "reviewDemoAccount" in .storebound/credentials.json. Never in the config or the repo. */
export function reviewDemoAccount(root: string): DemoAccount | null {
const eu = process.env.REVIEW_DEMO_USER, ep = process.env.REVIEW_DEMO_PASSWORD;
if (eu && ep) return { username: eu, password: ep };
const d = fileStore(root).reviewDemoAccount as Partial<DemoAccount> | undefined;
if (typeof d?.username === 'string' && d.username && !isPlaceholder(d.username, { example: false }) && typeof d.password === 'string' && d.password) return { username: d.username, password: d.password };
return null;
}
/** For logs: "d***@example.com" / "d***"; a password is never shown, only its length class. */
export const maskUser = (u: string): string => {
const at = u.indexOf('@');
return at > 0 ? `${u[0]}***${u.slice(at)}` : `${u[0] ?? ''}***`;
};
export const maskSecret = (s: string): string => (s ? '********' : '(empty)');
/** The App Store Connect key as a file, for tools that want a path (xcodebuild -authenticationKeyPath, altool).
* A path in ASC_P8 (or the usual ~/.appstoreconnect location) is used as is; inline PEM content goes to a private
* temporary folder (0700 / 0600) that `cleanup` removes. Commands show the path as $ASC_P8. */
export function ascKeyFile(root: string): { keyPath: string; keyId: string; issuerId: string; cleanup: () => void } {
const k = ascKey(root);
const raw = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${k.keyId}.p8`);
// xcodebuild refuses a relative -authenticationKeyPath ("must be an absolute path"): resolve it from the project
const asPath = (p: string) => { const h = p.replace(/^~(?=[/\\])/, homedir()); return isAbsolute(h) ? h : resolve(root, h); };
if (!/BEGIN PRIVATE KEY/.test(raw)) return { keyPath: asPath(raw), keyId: k.keyId, issuerId: k.issuerId, cleanup: () => {} };
const dir = mkdtempSync(join(tmpdir(), 'storebound-key-'));
chmodSync(dir, 0o700);
const keyPath = join(dir, `AuthKey_${k.keyId}.p8`);
writeFileSync(keyPath, k.pem, { mode: 0o600 });
return { keyPath, keyId: k.keyId, issuerId: k.issuerId, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
}
export interface AndroidSigning { storeFile: string; storePassword: string; keyAlias: string; keyPassword: string }
/** Android upload key for `play build` (env or .storebound/credentials.json): ANDROID_KEYSTORE (path to the .jks /
* .keystore), ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD (default: the keystore password).
* null when no keystore is set (the project's own signingConfig signs then, e.g. Flutter's key.properties). */
export function androidSigning(root: string): AndroidSigning | null {
const storeFile = value(root, 'ANDROID_KEYSTORE');
if (!storeFile) return null;
const storePassword = value(root, 'ANDROID_KEYSTORE_PASSWORD'), keyAlias = value(root, 'ANDROID_KEY_ALIAS');
if (!storePassword || !keyAlias) throw new Error('ANDROID_KEYSTORE is set but ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS are not (env or .storebound/credentials.json)');
const abs = storeFile.replace(/^~(?=\/)/, homedir());
return { storeFile: isAbsolute(abs) ? abs : join(root, abs), storePassword, keyAlias, keyPassword: value(root, 'ANDROID_KEY_PASSWORD') ?? storePassword };
}
/** App Store Connect vendor number for sales and finance reports (Payments and Financial Reports, top left): the
* credential profile, env ASC_VENDOR_NUMBER, else "ASC_VENDOR_NUMBER" in .storebound/credentials.json. An account
* id, so never in the config. */
export function ascVendorNumber(root: string): string | null {
const v = value(root, 'ASC_VENDOR_NUMBER');
return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null;
}
/** Credential paths that do not exist (a machine move left another computer's absolute paths) and an upload keystore
* the stored password / alias do not open: each names the field. `keytool` runs only when it is on PATH. */
export function credentialFileIssues(root: string, run: (argv: string[]) => { ok: boolean; out: string } | null = keytool): string[] {
const issues: string[] = [];
const asPath = (v: string) => { const p = v.replace(/^~(?=[/\\])/, homedir()); return isAbsolute(p) ? p : join(root, p); };
for (const k of ['PLAY_SERVICE_ACCOUNT', 'ASC_P8', 'ANDROID_KEYSTORE'] as const) {
let v: string | undefined;
try { v = value(root, k); } catch { continue; }
if (!v || /^\s*\{/.test(v) || /BEGIN [A-Z ]*PRIVATE KEY/.test(v)) continue;
if (!existsSync(asPath(v))) issues.push(`${k}: ${v} does not exist on this machine (moved from another computer?): point it at the file here`);
}
let s: AndroidSigning | null = null;
try { s = androidSigning(root); } catch (e) { issues.push((e as Error).message); }
if (s && existsSync(s.storeFile)) {
const r = run(['-list', '-keystore', s.storeFile, '-storepass', s.storePassword, '-alias', s.keyAlias]);
if (r && !r.ok) issues.push(/password|tampered|Keystore was tampered/i.test(r.out)
? 'ANDROID_KEYSTORE_PASSWORD does not open the upload keystore (keytool -list failed): check the password (android/keystore.properties may hold the right one)'
: /alias|does not exist/i.test(r.out) ? `ANDROID_KEY_ALIAS "${s.keyAlias}" is not in the upload keystore` : `the upload keystore does not open with the stored password / alias: ${r.out.trim().split('\n')[0]}`);
}
return issues;
}
function keytool(argv: string[]): { ok: boolean; out: string } | null {
const bin = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'keytool.exe' : 'keytool') : 'keytool';
try {
const r = rt.spawnSync([bin, ...argv], { timeout: 20_000 });
return { ok: r.success, out: `${r.stdout.toString()}${r.stderr.toString()}` };
} catch { return null; }
}