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:
parent
34c2fa22e5
commit
7219ded3e8
27 changed files with 659 additions and 19 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue