// 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, reviewVersion, 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> { 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 access comes from the live version's review detail (a newer draft's empty one must not hide its demo account) const rv = reviewVersion(versions); if (rv) review = (await c.maybe(`/v1/appStoreVersions/${rv.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> { 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 : {}; cur[key] = value; writeFileSync(f, JSON.stringify(cur, null, 2) + '\n', { mode: 0o600 }); return true; } export async function run(ctx: Ctx): Promise { 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 = {}; 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 (what: string, f: () => Promise): Promise => { 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> | null = null, playF: Awaited> | 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)`); }