- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG - storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S, Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download; the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json - credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI made; precedence env > named profile > project file > default profile; config `account`, "account" in the project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use - app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop, apps folders | add | remove, ws init and ws without a workspace file - desktop: notice when a folder pins another storebound version; storebound-desktop --version - tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust), keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
207 lines
13 KiB
TypeScript
207 lines
13 KiB
TypeScript
// 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
|
|
// <credentials>/<name>/ (0600, folder 0700), and the project file keeps everything that belongs to the app (upload
|
|
// keystore, review contact, demo account) plus "account": "<name>". 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<string, unknown>;
|
|
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 <one of them> 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 <new profile>`);
|
|
if (have && playEmail && have.playEmail && have.playEmail !== playEmail) throw new Error(`profile "${profile}" already has another Play service account: use --name <new profile>`);
|
|
|
|
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-<time>, 0600). Safe to run again. */
|
|
export function applyImport(plan: ImportPlan, now = new Date()): { backup: string | null } {
|
|
const dir = profileDir();
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
try { chmodSync(dir, 0o700); } catch { /* not ours to change (Windows) */ }
|
|
let backup: string | null = null;
|
|
for (const w of plan.writes) {
|
|
if (w.action === 'same') continue;
|
|
if (w.path === plan.projectFile) {
|
|
// the profile must answer before the project forgets its keys
|
|
const id = profileIdentity(plan.profile);
|
|
if ((plan.stores.includes('apple') && !id.appleKeyId) || (plan.stores.includes('play') && !id.playEmail)) throw new Error(`profile ${plan.profile} does not read back: the project file was not changed`);
|
|
backup = `${plan.projectFile}.bak-${now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
|
|
copyFileSync(plan.projectFile, backup);
|
|
chmodSync(backup, 0o600);
|
|
} else {
|
|
const parent = resolve(w.path, '..');
|
|
if (parent !== dir) { mkdirSync(parent, { recursive: true, mode: 0o700 }); try { chmodSync(parent, 0o700); } catch { /* Windows */ } }
|
|
}
|
|
atomic(w.path, w.copyFrom ? readFileSync(w.copyFrom) : w.content!, w.mode);
|
|
}
|
|
return { backup };
|
|
}
|
|
|
|
/** Points a project at a profile ("account" in its .storebound/credentials.json; the file is created when missing). */
|
|
export function useProfile(project: string, name: string): string {
|
|
if (!PROFILE_RE.test(name)) throw new Error(`${name}: letters, digits, . _ - (max 64)`);
|
|
if (!profileNames().includes(name)) throw new Error(`no profile "${name}" in ${profileDir()}: \`storebound accounts\` lists them`);
|
|
const file = join(project, '.storebound', 'credentials.json');
|
|
const cur: Json = existsSync(file) ? readJson(file) : {};
|
|
mkdirSync(join(project, '.storebound'), { recursive: true });
|
|
atomic(file, JSON.stringify({ ...cur, account: name }, null, 2) + '\n', 0o600);
|
|
return file;
|
|
}
|