// 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/.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: /.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 / env STOREBOUND_PROFILE (set but empty = none), the // config's `account`, "account" in /.storebound/credentials.json (written by `storebound accounts import`), // else the default: the only profile on this machine, or 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_.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. /.storebound/credentials.json, same names: // { "account": "", "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_.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 /, mode 0600), or env // STOREBOUND_PROFILE__ (NAME upper case, other characters as _). storebound.workspace.json names profiles too. import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; 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; 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'] 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__ 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 { 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 = {}; 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 default profile: the only one on this machine, else the one named "default"; null when that is not clear. */ export function defaultProfile(names = profileNames()): string | null { 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(); 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", "", "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 = {}; 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): string => (marker.test(v) ? v : readFileSync(v.replace(/^~(?=\/)/, homedir()), 'utf8')); export function parseServiceAccount(text: string): ServiceAccount { const j = JSON.parse(text) as Partial; 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*\{/)); } 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(p8.replace(/^~(?=\/)/, homedir()))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`); return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) }; } /** App Review contact from .storebound/credentials.json (personal data, so never in the config). */ export function reviewContact(root: string): ReviewContact | null { const c = fileStore(root).reviewContact as Partial | undefined; if (!c?.email || isPlaceholder(c.email, { example: false })) return null; return { firstName: c.firstName ?? '', lastName: c.lastName ?? '', phone: c.phone ?? '', email: c.email }; } 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 | 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`); if (!/BEGIN PRIVATE KEY/.test(raw)) return { keyPath: raw.replace(/^~(?=\/)/, homedir()), 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: abs.startsWith('/') ? 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; }