187 lines
14 KiB
TypeScript
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)`);
|
|
}
|