storebound/cli/onboard.ts

187 lines
14 KiB
TypeScript

// Onboarding an app that is already live on a store (after `storebound init` and `listing pull`): reads what the
// stores and the code prove and puts it into the project, so the owner is asked only what nothing proves.
// storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]
// Read-only on both stores (GETs; a Play edit that is opened, read and deleted, never committed). Without --write it
// prints the plan; --write (never with --dry) changes only local files:
// 1. products / subscriptions in storebound.config.ts from the live catalog of both stores: ids, type, listings per
// language, usd = the US price (Apple: its base territory as an override when it is not the USA), an item on one
// store only kept with platforms. `storebound catalog diff` comes out clean. Store prices are never changed.
// 2. app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app details and App Store
// Connect, where the config still holds a template value (example.com); the App Review contact (Apple's review
// detail) into .storebound/credentials.json reviewContact when it has none. Personal data is masked in all output.
// 3. reviewAccess.signInRequired from Apple's review detail and the code's auth signals (never a default yes); a demo
// account set on App Store Connect goes into .storebound/credentials.json reviewDemoAccount, never printed.
// 4. accounts.deletionUrl, when the app has accounts and none is set: a deletion page found on the app's own site
// (website, privacy policy links). Not found is reported as the gap it is, never invented.
// Then the owner questions that are left (one batch).
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { appleItems, appleTexts, playItems, playTexts } from './catalog';
import { ascClient, type J } from './apple/asc';
import type { StoreItem } from './lib/catalog';
import { reviewContact, reviewDemoAccount } from './lib/credentials';
import type { Ctx } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import { type AppleReviewDetail, contactProposal, type DeletionResult, findDeletionUrl, importCatalog, maskContact, type PageGet, reviewContactOf, signInDecision, type StoreContact } from './lib/onboard';
import { authEvidence } from './lib/project-signals';
import { detectProject } from './lib/scan/project';
import { upsertBlock, upsertNestedString } from './lib/scan/config-edit';
import { playClient } from './play/client';
import { textTable } from './lib/table';
const TAG = 'onboard';
const TIMEOUT_MS = 10_000;
/** GET a public page with a timeout (redirects followed); network errors are status null. */
export const pageGet: PageGet = async url => {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
try {
const r = await fetch(url, { signal: ctl.signal, redirect: 'follow', headers: { 'user-agent': 'storebound (account deletion page check)' } });
const text = (r.headers.get('content-type') ?? '').includes('text') ? (await r.text()).slice(0, 500_000) : '';
return { status: r.status, text, finalUrl: r.url };
} catch { return { status: null, text: '' }; } finally { clearTimeout(t); }
};
async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']>> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
const app = (await c.asc('GET', `/v1/apps/${appId}`)).data;
const primary: string = app?.attributes?.primaryLocale ?? 'en-US';
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
const v: J = (await c.editableVersion()) ?? versions.find((x: J) => (x.attributes.appStoreState ?? x.attributes.appVersionState) === 'READY_FOR_SALE') ?? versions[0] ?? null;
let supportUrl: string | undefined, marketingUrl: string | undefined, review: AppleReviewDetail | null = null;
if (v) {
const locs = await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`);
const l = locs.find((x: J) => x.attributes.locale === primary) ?? locs[0];
supportUrl = l?.attributes?.supportUrl ?? undefined;
marketingUrl = l?.attributes?.marketingUrl ?? undefined;
review = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null;
}
const info = await c.editableAppInfo();
const infoLocs = info ? await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`) : [];
const il = infoLocs.find((x: J) => x.attributes.locale === primary) ?? infoLocs[0];
return { supportUrl, marketingUrl, privacyPolicyUrl: il?.attributes?.privacyPolicyUrl ?? undefined, review };
}
async function playFacts(ctx: Ctx): Promise<NonNullable<StoreContact['play']>> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
try {
const d = await play.call<{ contactEmail?: string; contactWebsite?: string; contactPhone?: string }>('GET', `${play.API}/edits/${e.id}/details`);
return { contactEmail: d.contactEmail, contactWebsite: d.contactWebsite, contactPhone: d.contactPhone };
} finally {
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
}
}
/** .storebound/credentials.json with `key` set when it has none (0600). Returns whether it was written. */
function setProjectSecret(root: string, key: 'reviewContact' | 'reviewDemoAccount', value: unknown): boolean {
mkdirSync(join(root, '.storebound'), { recursive: true });
const f = join(root, '.storebound', 'credentials.json');
const cur = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) as Record<string, unknown> : {};
cur[key] = value;
writeFileSync(f, JSON.stringify(cur, null, 2) + '\n', { mode: 0o600 });
return true;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, path: configPath } = ctx.loaded;
const a = ctx.args;
const store = a.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
const useApple = (!store || store === 'apple') && !!config.apple;
const usePlay = (!store || store === 'play') && !!config.play;
const write = a.has('write') && !a.has('dry');
const json = a.has('json');
const questions: string[] = [];
const report: Record<string, unknown> = {};
let text = readFileSync(configPath, 'utf8');
const before = text;
// 1. catalog
if (!a.has('no-catalog')) {
log(TAG, 'reading the live products and subscriptions');
const read = async <T>(what: string, f: () => Promise<T>): Promise<T | null> => { try { return await f(); } catch (e) { warn(TAG, `${what}: ${(e as Error).message.slice(0, 300)}`); return null; } };
const apple: StoreItem[] | null = useApple ? await read('App Store products', () => appleItems(ctx)) : null;
const play: StoreItem[] | null = usePlay ? await read('Play products', () => playItems(ctx)) : null;
const at = useApple && apple ? await read('App Store product texts', () => appleTexts(ctx)) ?? undefined : undefined;
const pt = usePlay && play ? await read('Play product texts', () => playTexts(ctx)) ?? undefined : undefined;
// a store that could not be read keeps the config's items for that store as they are
const cat = importCatalog(config, { apple, play, appleTexts: at, playTexts: pt });
report.catalog = { added: cat.added, updated: cat.updated, skipped: cat.skipped, notes: cat.notes, products: cat.products.length, subscriptions: cat.subscriptions.reduce((n, g) => n + g.subscriptions.length, 0) };
if (!json) {
const rows = [...cat.products.map(p => [p.id, p.type, p.usd.toFixed(2), (p.overrides ?? []).map(o => `${o.region} ${o.price} ${o.currency}`).join(' '), (p.platforms ?? ['both']).join(' '), Object.keys(p.listings).join(' ')]),
...cat.subscriptions.flatMap(g => g.subscriptions.map(s => [s.id, `sub ${s.period} (${g.name})`, s.usd.toFixed(2), (s.overrides ?? []).map(o => `${o.region} ${o.price} ${o.currency}`).join(' '), (s.platforms ?? ['both']).join(' '), Object.keys(s.listings).join(' ')]))];
if (rows.length) console.log(textTable(['product', 'type', 'usd', 'base / overrides', 'stores', 'languages'], rows));
for (const n of cat.notes) (n.level === 'warn' ? warn : log)(TAG, `${n.id}: ${n.msg}`);
for (const s of cat.skipped) warn(TAG, `${s.id}: not written: ${s.why}`);
}
if (apple || play) {
if (cat.products.length || config.products) text = upsertBlock(text, configPath, 'products', cat.products);
if (cat.subscriptions.length || config.subscriptions) text = upsertBlock(text, configPath, 'subscriptions', cat.subscriptions);
}
}
// 2. contact and URLs, 3. sign-in for review
let appleF: Awaited<ReturnType<typeof appleFacts>> | null = null, playF: Awaited<ReturnType<typeof playFacts>> | null = null;
if (useApple) { try { appleF = await appleFacts(ctx); } catch (e) { warn(TAG, `App Store app details: ${(e as Error).message.slice(0, 300)}`); } }
if (usePlay) { try { playF = await playFacts(ctx); } catch (e) { warn(TAG, `Play app details: ${(e as Error).message.slice(0, 300)}`); } }
const contact = contactProposal(config.app, { apple: appleF, play: playF });
report.contact = contact;
for (const c of contact) {
if (c.replace) text = upsertNestedString(text, configPath, 'app', c.field, c.to);
if (!json) (c.replace ? log : warn)(TAG, c.replace ? `app.${c.field}: ${c.to} (${c.source}; was ${c.from ?? 'unset'})` : `app.${c.field}: the config has ${c.from}, the store ${c.to} (${c.source}); kept the config's`);
}
const rc = reviewContactOf(appleF?.review);
const haveRc = !!reviewContact(root);
report.reviewContact = rc ? { from: 'App Store review detail', masked: maskContact(rc), action: haveRc ? 'kept (already in .storebound/credentials.json)' : 'import' } : null;
if (rc && !json) log(TAG, `App Review contact ${maskContact(rc)}: ${haveRc ? 'already in .storebound/credentials.json (kept)' : 'into .storebound/credentials.json reviewContact'}`);
if (!rc && useApple && !haveRc) questions.push('App Review contact (first and last name, phone, e-mail): App Store Connect has none; put it in .storebound/credentials.json reviewContact');
const facts = detectProject(root);
const auth = authEvidence(facts);
const sign = signInDecision({ auth, apple: appleF?.review });
report.signIn = { signInRequired: sign.signInRequired, because: sign.because, demoAccountOnStore: !!sign.demoAccount, configured: config.reviewAccess?.signInRequired ?? null };
if (!json) log(TAG, `sign-in for review: ${sign.signInRequired === null ? 'unknown' : sign.signInRequired ? 'required' : 'not required'} (${sign.because})`);
if (sign.signInRequired !== null && config.reviewAccess?.signInRequired !== sign.signInRequired) {
if (config.reviewAccess === undefined || config.reviewAccess.signInRequired === undefined) text = upsertBlock(text, configPath, 'reviewAccess', { ...(config.reviewAccess ?? {}), signInRequired: sign.signInRequired }, 'from storebound onboard: App Review sign-in');
else warn(TAG, `reviewAccess.signInRequired is ${config.reviewAccess.signInRequired} in the config, the evidence says ${sign.signInRequired} (${sign.because}); kept the config's`);
}
if (sign.question) questions.push(sign.question);
const haveDemo = !!reviewDemoAccount(root);
if (sign.demoAccount && !json) log(TAG, `demo account on App Store Connect: ${haveDemo ? 'one is already in .storebound/credentials.json (kept)' : 'into .storebound/credentials.json reviewDemoAccount (not printed)'}`);
if (sign.signInRequired && !sign.demoAccount && !haveDemo) questions.push('Demo account for App Review (sign-in is required, none is set on App Store Connect): put it in .storebound/credentials.json reviewDemoAccount');
// 4. account deletion page
const accounts = !!config.accounts?.enabled || auth.length > 0;
let deletion: DeletionResult | null = null;
if (accounts && !config.accounts?.deletionUrl && !a.has('no-web')) {
const site = contact.find(c => c.field === 'website' && c.replace)?.to ?? config.app.website;
const privacy = contact.find(c => c.field === 'privacyPolicyUrl' && c.replace)?.to ?? appleF?.privacyPolicyUrl ?? config.app.privacyPolicyUrl;
deletion = await findDeletionUrl({ website: site, privacyUrl: privacy }, pageGet);
if (!json) (deletion.url ? log : warn)(TAG, deletion.url ? `account deletion page: ${deletion.url} (${deletion.how})` : `account deletion page: ${deletion.how}; tried ${deletion.tried.length} page(s)`);
if (deletion.url) {
text = config.accounts ? upsertNestedString(text, configPath, 'accounts', 'deletionUrl', deletion.url)
: upsertBlock(text, configPath, 'accounts', { enabled: true, deletionUrl: deletion.url }, 'from storebound onboard: the app has sign-in code');
} else if (config.play) questions.push('Account deletion page (Play requires a web page to request account deletion): none was found on the app\'s site; publish one, then set accounts.deletionUrl (account-deletion skill)');
}
report.deletion = deletion;
report.questions = questions;
const changed = text !== before;
const secrets = [rc && !haveRc ? 'reviewContact' : null, sign.demoAccount && !haveDemo ? 'reviewDemoAccount' : null].filter(Boolean);
report.write = { config: changed, credentials: secrets, written: write };
if (write) {
if (changed) writeFileSync(configPath, text);
if (rc && !haveRc) setProjectSecret(root, 'reviewContact', rc);
if (sign.demoAccount && !haveDemo) setProjectSecret(root, 'reviewDemoAccount', sign.demoAccount);
}
if (json) { printJson(report); return; }
if (questions.length) console.log(`\nowner questions (nothing on the stores or in the code proves these):\n${questions.map((q, i) => ` ${i + 1}. ${q}`).join('\n')}`);
const what = [changed && 'storebound.config.ts', secrets.length && `.storebound/credentials.json (${secrets.join(', ')})`].filter(Boolean).join(' + ');
if (!what) done(TAG, 'nothing to change');
else done(TAG, write ? `written: ${what}; next: storebound catalog diff, storebound validate` : `would write ${what} (run with --write)`);
}