// The machine's store accounts: credential profiles in ~/.storebound/credentials (cli/lib/credentials.ts), shared by // every project, the CLI and the desktop app. This module reads what a profile can reach (without values) and builds // a profile from a project's .storebound/credentials.json (`storebound accounts import`): the store-account part // (Apple team key, Play service account, vendor number) moves to the profile, the key files are copied into // // (0600, folder 0700), and the project file keeps everything that belongs to the app (upload // keystore, review contact, demo account) plus "account": "". Same key id or service account e-mail as an // existing profile = that profile (run it on every project; one account, one profile). Values are never printed. import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; import { maskUser, PROFILE_KEYS, PROFILE_RE, profileDir, profileEnv, profileNames } from './credentials'; export type Kind = 'apple' | 'play'; /** The store keys that move to a profile (one developer account's), never the app's own ones. */ export const STORE_KEYS = PROFILE_KEYS; export interface Identity { appleKeyId: string | null; playEmail: string | null; vendor: boolean } const PEM = /BEGIN PRIVATE KEY/; const isJsonText = (v: string) => /^\s*\{/.test(v); const expand = (p: string, base: string) => { const x = p.replace(/^~(?=\/|\\)/, homedir()); return isAbsolute(x) ? x : resolve(base, x); }; /** The client_email of a service account (inline JSON or a file); null when unreadable. */ export function serviceAccountEmail(v: string, base: string): string | null { try { const text = isJsonText(v) ? v : readFileSync(expand(v, base), 'utf8'); const e = (JSON.parse(text) as { client_email?: unknown }).client_email; return typeof e === 'string' && e ? e : null; } catch { return null; } } /** What a profile can reach: the Apple key id and the Play service account e-mail (for matching, never printed). */ export function profileIdentity(name: string): Identity { const env = profileEnv(name); return { appleKeyId: env.ASC_KEY_ID ?? null, playEmail: env.PLAY_SERVICE_ACCOUNT ? serviceAccountEmail(env.PLAY_SERVICE_ACCOUNT, profileDir()) : null, vendor: !!env.ASC_VENDOR_NUMBER }; } /** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */ export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; error?: string }[] { return profileNames().map(profile => { try { const id = profileIdentity(profile); return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor }; } catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; } }); } export interface FileWrite { path: string; what: string; action: 'create' | 'update' | 'same'; mode: number; content?: string; copyFrom?: string } export interface ImportPlan { project: string; projectFile: string; profile: string; /** new profile, or the existing one with the same Apple key id / Play service account */ reuse: boolean; stores: Kind[]; writes: FileWrite[]; /** keys removed from the project file (moved to the profile) */ moved: string[]; notes: string[]; } type Json = Record; const readJson = (p: string): Json => JSON.parse(readFileSync(p, 'utf8')) as Json; const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '').slice(0, 64); /** * The plan of `accounts import` for one project (reads only). Throws with the reason when there is nothing to move, * when the project's Apple key and Play account belong to two different profiles, or when --name names a profile that * already holds another account. */ export function planImport(project: string, o: { name?: string } = {}): ImportPlan { const projectFile = join(project, '.storebound', 'credentials.json'); if (!existsSync(projectFile)) throw new Error(`${projectFile}: not found (nothing to import)`); const file = readJson(projectFile); const str = (k: string) => (typeof file[k] === 'string' && (file[k] as string).trim() ? (file[k] as string).trim() : undefined); const notes: string[] = []; const keyId = str('ASC_KEY_ID'), issuer = str('ASC_ISSUER_ID'); let p8 = str('ASC_P8'); if (keyId && !p8) { const xcode = join(homedir(), '.appstoreconnect', 'private_keys', `AuthKey_${keyId}.p8`); if (existsSync(xcode)) { p8 = xcode; notes.push('ASC_P8 from ~/.appstoreconnect/private_keys (copied into the profile)'); } } const sa = str('PLAY_SERVICE_ACCOUNT'); const playEmail = sa ? serviceAccountEmail(sa, project) : null; if (sa && !playEmail) throw new Error('PLAY_SERVICE_ACCOUNT in the project file is not a readable service account key (path relative to the project, or the JSON)'); if (p8 && !PEM.test(p8) && !existsSync(expand(p8, project))) throw new Error('ASC_P8 in the project file points to a file that does not exist'); const apple = !!(keyId && issuer); if ((keyId || issuer) && !apple) throw new Error('the project file has only half an App Store Connect key (ASC_KEY_ID and ASC_ISSUER_ID go together)'); if (!apple && !sa && typeof file.account === 'string' && profileNames().includes(file.account)) { return { project, projectFile, profile: file.account, reuse: true, stores: [], writes: [], moved: [], notes: [`already imported: the project uses profile "${file.account}"`] }; } if (!apple && !sa) throw new Error('the project file holds no store account (ASC_KEY_ID + ASC_ISSUER_ID, PLAY_SERVICE_ACCOUNT): nothing to import'); if (o.name !== undefined && !PROFILE_RE.test(o.name)) throw new Error(`--name ${o.name}: letters, digits, . _ - (max 64)`); // the profile: the existing one with this account, else --name, else "default" (first profile) or a free name const ids = new Map(profileNames().map(n => [n, (() => { try { return profileIdentity(n); } catch { return null; } })()] as const)); const hits = [...ids].filter(([, id]) => id && ((apple && id.appleKeyId === keyId) || (playEmail && id.playEmail === playEmail))).map(([n]) => n); if (hits.length > 1) throw new Error(`the Apple key and the Play account of this project are in two profiles (${hits.join(', ')}): import with --name after merging them, or keep them apart`); let profile: string; let reuse = false; if (hits.length === 1) { profile = hits[0]!; reuse = true; if (o.name && o.name !== profile) notes.push(`this account is already in profile "${profile}": --name ${o.name} ignored`); } else if (o.name) { profile = o.name; reuse = ids.has(profile); } else if (!ids.size) profile = 'default'; else { const base = slug(project.split(/[\\/]/).filter(Boolean).pop() ?? 'account') || 'account'; profile = base; for (let i = 2; ids.has(profile); i++) profile = `${base}-${i}`; notes.push(`a second store account on this machine: profile "${profile}" (pick another with --name)`); } const current: Json = ids.has(profile) ? readJson(join(profileDir(), `${profile}.json`)) : {}; const have = ids.get(profile) ?? null; if (have && apple && have.appleKeyId && have.appleKeyId !== keyId) throw new Error(`profile "${profile}" already has another App Store Connect key: use --name `); if (have && playEmail && have.playEmail && have.playEmail !== playEmail) throw new Error(`profile "${profile}" already has another Play service account: use --name `); const dir = profileDir(); const writes: FileWrite[] = []; const next: Json = { ...current }; const keyFile = (rel: string, what: string, v: string, base: string) => { const path = join(dir, rel); const content = PEM.test(v) || isJsonText(v) ? (PEM.test(v) ? v.trim() + '\n' : v) : undefined; const src = content === undefined ? expand(v, base) : undefined; const bytes = content ?? readFileSync(src!, 'utf8'); const action = !existsSync(path) ? 'create' : readFileSync(path, 'utf8') === bytes ? 'same' : 'update'; writes.push({ path, what, action, mode: 0o600, ...(content !== undefined ? { content } : { copyFrom: src }) }); return rel.split('\\').join('/'); }; const moved: string[] = []; const stores: Kind[] = []; if (apple) { stores.push('apple'); // a profile that already has this key keeps its own key file if (!(have?.appleKeyId === keyId && typeof current.ASC_P8 === 'string')) { if (p8) next.ASC_P8 = keyFile(join(profile, 'app-store-connect.p8'), 'App Store Connect key (.p8)', p8, project); } next.ASC_KEY_ID = keyId; next.ASC_ISSUER_ID = issuer; moved.push('ASC_KEY_ID', 'ASC_ISSUER_ID', ...(str('ASC_P8') ? ['ASC_P8'] : [])); } if (sa) { stores.push('play'); if (!(have?.playEmail === playEmail && typeof current.PLAY_SERVICE_ACCOUNT === 'string')) next.PLAY_SERVICE_ACCOUNT = keyFile(join(profile, 'play-service-account.json'), 'Play service account key (JSON)', sa, project); moved.push('PLAY_SERVICE_ACCOUNT'); } const vendor = str('ASC_VENDOR_NUMBER'); if (vendor) { if (typeof current.ASC_VENDOR_NUMBER === 'string' && current.ASC_VENDOR_NUMBER !== vendor) notes.push('ASC_VENDOR_NUMBER: the profile keeps its own; the project value stays in the project'); else { next.ASC_VENDOR_NUMBER = vendor; moved.push('ASC_VENDOR_NUMBER'); } } const profileText = JSON.stringify(next, null, 2) + '\n'; const profilePath = join(dir, `${profile}.json`); writes.push({ path: profilePath, what: `profile ${profile}`, action: !existsSync(profilePath) ? 'create' : readFileSync(profilePath, 'utf8') === profileText ? 'same' : 'update', mode: 0o600, content: profileText }); // the project file: store keys out, "account" in; everything else (keystore, review contact, demo account) stays const proj: Json = {}; for (const [k, v] of Object.entries(file)) if (!moved.includes(k)) proj[k] = v; proj.account = profile; const projText = JSON.stringify(proj, null, 2) + '\n'; writes.push({ path: projectFile, what: 'project credentials (store keys removed, "account" set; a backup is kept)', action: readFileSync(projectFile, 'utf8') === projText ? 'same' : 'update', mode: 0o600, content: projText }); return { project, projectFile, profile, reuse, stores, writes, moved, notes }; } const atomic = (path: string, data: string | Buffer, mode: number) => { const tmp = `${path}.tmp-${process.pid}`; writeFileSync(tmp, data, { mode }); chmodSync(tmp, mode); renameSync(tmp, path); }; /** Runs a plan: key files and the profile first (0600, folders 0700), then reads the profile back, then the project * file (its old version kept as credentials.json.bak-