template compliance answers never reach a store: init writes unanswered (dataSafety, appPrivacy, reviewNotes, ageRating, accounts, reviewAccess), older configs are recognised by the template values; check / validate / scan block, play data-safety (also --dry), console plan / fill, apple review-details and appinfo refuse. onboard --declarations replaces them on a live app: name and copyright from the stores (a copyright with a private term is left out), the rows the code proves (scan proposal, sign-up methods from auth code and login SDKs), the rest as one owner question list. play data-safety --dry prints the answers that change (old -> new, account and deletion first) against the last uploaded CSV; pitfall 60; tests

This commit is contained in:
CRTkafa 2026-10-14 11:36:30 +08:00
parent 34c2fa22e5
commit 7219ded3e8
27 changed files with 659 additions and 19 deletions

View file

@ -1,6 +1,7 @@
// 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] [--no-art]
// storebound onboard --declarations [--write] only the template compliance answers and identity (no catalog, no art)
// 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
@ -16,6 +17,10 @@
// 5. the live store art (art pull): App Store screenshot sets, previews and icon, Play icon, feature graphic and
// screenshots into store/renders with pulled.json, so check / validate / uploads treat them like rendered art;
// local files that differ are kept (art pull --force replaces them).
// 6. the template compliance answers and identity `storebound init` wrote (cli/lib/template-answers.ts): name and
// copyright from the stores (a copyright with a private term, STOREBOUND_PRIVATE_TERMS / WAYBETTER_FORBIDDEN_TERMS,
// is left out), the template's Data safety / App Privacy / review notes / accounts / review access out, the
// answers the code proves in (scan's declaration proposal, sign-up methods), the rest stays in `unanswered`.
// Then the owner questions that are left (one batch).
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
@ -32,6 +37,8 @@ import { detectProject } from './lib/scan/project';
import { upsertBlock, upsertNestedString } from './lib/scan/config-edit';
import { playClient } from './play/client';
import { textTable } from './lib/table';
import { resetDeclarations, type StoreIdentity } from './lib/onboard-declarations';
import { privateTerms, TEMPLATE_NAMES } from './lib/template-answers';
const TAG = 'onboard';
const TIMEOUT_MS = 10_000;
@ -47,7 +54,7 @@ export const pageGet: PageGet = async url => {
} catch { return { status: null, text: '' }; } finally { clearTimeout(t); }
};
async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']>> {
async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']> & { name?: string; copyright?: string }> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
@ -68,16 +75,19 @@ async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']>>
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 };
return { supportUrl, marketingUrl, privacyPolicyUrl: il?.attributes?.privacyPolicyUrl ?? undefined, review, name: il?.attributes?.name ?? undefined, copyright: v?.attributes?.copyright ?? undefined };
}
async function playFacts(ctx: Ctx): Promise<NonNullable<StoreContact['play']>> {
async function playFacts(ctx: Ctx): Promise<NonNullable<StoreContact['play']> & { title?: string }> {
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 };
const d = await play.call<{ contactEmail?: string; contactWebsite?: string; contactPhone?: string; defaultLanguage?: string }>('GET', `${play.API}/edits/${e.id}/details`);
const lang = d.defaultLanguage ?? config.play!.defaultLanguage ?? 'en-US';
// the title only when the config still has the template name (onboard replaces it with the store's)
const title = TEMPLATE_NAMES.has(config.app.name) ? (await play.call<{ title?: string }>('GET', `${play.API}/edits/${e.id}/listings/${lang}`).catch(() => null))?.title : undefined;
return { contactEmail: d.contactEmail, contactWebsite: d.contactWebsite, contactPhone: d.contactPhone, title };
} finally {
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
}
@ -94,8 +104,10 @@ function setProjectSecret(root: string, key: 'reviewContact' | 'reviewDemoAccoun
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, path: configPath } = ctx.loaded;
const { root, path: configPath } = ctx.loaded;
let config = ctx.loaded.config;
const a = ctx.args;
const declOnly = a.has('declarations');
const store = a.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
const useApple = (!store || store === 'apple') && !!config.apple;
@ -108,7 +120,7 @@ export async function run(ctx: Ctx): Promise<void> {
const before = text;
// 1. catalog
if (!a.has('no-catalog')) {
if (!a.has('no-catalog') && !declOnly) {
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;
@ -135,6 +147,22 @@ export async function run(ctx: Ctx): Promise<void> {
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)}`); } }
// 6. template compliance answers and identity (before the steps below read accounts / reviewAccess)
const facts = detectProject(root);
const termsRead = (f: string) => { try { return readFileSync(f, 'utf8'); } catch { return null; } };
const storeId: StoreIdentity = appleF?.name ? { name: appleF.name, copyright: appleF.copyright, source: 'App Store Connect' }
: { name: playF?.title, copyright: appleF?.copyright, source: playF?.title ? 'Google Play listing' : 'App Store Connect' };
const reset = resetDeclarations({ config, text, path: configPath, facts, store: storeId, terms: privateTerms(termsRead) });
text = reset.text;
config = reset.config;
report.declarations = { removed: reset.removed, proven: reset.proven, unanswered: reset.unanswered };
if (!json) {
for (const r of reset.removed) warn(TAG, `template answer out: ${r}`);
for (const p of reset.proven) log(TAG, `proven: ${p}`);
if (reset.unanswered.length) warn(TAG, `unanswered (check / validate block until the owner answers): ${reset.unanswered.join(', ')}`);
}
questions.push(...reset.questions);
const contact = contactProposal(config.app, { apple: appleF, play: playF });
report.contact = contact;
for (const c of contact) {
@ -147,7 +175,6 @@ export async function run(ctx: Ctx): Promise<void> {
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 };
@ -178,7 +205,7 @@ export async function run(ctx: Ctx): Promise<void> {
// 5. the store art (icon, screenshots, previews, feature graphic) from the live stores into store/renders, so
// nothing has to be rendered again for an app that is already live (art pull; upsert, local files kept)
if (!a.has('no-art')) {
if (!a.has('no-art') && !declOnly) {
try {
const art = await artPull(ctx, { store, write, force: false, quiet: json });
report.art = { summary: art.summary, conflicts: art.conflicts, unmapped: art.unmapped, written: art.written, stores: art.stores };