# Conflicts: # cli/index.ts # cli/lib/config.ts # cli/lib/credentials.ts # docs/desktop.md # templates/credentials.example.json # tools/site-icons.ts
169 lines
11 KiB
TypeScript
169 lines
11 KiB
TypeScript
// Credentials never live in storebound.config.ts or in git. Sources, 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)
|
|
// 2. <project>/.storebound/credentials.json (gitignored), same names:
|
|
// { "PLAY_SERVICE_ACCOUNT": "...", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "...",
|
|
// "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.
|
|
// 3. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
|
// With a credential profile (`--profile <name>` or STOREBOUND_PROFILE), the profile comes before all of these:
|
|
// ~/.storebound/credentials/<name>.json (same four names; relative paths are relative to that file; folder:
|
|
// STOREBOUND_CREDENTIALS_DIR) or env STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _).
|
|
// Many studios / store accounts from one machine: one profile per account; storebound.workspace.json names them.
|
|
import { chmodSync, existsSync, mkdtempSync, 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<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'] 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;
|
|
}
|
|
|
|
/** 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 };
|
|
}
|
|
}
|
|
|
|
function value(root: string, name: string): string | undefined {
|
|
const profile = process.env.STOREBOUND_PROFILE;
|
|
if (profile) { const v = profileEnv(profile)[name]; if (v) return v; }
|
|
const env = process.env[name];
|
|
if (env) return env;
|
|
const v = fileStore(root)[name];
|
|
return typeof v === 'string' && v ? v : 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<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, or .storebound/credentials.json): path to the service account JSON key');
|
|
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, or .storebound/credentials.json)');
|
|
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<ReviewContact> | undefined;
|
|
if (!c?.email) 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<DemoAccount> | undefined;
|
|
if (typeof d?.username === 'string' && d.username && 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;
|
|
}
|