304 lines
20 KiB
TypeScript
304 lines
20 KiB
TypeScript
// Onboarding an app that is already live: everything the stores and the code can prove goes into the config, so the
|
|
// owner is asked only for what nothing proves. Pure parts of `storebound onboard` (cli/onboard.ts reads the stores):
|
|
// - importCatalog: the live one-time products and subscriptions of both stores (ids, type, listings per language,
|
|
// the price in USD: the US price, Apple's base territory as an override when it is not the USA) as the config's
|
|
// products / subscriptions, so `storebound catalog diff` comes out clean. Store prices are never changed.
|
|
// - contactProposal: app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app
|
|
// details and App Store Connect, replacing only template placeholders; the App Review contact for
|
|
// .storebound/credentials.json (personal data: masked in every output).
|
|
// - signInDecision: reviewAccess.signInRequired from Apple's review detail and the code's auth signals, else a
|
|
// question for the owner.
|
|
// - deletionCandidates / findDeletionUrl: an account deletion page on the app's own website, never invented.
|
|
import { type Language, playCodes, type Product, type ProductType, type ShipConfig, type Subscription, type SubscriptionGroup, type SubscriptionPeriod } from './config';
|
|
import type { StoreItem } from './catalog';
|
|
import { isPlaceholder, maskUser, type ReviewContact } from './credentials';
|
|
import type { RemoteCatalog } from './listing-pull';
|
|
|
|
type Store = 'apple' | 'play';
|
|
|
|
export interface ImportNote { id: string; level: 'info' | 'warn'; msg: string }
|
|
export interface CatalogImport {
|
|
products: Product[];
|
|
subscriptions: SubscriptionGroup[];
|
|
/** ids added (not in the config before) / updated (price, type or listings changed) / kept as they were */
|
|
added: string[];
|
|
updated: string[];
|
|
notes: ImportNote[];
|
|
/** items that could not be written, with the reason (only when no store has a usable price or text) */
|
|
skipped: { id: string; why: string }[];
|
|
}
|
|
|
|
const cents = (n: number) => Math.round(n * 100) / 100;
|
|
const langOf = (langs: Language[], store: Store, code: string) => langs.find(l => (store === 'apple' ? l.apple === code : playCodes(l).includes(code)))?.id;
|
|
const PERIODS: SubscriptionPeriod[] = ['P1W', 'P1M', 'P2M', 'P3M', 'P6M', 'P1Y'];
|
|
|
|
/** The USD price a store item shows today: the US region, else the store's own USD price for other regions. */
|
|
export function usdOf(i: StoreItem | undefined): number | undefined {
|
|
if (!i) return undefined;
|
|
const us = i.prices.find(p => p.region === 'US' && p.currency === 'USD');
|
|
return us?.price ?? i.usd;
|
|
}
|
|
|
|
/**
|
|
* The config's products and subscriptions from what both stores sell today. Items in the config keep everything that
|
|
* is not on the store (review screenshots, notes, offers); their USD price, type, platforms and listings follow the
|
|
* store. An item on one store only is kept and marked for that store (`platforms`). Pure.
|
|
*/
|
|
export function importCatalog(config: Pick<ShipConfig, 'languages' | 'products' | 'subscriptions' | 'apple' | 'play'>,
|
|
s: { apple: StoreItem[] | null; play: StoreItem[] | null; appleTexts?: RemoteCatalog; playTexts?: RemoteCatalog }): CatalogImport {
|
|
const out: CatalogImport = { products: [], subscriptions: [], added: [], updated: [], notes: [], skipped: [] };
|
|
const note = (id: string, msg: string, level: ImportNote['level'] = 'info') => out.notes.push({ id, level, msg });
|
|
const both = !!config.apple && !!config.play;
|
|
const byId = (xs: StoreItem[] | null) => new Map((xs ?? []).map(x => [x.id, x] as const));
|
|
const A = byId(s.apple), P = byId(s.play);
|
|
const oldProducts = new Map((config.products ?? []).map(p => [p.id, p] as const));
|
|
const oldSubs = new Map((config.subscriptions ?? []).flatMap(g => g.subscriptions.map(x => [x.id, { group: g, sub: x }] as const)));
|
|
const ids = [...new Set([...A.keys(), ...P.keys()])];
|
|
const groups = new Map<string, SubscriptionGroup>();
|
|
|
|
for (const id of ids) {
|
|
const a = A.get(id), p = P.get(id);
|
|
const isSub = a ? a.kind === 'subscription' : p!.kind === 'subscription';
|
|
if (a && p && (a.kind === 'subscription') !== (p.kind === 'subscription')) { out.skipped.push({ id, why: `a subscription on one store and a one-time product on the other: one id cannot be both (the stores keep it; fix by hand)` }); continue; }
|
|
// price: US first (Apple's, else Play's), else the store's own USD price for new regions
|
|
const aUsd = usdOf(a), pUsd = usdOf(p);
|
|
const usd = aUsd ?? pUsd;
|
|
if (usd === undefined) {
|
|
const base = a?.base ?? p?.prices[0];
|
|
out.skipped.push({ id, why: `no USD price on ${a && p ? 'either store' : a ? 'the App Store' : 'Google Play'}${base ? ` (base ${base.region} ${base.price} ${base.currency})` : ''}: the config prices in USD, so it is not written (catalog diff lists it as on the store only)` });
|
|
continue;
|
|
}
|
|
if (aUsd !== undefined && pUsd !== undefined && cents(aUsd) !== cents(pUsd))
|
|
note(id, `US price ${aUsd.toFixed(2)} on the App Store, ${pUsd.toFixed(2)} on Google Play: usd follows the App Store; catalog diff shows the Play difference (store prices are never changed here)`, 'warn');
|
|
// Apple's base territory (not the USA): kept as an override when every store that sells the item has that price
|
|
const overrides: { region: string; currency: string; price: number }[] = [];
|
|
if (a?.base && a.base.region !== 'US') {
|
|
const same = !p || p.prices.some(x => x.region === a.base!.region && x.currency === a.base!.currency && cents(x.price) === cents(a.base!.price));
|
|
if (same) overrides.push({ region: a.base.region, currency: a.base.currency, price: a.base.price });
|
|
else note(id, `App Store base territory ${a.base.region} (${a.base.price} ${a.base.currency}) differs from Google Play there: not kept as an override`, 'warn');
|
|
}
|
|
// listings: the config's, then Play's texts, then Apple's (Apple's limits are the config's)
|
|
const old = isSub ? oldSubs.get(id)?.sub : oldProducts.get(id);
|
|
const listings: Record<string, { title: string; description: string; benefits?: string[] }> = {};
|
|
for (const [lang, l] of Object.entries(old?.listings ?? {})) listings[lang] = { ...l };
|
|
for (const store of ['play', 'apple'] as const) {
|
|
const texts = (store === 'apple' ? s.appleTexts : s.playTexts)?.[id] ?? {};
|
|
for (const [code, t] of Object.entries(texts)) {
|
|
const lang = langOf(config.languages, store, code);
|
|
if (!lang) { note(id, `${store} language ${code} is not in languages: its texts are left out`); continue; }
|
|
if (!t.title) continue;
|
|
if ([...t.title].length > 30) { note(id, `${store} ${code}: title "${t.title}" is over the config's 30 characters: kept the ${listings[lang] ? 'other store\'s' : 'store'} text out of the config`, 'warn'); continue; }
|
|
listings[lang] = { ...listings[lang], title: t.title, description: t.description || listings[lang]?.description || '', ...(store === 'play' && t.benefits?.length ? { benefits: t.benefits } : {}) };
|
|
}
|
|
}
|
|
for (const [lang, l] of Object.entries(listings)) if (!l.description) { note(id, `${lang}: no description on the store: the title is used as the description`, 'warn'); l.description = l.title; }
|
|
if (!Object.keys(listings).length) { out.skipped.push({ id, why: 'no title in any language of the config on the stores' }); continue; }
|
|
const platforms = both && !(a && p) ? { platforms: [a ? 'ios' as const : 'android' as const] } : {};
|
|
if (both && !(a && p)) note(id, `only on ${a ? 'the App Store' : 'Google Play'}: kept and marked platforms: ['${a ? 'ios' : 'android'}']`);
|
|
if (isSub) {
|
|
const period = (a?.period ?? p?.period) as SubscriptionPeriod | undefined;
|
|
if (!period || !PERIODS.includes(period)) { out.skipped.push({ id, why: `billing period ${period ?? 'unknown'} is not one the config supports (${PERIODS.join(', ')})` }); continue; }
|
|
if (a?.period && p?.period && a.period !== p.period) note(id, `bills every ${a.period} on the App Store, ${p.period} on Google Play: period follows the App Store`, 'warn');
|
|
const prev = oldSubs.get(id);
|
|
const sub: Subscription = { ...(prev?.sub ?? {}), id, period, level: a?.level ?? prev?.sub.level ?? 1, usd: cents(usd), listings, ...platforms,
|
|
...(overrides.length || prev?.sub.overrides ? { overrides: mergeOverrides(prev?.sub.overrides, overrides) } : {}) } as Subscription;
|
|
if (!both || (a && p)) delete sub.platforms;
|
|
const gname = a?.group ?? prev?.group.name ?? 'Subscriptions';
|
|
const g = groups.get(gname) ?? { ...(prev?.group ?? { name: gname, listings: {} }), name: gname, subscriptions: [] };
|
|
if (!Object.keys(g.listings).length) {
|
|
const first = Object.keys(listings)[0]!;
|
|
g.listings = { [first]: { name: gname } };
|
|
note(id, `group "${gname}": display name set to its reference name for ${first} (App Store group localizations are not read; edit if they differ)`);
|
|
}
|
|
g.subscriptions.push(sub);
|
|
groups.set(gname, g);
|
|
track(out, id, prev?.sub, sub);
|
|
} else {
|
|
const prev = oldProducts.get(id);
|
|
let type: ProductType;
|
|
if (a && a.kind !== 'one_time' && a.kind !== 'subscription') type = a.kind;
|
|
else if (prev) type = prev.type;
|
|
else { type = 'non_consumable'; note(id, 'Google Play does not say whether a one-time product is consumable: type non_consumable; change it to consumable if people buy it again and again', 'warn'); }
|
|
const prod: Product = { ...(prev ?? {}), id, type, usd: cents(usd), listings: listings as Product['listings'], ...platforms,
|
|
...(overrides.length || prev?.overrides ? { overrides: mergeOverrides(prev?.overrides, overrides) } : {}) } as Product;
|
|
if (!both || (a && p)) delete prod.platforms;
|
|
out.products.push(prod);
|
|
track(out, id, prev, prod);
|
|
}
|
|
}
|
|
// config items no store has (not created yet): kept as they are
|
|
for (const p of config.products ?? []) if (!A.has(p.id) && !P.has(p.id)) { out.products.push(p); note(p.id, 'in the config, on no store yet: kept'); }
|
|
for (const g of config.subscriptions ?? []) for (const x of g.subscriptions) if (!A.has(x.id) && !P.has(x.id)) {
|
|
const grp = groups.get(g.name) ?? { ...g, subscriptions: [] };
|
|
grp.subscriptions.push(x);
|
|
groups.set(g.name, grp);
|
|
note(x.id, 'in the config, on no store yet: kept');
|
|
}
|
|
out.subscriptions = [...groups.values()];
|
|
return out;
|
|
}
|
|
|
|
function mergeOverrides(prev: { region: string; currency: string; price: number; territory?: string }[] | undefined, add: { region: string; currency: string; price: number }[]) {
|
|
const m = new Map((prev ?? []).map(o => [o.region, o] as const));
|
|
for (const o of add) m.set(o.region, { ...m.get(o.region), ...o });
|
|
return [...m.values()];
|
|
}
|
|
|
|
function track(out: CatalogImport, id: string, prev: unknown, next: unknown): void {
|
|
if (!prev) out.added.push(id);
|
|
else if (JSON.stringify(prev) !== JSON.stringify(next)) out.updated.push(id);
|
|
}
|
|
|
|
// ---- contact, URLs and the App Review contact ----
|
|
|
|
export interface StoreContact {
|
|
play?: { contactEmail?: string; contactWebsite?: string; contactPhone?: string } | null;
|
|
apple?: { supportUrl?: string; marketingUrl?: string; privacyPolicyUrl?: string; review?: AppleReviewDetail | null } | null;
|
|
}
|
|
/** appStoreReviewDetails attributes (App Store Connect), as read. */
|
|
export interface AppleReviewDetail {
|
|
contactFirstName?: string | null; contactLastName?: string | null; contactPhone?: string | null; contactEmail?: string | null;
|
|
demoAccountName?: string | null; demoAccountPassword?: string | null; demoAccountRequired?: boolean | null; notes?: string | null;
|
|
}
|
|
|
|
export interface FieldProposal { field: string; from: string | null; to: string; source: string; replace: boolean }
|
|
|
|
/** A value that is a template placeholder (example.com / example.org, "your-…"), or missing. */
|
|
export const isTemplateValue = (v: string | undefined | null): boolean =>
|
|
// the templates (`storebound init`) write example.com: https://example.com/..., support@example.com
|
|
!v || isPlaceholder(v, { example: false }) || /^(https?:\/\/)?(www\.)?example\.com(\/|$)/i.test(v.trim()) || /@example\.com$/i.test(v.trim());
|
|
|
|
const httpsUrl = (u: string | undefined | null): string | null => {
|
|
const s = (u ?? '').trim();
|
|
if (!s) return null;
|
|
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`;
|
|
return /^https:\/\/[^\s/]+\.[^\s/]+/i.test(withScheme) ? withScheme : null;
|
|
};
|
|
|
|
/**
|
|
* app.* values from the stores. `replace` is true only where the config holds a template value (example.com,
|
|
* support@example.com) or nothing; a real value that differs from the store is reported, never overwritten. Pure.
|
|
*/
|
|
export function contactProposal(app: Partial<ShipConfig['app']>, s: StoreContact): FieldProposal[] {
|
|
const out: FieldProposal[] = [];
|
|
const want = (field: keyof ShipConfig['app'], value: string | null | undefined, source: string) => {
|
|
if (!value) return;
|
|
if (out.some(o => o.field === field)) return;
|
|
const cur = app[field] as string | undefined;
|
|
if (cur === value) return;
|
|
out.push({ field, from: cur ?? null, to: value, source, replace: isTemplateValue(cur) });
|
|
};
|
|
const email = s.play?.contactEmail?.trim();
|
|
if (email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) want('contactEmail', email, 'Google Play app details (contactEmail)');
|
|
want('website', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)');
|
|
want('website', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)');
|
|
want('supportUrl', httpsUrl(s.apple?.supportUrl), 'App Store version (supportUrl)');
|
|
want('supportUrl', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)');
|
|
want('privacyPolicyUrl', httpsUrl(s.apple?.privacyPolicyUrl), 'App Store app information (privacyPolicyUrl)');
|
|
want('marketingUrl', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)');
|
|
return out;
|
|
}
|
|
|
|
/** The App Review contact from Apple's review detail, for .storebound/credentials.json (personal data). */
|
|
export function reviewContactOf(r: AppleReviewDetail | null | undefined): ReviewContact | null {
|
|
if (!r?.contactEmail) return null;
|
|
return { firstName: r.contactFirstName ?? '', lastName: r.contactLastName ?? '', phone: r.contactPhone ?? '', email: r.contactEmail };
|
|
}
|
|
|
|
/** For logs: the name's initials, the masked e-mail, the phone as its last 2 digits only. Never the full values. */
|
|
export function maskContact(c: ReviewContact): string {
|
|
const digits = c.phone.replace(/\D/g, '');
|
|
return `${c.firstName ? c.firstName[0] + '.' : ''}${c.lastName ? ` ${c.lastName[0]}.` : ''} ${maskUser(c.email)}${digits ? `, phone ***${digits.slice(-2)}` : ''}`.trim();
|
|
}
|
|
|
|
// ---- sign-in for review ----
|
|
|
|
export interface SignInDecision {
|
|
signInRequired: boolean | null;
|
|
/** why, in one line */
|
|
because: string;
|
|
/** Apple's review detail holds a demo account (name + password): import it, never print it */
|
|
demoAccount: { username: string; password: string } | null;
|
|
/** the owner question, only when nothing proves it */
|
|
question: string | null;
|
|
}
|
|
|
|
/**
|
|
* reviewAccess.signInRequired from what is proven: Apple's live review detail (demoAccountRequired, set by whoever
|
|
* submitted the app and accepted by App Review) wins; else no auth code in the project means no sign-in; else
|
|
* it is an owner question. Never defaults to yes. Pure.
|
|
*/
|
|
export function signInDecision(o: { auth: string[]; apple: AppleReviewDetail | null | undefined; configured?: boolean }): SignInDecision {
|
|
const r = o.apple;
|
|
const demo = r?.demoAccountName && r.demoAccountPassword ? { username: r.demoAccountName, password: r.demoAccountPassword } : null;
|
|
if (r && r.demoAccountRequired === true)
|
|
return { signInRequired: true, because: `App Store review detail: demo account required${demo ? ' (a demo account is set there)' : ' (but none is set there)'}`, demoAccount: demo, question: null };
|
|
if (r && r.demoAccountRequired === false)
|
|
return { signInRequired: false, because: `App Store review detail: no demo account required${o.auth.length ? ` (the code has sign-in, ${o.auth[0]}, so it is optional for reviewers)` : ''}`, demoAccount: demo, question: null };
|
|
if (!o.auth.length) return { signInRequired: false, because: 'no sign-in or account code in the project (scan auth signals)', demoAccount: demo, question: null };
|
|
return { signInRequired: null, because: `the code has sign-in (${o.auth.slice(0, 2).join('; ')}) and no store says whether reviewers need it`, demoAccount: demo,
|
|
question: 'Can a reviewer use every main feature without signing in? (no: reviewAccess.signInRequired true and a demo account in .storebound/credentials.json reviewDemoAccount)' };
|
|
}
|
|
|
|
// ---- account deletion page ----
|
|
|
|
/** Pages to try on the app's own site, in order (Play's account deletion requirement: a web page to request it). */
|
|
export function deletionCandidates(urls: (string | null | undefined)[]): string[] {
|
|
const out: string[] = [];
|
|
for (const u of urls) {
|
|
let base: URL;
|
|
try { base = new URL(u ?? ''); } catch { continue; }
|
|
if (base.protocol !== 'https:' || isTemplateValue(base.href)) continue;
|
|
for (const p of ['/delete-account', '/account/delete', '/delete', '/account-deletion', '/delete-account.html'])
|
|
{ const c = new URL(p, base.origin).href; if (!out.includes(c)) out.push(c); }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const DELETE_WORDS = /delet|deletion|remove (my|your) (account|data)|hesab[ıi]n[ıi]? sil|konto l[öo]schen|supprim/i;
|
|
|
|
/** Links on a page (privacy policy) whose text or address speaks of deleting an account or data. Pure. */
|
|
export function deletionLinks(html: string, pageUrl: string): string[] {
|
|
const out: string[] = [];
|
|
for (const m of html.matchAll(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)) {
|
|
const href = m[1]!, text = m[2]!.replace(/<[^>]+>/g, ' ');
|
|
if (!DELETE_WORDS.test(text) && !DELETE_WORDS.test(href)) continue;
|
|
let abs: string;
|
|
try { abs = new URL(href, pageUrl).href; } catch { continue; }
|
|
if (!/^https:/.test(abs) || out.includes(abs)) continue;
|
|
out.push(abs);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface DeletionResult { url: string | null; how: string; tried: string[] }
|
|
/** GET of a page: status and text (a timeout or network error is status null). */
|
|
export type PageGet = (url: string) => Promise<{ status: number | null; text: string; finalUrl?: string }>;
|
|
|
|
/**
|
|
* Looks for an account deletion page: the usual paths on the website and the privacy policy's host, then links in the
|
|
* privacy policy page that mention delete or deletion, then an anchor on the privacy page itself (#delete...). A
|
|
* page counts only when it answers 200 and speaks of deleting. Never invents a URL.
|
|
*/
|
|
export async function findDeletionUrl(o: { website?: string | null; privacyUrl?: string | null }, get: PageGet): Promise<DeletionResult> {
|
|
const tried: string[] = [];
|
|
const ok = async (u: string) => {
|
|
tried.push(u);
|
|
const r = await get(u).catch(() => ({ status: null, text: '' }));
|
|
return r.status === 200 && DELETE_WORDS.test(r.text.replace(/<script[\s\S]*?<\/script>/gi, '')) ? r : null;
|
|
};
|
|
for (const c of deletionCandidates([o.website, o.privacyUrl])) if (await ok(c)) return { url: c, how: `the page ${new URL(c).pathname} on the site answers and speaks of deletion`, tried };
|
|
if (o.privacyUrl && !isTemplateValue(o.privacyUrl)) {
|
|
tried.push(o.privacyUrl);
|
|
const page = await get(o.privacyUrl).catch(() => ({ status: null, text: '' }));
|
|
if (page.status === 200) {
|
|
for (const l of deletionLinks(page.text, o.privacyUrl)) {
|
|
if (l.split('#')[0] === o.privacyUrl.split('#')[0]) return { url: l, how: 'a section of the privacy policy linked as account / data deletion', tried };
|
|
if (await ok(l)) return { url: l, how: 'linked from the privacy policy as account / data deletion', tried };
|
|
}
|
|
const anchor = /\bid\s*=\s*["']([^"']*delet[^"']*)["']/i.exec(page.text)?.[1];
|
|
if (anchor) return { url: `${o.privacyUrl.split('#')[0]}#${anchor}`, how: 'a deletion section of the privacy policy page', tried };
|
|
}
|
|
}
|
|
return { url: null, how: 'not found: a real gap, Play requires a web page to request account deletion (account-deletion skill)', tried };
|
|
}
|