Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52)
This commit is contained in:
parent
d2ce185441
commit
e5f148e534
55 changed files with 1648 additions and 158 deletions
187
cli/onboard.ts
Normal file
187
cli/onboard.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// 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)`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue