storebound/cli/lib/portfolio.ts

226 lines
15 KiB
TypeScript

// Pure pieces of `portfolio.status` (serve.ts): an app's identity across platforms, its release trains (one version
// across the App Store and Google Play) and the in-app catalog parity between the config and both stores.
// Inputs are the `apple status --json` / `play status --json` results, read defensively: "unknown, never false".
import type { AppIdentity, CatalogParity, ProjectSummary, ReleasePhase, ReleaseTrain, StoreRelease } from './desktop-protocol';
type J = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
export function appIdentity(s: ProjectSummary): AppIdentity {
return {
id: s.dir,
name: s.name,
platforms: {
ios: s.apple && s.platforms.includes('ios') ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null,
android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName, developerId: s.play.developerId ?? null, appId: s.play.appId ?? null } : null,
},
};
}
/** Numeric compare of dotted versions ("2.10.0" > "2.9.1"); non-numeric parts compare as 0. */
export function cmpVersion(a: string, b: string): number {
const pa = a.split('.').map(x => parseInt(x, 10) || 0), pb = b.split('.').map(x => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d; }
return 0;
}
/** Matching key of a version: numeric parts without trailing zero parts, so 1 == 1.0 == 1.0.0 ("1"), 2.10 != 2.1.
* A version with non-numeric parts keeps its own spelling. The stores' strings are shown as they are; this only joins. */
export function versionKey(v: string): string {
const parts = v.trim().split('.');
if (!parts.every(p => /^\d+$/.test(p))) return v.trim();
const n = parts.map(p => String(parseInt(p, 10)));
while (n.length > 1 && n.at(-1) === '0') n.pop();
return n.join('.');
}
/** The same release on both stores (App Store "1.0" and Play "1.0.0"). */
export const sameVersion = (a: string | null | undefined, b: string | null | undefined): boolean => !!a && !!b && versionKey(a) === versionKey(b);
/** The spelling a joined row shows: the longest of the stores' strings (1.0.0 over 1.0), the first one on a tie. */
export function displayVersion(vs: (string | null | undefined)[]): string {
const xs = vs.filter((x): x is string => !!x);
return xs.reduce((a, b) => (b.split('.').length > a.split('.').length ? b : a), xs[0] ?? '');
}
/** App Store version states (appStoreState / appVersionState) -> phase. */
export function applePhase(state: string): ReleasePhase {
if (/^(READY_FOR_SALE|READY_FOR_DISTRIBUTION)$/.test(state)) return 'live';
if (/^(PENDING_DEVELOPER_RELEASE|PENDING_APPLE_RELEASE|ACCEPTED)$/.test(state)) return 'approved';
if (/^(WAITING_FOR_REVIEW|IN_REVIEW|WAITING_FOR_EXPORT_COMPLIANCE|PROCESSING_FOR_DISTRIBUTION|PROCESSING_FOR_APP_STORE)$/.test(state)) return 'review';
if (/REJECTED$/.test(state)) return 'rejected';
if (/^(PREPARE_FOR_SUBMISSION|DEVELOPER_ACTION_NEEDED)$/.test(state)) return 'draft';
return 'other';
}
/** Apple's phased release schedule: share of users with automatic updates on day 1-7. */
export const PHASED_SCHEDULE = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1] as const;
/** Phased release of a version (`apple status --json` phased[versionId]) -> rollout share, or null. */
export function phasedFraction(p: { state?: string; currentDayNumber?: number | null } | null | undefined): number | null {
if (!p?.state) return null;
if (p.state === 'COMPLETE') return 1;
const d = typeof p.currentDayNumber === 'number' ? Math.min(7, Math.max(1, Math.round(p.currentDayNumber))) : null;
return d ? PHASED_SCHEDULE[d - 1]! : p.state === 'INACTIVE' ? 0 : null;
}
const OTHER_PLATFORM: Record<string, string> = { MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
/** Versions of the app's other App Store platforms (macOS, tvOS, visionOS) that are not live or replaced: the train
* is iOS, so these are named as warnings ("macOS 0.8.7: REJECTED") instead of being invisible. Pure. */
export function applePlatformWarnings(data: unknown): string[] {
const versions = ((data as J)?.versions ?? []) as J[];
return versions.filter(v => typeof v?.attributes?.versionString === 'string' && OTHER_PLATFORM[v.attributes.platform ?? 'IOS'])
.map(v => ({ v, state: String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? 'UNKNOWN') }))
.filter(({ state }) => !/READY_FOR_SALE|READY_FOR_DISTRIBUTION|REPLACED_WITH_NEW_VERSION|REMOVED_FROM_SALE/.test(state))
.map(({ v, state }) => `App Store ${OTHER_PLATFORM[v.attributes.platform]} ${v.attributes.versionString}: ${state} (storebound apple status --platform ${OTHER_PLATFORM[v.attributes.platform]!.toLowerCase()})`);
}
export function appleReleases(data: unknown): StoreRelease[] {
const versions = ((data as J)?.versions ?? []) as J[];
const recent = (((data as J)?.builds ?? []) as J[]);
const builds = new Map([...recent, ...(((data as J)?.attachedBuilds ?? []) as J[])].map(b => [b.id, b.attributes?.version] as const));
const phasedById = (((data as J)?.phased ?? {}) as Record<string, J | null>);
const rows = versions.filter(v => typeof v?.attributes?.versionString === 'string' && (v.attributes.platform ?? 'IOS') === 'IOS').map(v => {
const state = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? 'UNKNOWN');
const buildId = v.relationships?.build?.data?.id;
let phase = applePhase(state);
const pr = (typeof v.id === 'string' ? phasedById[v.id] : null) ?? null;
const prState = typeof pr?.attributes?.phasedReleaseState === 'string' ? String(pr.attributes.phasedReleaseState) : null;
const day = typeof pr?.attributes?.currentDayNumber === 'number' ? pr.attributes.currentDayNumber as number : null;
const fraction = phase === 'live' && prState ? phasedFraction({ state: prState, currentDayNumber: day }) : null;
// live but still rolling out (phased release on, not complete): the same as a Play staged rollout
if (phase === 'live' && prState === 'ACTIVE') phase = 'rollout';
if (phase === 'live' && prState === 'PAUSED') phase = 'halted';
const r: StoreRelease = { store: 'ios', platform: 'IOS', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase, fraction, track: null };
if (prState) r.phased = { state: prState, day };
if (typeof v.attributes.createdDate === 'string') r.date = v.attributes.createdDate;
return r;
});
return [...rows, ...testflightReleases(recent, ((data as J)?.buildVersions ?? {}) as Record<string, string>, new Set(rows.map(r => r.version)))];
}
/** Marketing versions that have a processed build but no App Store version yet: on the train at "testing" (TestFlight),
* with the highest build number. Seen live: builds of 0.6.0 in TestFlight while 0.5.0 waited for review. */
export function testflightReleases(builds: J[], buildVersions: Record<string, string>, versions: Set<string | null>): StoreRelease[] {
const best = new Map<string, string>();
for (const b of builds) {
const v = buildVersions[b?.id];
const n = b?.attributes?.version;
if (!v || versions.has(v) || typeof n !== 'string' || b.attributes.processingState !== 'VALID' || b.attributes.expired) continue;
const cur = best.get(v);
if (!cur || cmpVersion(n, cur) > 0) best.set(v, n);
}
return [...best].map(([version, build]) => ({ store: 'ios' as const, version, build, state: 'TESTFLIGHT', phase: 'testing' as const, fraction: null, track: 'TestFlight' }));
}
/** Leading dotted version of a Play release name ("2.3.0 (45)" -> "2.3.0"). */
export const versionOfName = (name: unknown): string | null => (typeof name === 'string' ? name.match(/\d+(?:\.\d+){0,3}/)?.[0] ?? null : null);
/**
* Play tracks (`play status --json`) -> releases. The Developer API has no review state, so a completed production
* release is decided by the public store page (`publicPage` in the status JSON): the page's version is this versionName
* (or newer, or "Varies with device") = live; 404 = in review (with managed publishing: in review or ready to publish);
* an older version on the page = in review, with the live one named. Only a network failure without any earlier page
* read leaves it undecided ("released", publicPage unknown: "Couldn't check the store page"); with an earlier read the
* phase comes from that read and `pageStaleAt` says when.
*/
export function playReleases(data: unknown): StoreRelease[] {
const out: StoreRelease[] = [];
const page = (data as J)?.publicPage as { state?: string; versionName?: string | null; varies?: boolean; stale?: boolean; checkedAt?: string } | undefined;
const managed = (data as J)?.managedPublishing === true;
const pageState = page?.state === 'public' || page?.state === 'notPublic' ? page.state : 'unknown';
const raw = (data as J)?.tracks;
const tracks: J[] = Array.isArray(raw) ? raw : [];
const codesOf = (r: J): number[] => (Array.isArray(r?.versionCodes) ? (r.versionCodes as unknown[]) : []).map(Number).filter(Number.isFinite);
const rels = (t: J): J[] => (Array.isArray(t?.releases) ? t.releases : []);
for (const t of tracks) {
for (const r of rels(t)) {
const codes = codesOf(r);
const production = t.track === 'production';
const status = String(r.status ?? 'unknown');
const version = versionOfName(r.name);
const rel: StoreRelease = { store: 'android', version, build: codes.length ? String(Math.max(...codes)) : null, state: status, phase: 'other', fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) };
rel.phase = !production ? (status === 'draft' ? 'draft' : 'testing')
: status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other';
if (production && status === 'completed') {
const pv = page?.versionName ?? null;
if (pageState === 'notPublic') { rel.phase = 'review'; if (managed) rel.managed = true; }
else if (pageState === 'public') {
// its version (or newer: this is an earlier one), or "Varies with device" (nothing to compare) = live; no
// version found on a public page means the page changed shape: not checked, never guessed
if (page?.varies || (pv && version && cmpVersion(pv, version) >= 0)) rel.phase = 'live';
else if (!pv || !version) rel.phase = 'released';
else { rel.phase = 'review'; rel.liveVersion = pv; if (managed) rel.managed = true; }
} else rel.phase = 'released';
rel.publicPage = pageState;
if (page?.stale && pageState !== 'unknown') rel.pageStaleAt = page.checkedAt ?? null;
}
out.push(rel);
}
}
return out;
}
/** Plain words for a Play release's phase, decided from the store page (CLI text; the desktop has the same words in
* i18n, apps/desktop/src/ui/kit.tsx phaseLabel). */
export function playReleaseLabel(r: StoreRelease): string {
const base = ((): string => {
if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `In review (live: ${r.liveVersion})` : 'In review';
if (r.phase === 'released') return "Couldn't check the store page";
if (r.phase === 'live') return 'Live';
if (r.phase === 'rollout') return `Rolling out ${Math.round((r.fraction ?? 0) * 1000) / 10}%`;
if (r.phase === 'halted') return 'Halted';
if (r.phase === 'draft') return 'Draft';
return r.state;
})();
return r.pageStaleAt ? `${base} (couldn't check the store page now; as of ${r.pageStaleAt.slice(0, 16).replace('T', ' ')} UTC)` : base;
}
export const PHASE_RANK: Record<ReleasePhase, number> = { live: 0, rollout: 1, halted: 2, released: 3, approved: 4, review: 5, sent: 6, rejected: 7, testing: 8, draft: 9, other: 10 };
/** Phases where the version reaches (or may reach) users. */
export const REACHING: ReadonlySet<ReleasePhase> = new Set<ReleasePhase>(['live', 'rollout', 'halted', 'released']);
const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase])[0] ?? null;
/**
* Release trains: one version across both stores. current = the highest version that reaches users on a store (live,
* or rolling out: a staged rollout or an iOS phased release, also when halted / paused); next = the highest version
* above it on either store (review, testing, draft ...). A store without that version has null in the train.
*/
export function releaseTrains(ios: StoreRelease[], android: StoreRelease[]): { current: ReleaseTrain | null; next: ReleaseTrain | null } {
const all = [...ios, ...android].filter((r): r is StoreRelease & { version: string } => !!r.version);
const train = (v: string): ReleaseTrain => {
const i = pick(all.filter(r => r.store === 'ios' && sameVersion(r.version, v))), a = pick(all.filter(r => r.store === 'android' && sameVersion(r.version, v)));
return { version: displayVersion([a?.version, i?.version, v]), ios: i, android: a };
};
const live = all.filter(r => REACHING.has(r.phase)).map(r => r.version).sort(cmpVersion).at(-1) ?? null;
const above = all.filter(r => !live || cmpVersion(r.version, live) > 0).map(r => r.version).sort(cmpVersion).at(-1) ?? null;
return { current: live ? train(live) : null, next: above ? train(above) : null };
}
/** Product ids on App Store Connect (`apple status --json` iaps) and Google Play (`play status --json` products). */
export const appleProductIds = (data: unknown): string[] => (((data as J)?.iaps ?? []) as J[]).map(p => p?.attributes?.productId).filter((x): x is string => typeof x === 'string');
export const playProductIds = (data: unknown): string[] => (((data as J)?.products ?? []) as J[]).map(p => p?.productId ?? p?.sku).filter((x): x is string => typeof x === 'string');
/**
* Catalog parity. Without store data (offline) only the config side is known. With it: ids only on one store,
* and config ids missing from a store the app is on.
*/
export function catalogParity(s: ProjectSummary, apple?: string[], play?: string[]): CatalogParity {
const live = apple !== undefined || play !== undefined;
const onlyIos = apple && play ? apple.filter(x => !play.includes(x)) : [];
const onlyAndroid = apple && play ? play.filter(x => !apple.includes(x)) : [];
// the status reads list one-time products; subscriptions are compared by `catalog diff` (catalog.diff method)
const sold = (ids: string[]) => ids.filter(x => s.productIds.includes(x));
const missingIos = apple ? sold(s.catalog.ios).filter(x => !apple.includes(x)) : [];
const missingAndroid = play ? sold(s.catalog.android).filter(x => !play.includes(x)) : [];
return {
checked: live ? 'live' : 'config', products: s.products, subscriptions: s.subscriptions,
onlyIos, onlyAndroid, missingIos, missingAndroid,
ok: !onlyIos.length && !onlyAndroid.length && !missingIos.length && !missingAndroid.length,
};
}
/** The warnings of `apple status --json` about first in-app purchases left out of the review (pitfall 70). Pure. */
export const appleFirstProductWarnings = (data: unknown): string[] => (((data as J)?.firstProductGaps ?? []) as J[])
.filter(g => typeof g?.msg === 'string').map(g => `App Store: ${g.msg}; ${g.fix ?? ''}`.replace(/; $/, ''));