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
|
|
@ -10,10 +10,14 @@
|
|||
// contact and demo account stay in the project
|
||||
// storebound accounts use <profile> [--dir <project>] this project uses that profile ("account" in its
|
||||
// .storebound/credentials.json)
|
||||
// storebound accounts default <profile> | --clear the machine's default profile (a pointer file in the
|
||||
// credentials folder). Order: env > a profile the project
|
||||
// names > the project's own keys > this default > the only
|
||||
// profile (or the one named "default")
|
||||
import { resolve } from 'node:path';
|
||||
import type { Args } from './lib/args';
|
||||
import { applyImport, describeProfiles, planImport } from './lib/accounts';
|
||||
import { activeProfile, profileDir } from './lib/credentials';
|
||||
import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials';
|
||||
import { confirm, type Ctx } from './lib/ctx';
|
||||
import { textTable } from './lib/table';
|
||||
|
||||
|
|
@ -26,14 +30,17 @@ export async function run(args: Args): Promise<void> {
|
|||
const rows = describeProfiles();
|
||||
let active = null;
|
||||
try { active = activeProfile(dir); } catch { /* unreadable project file */ }
|
||||
if (args.has('json')) { console.log(JSON.stringify({ folder: tilde(profileDir()), profiles: rows, thisFolder: active }, null, 2)); return; }
|
||||
const def = defaultProfile();
|
||||
const pointer = defaultPointer();
|
||||
if (args.has('json')) { console.log(JSON.stringify({ folder: tilde(profileDir()), profiles: rows.map(r => ({ ...r, default: r.profile === def })), default: def, defaultFrom: def ? (def === pointer ? 'pointer' : rows.length === 1 ? 'only' : 'named-default') : null, thisFolder: active }, null, 2)); return; }
|
||||
if (!rows.length) {
|
||||
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
|
||||
return;
|
||||
}
|
||||
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-'])));
|
||||
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
|
||||
if (pointer && pointer !== def) console.log(`note: the default pointer names "${pointer}", which is not a profile here (storebound accounts default <profile>)`);
|
||||
console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`);
|
||||
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and none named "default": storebound accounts use <profile>)');
|
||||
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and no default: storebound accounts default <profile>, or accounts use <profile> in the project)');
|
||||
return;
|
||||
}
|
||||
if (sub === 'import') {
|
||||
|
|
@ -61,5 +68,13 @@ export async function run(args: Args): Promise<void> {
|
|||
console.log(`${tilde(f)}: "account": "${name}"`);
|
||||
return;
|
||||
}
|
||||
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile>]');
|
||||
if (sub === 'default') {
|
||||
if (args.has('clear')) { setDefaultPointer(null); console.log('default profile pointer removed (default: the only profile, or the one named "default")'); return; }
|
||||
const name = args._[1];
|
||||
if (!name) { const d = defaultProfile(); console.log(d ? `default profile: ${d}${d === defaultPointer() ? ' (accounts default)' : ''}` : 'no default profile (storebound accounts default <profile>)'); return; }
|
||||
const f = setDefaultPointer(name);
|
||||
console.log(`${tilde(f)}: default profile "${name}" (used by every folder that names no profile and has no keys of its own)`);
|
||||
return;
|
||||
}
|
||||
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile> | default <profile> | default --clear]');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appEvents_getInstance': 'apple/events',
|
||||
'appEvents_localizations_getToManyRelated': 'apple/events',
|
||||
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo console/verify validate',
|
||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
|
||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing onboard validate',
|
||||
'appInfos_getInstance': 'apple/appinfo',
|
||||
'appInfos_updateInstance': 'apple/appinfo',
|
||||
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
|
||||
|
|
@ -54,8 +54,8 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
|
||||
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
|
||||
'appStoreVersionReleaseRequests_createInstance': 'apple/release',
|
||||
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots validate',
|
||||
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details onboard validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots onboard validate',
|
||||
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
|
||||
'appStoreVersions_build_getToOneRelated': 'apple/listing apple/resubmit validate',
|
||||
'appStoreVersions_build_updateToOneRelationship': 'apple/listing apple/resubmit',
|
||||
|
|
@ -70,7 +70,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
|
||||
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
|
||||
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
|
||||
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps validate',
|
||||
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps onboard validate',
|
||||
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
|
||||
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
|
||||
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
|
||||
|
|
@ -78,7 +78,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_buildUploads_getToManyRelated': 'apple/upload',
|
||||
'apps_customerReviews_getToManyRelated': 'reviews',
|
||||
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id console/verify lib/store-apps',
|
||||
'apps_getInstance': 'apple/appinfo apple/status',
|
||||
'apps_getInstance': 'apple/appinfo apple/status apps onboard',
|
||||
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
|
||||
'apps_perfPowerMetrics_getToManyRelated': 'health',
|
||||
'apps_promotedPurchases_getToManyRelated': 'apple/promoted',
|
||||
|
|
@ -209,13 +209,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'androidpublisher.edits.bundles.upload': 'play/release play/upload',
|
||||
'androidpublisher.edits.commit': 'play/client',
|
||||
'androidpublisher.edits.countryavailability.get': 'validate',
|
||||
'androidpublisher.edits.delete': 'apps play/client validate',
|
||||
'androidpublisher.edits.details.get': 'apps play/listing play/status validate',
|
||||
'androidpublisher.edits.delete': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.details.get': 'apps onboard play/listing play/status validate',
|
||||
'androidpublisher.edits.details.update': 'play/listing',
|
||||
'androidpublisher.edits.images.deleteall': 'play/images',
|
||||
'androidpublisher.edits.images.list': 'apps play/images validate',
|
||||
'androidpublisher.edits.images.upload': 'play/images',
|
||||
'androidpublisher.edits.insert': 'apps play/client validate',
|
||||
'androidpublisher.edits.insert': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.listings.list': 'play/listing play/status validate',
|
||||
'androidpublisher.edits.listings.update': 'play/listing',
|
||||
'androidpublisher.edits.tracks.get': 'play/release play/rollout play/upload',
|
||||
|
|
|
|||
81
cli/apps.ts
81
cli/apps.ts
|
|
@ -8,21 +8,25 @@
|
|||
// storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,
|
||||
// the same list as the desktop app's App folders)
|
||||
// storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)
|
||||
// storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]
|
||||
// a store app with no project on this machine: the
|
||||
// desktop shows it read-only ("no local project");
|
||||
// apps remove apple:<id> | <entry id> takes it out
|
||||
import type { Args } from './lib/args';
|
||||
import { ascKey, playServiceAccount } from './lib/credentials';
|
||||
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
|
||||
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
|
||||
import { errorBody } from './lib/http';
|
||||
import { log, printJson, warn } from './lib/log';
|
||||
import { appleCheck, type Bytes, dataUrl, type Get, listAppleApps, listPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
|
||||
import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
|
||||
import { textTable } from './lib/table';
|
||||
import { ascClient } from './apple/asc';
|
||||
import { REPORTING_API } from './play/google';
|
||||
import * as rt from './lib/rt';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { CONFIG_NAMES } from './lib/config';
|
||||
import { addFolder, readRegistry, registryFile, removeFolder } from './lib/registry';
|
||||
import { addFolder, addStoreApp, readRegistry, registryFile, removeFolder, removeStoreApp } from './lib/registry';
|
||||
|
||||
type Store = 'apple' | 'play';
|
||||
|
||||
|
|
@ -99,8 +103,42 @@ export async function listApps(store: Store, root: string, icons: boolean): Prom
|
|||
return { store: 'ios', ...r, at };
|
||||
}
|
||||
const sa = playServiceAccount(root);
|
||||
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {});
|
||||
return { store: 'android', ...r, at };
|
||||
try {
|
||||
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {});
|
||||
return { store: 'android', ...r, at };
|
||||
} catch (e) {
|
||||
// the Reporting API is off in the service account's Cloud project: a clear finding with the exact link, and the
|
||||
// Play apps this machine knows (app folders' configs, store-only entries) instead of failing the whole list
|
||||
if (!isServiceDisabled(e)) throw e;
|
||||
const c = playCheck(sa.client_email, { error: e });
|
||||
const known = knownPlayPackages(root);
|
||||
return { store: 'android', apps: localPlayApps(known), at,
|
||||
warnings: [`${c.message} Fix: ${c.fix}`, known.length ? `listed ${known.length} Play app(s) from this machine's app folders and store-only entries instead (not read from Google Play)` : 'no Play app folders on this machine to list instead (storebound apps add)'] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Play package names this machine knows without the Reporting API: the config of `root`, every app folder of
|
||||
* ~/.storebound/projects.json (read as text, never run) and the store-only Play entries. */
|
||||
export function knownPlayPackages(root: string): { packageName: string; name: string; source: string }[] {
|
||||
const out: { packageName: string; name: string; source: string }[] = [];
|
||||
const reg = readRegistry();
|
||||
const fromFolder = (dir: string, name: string) => {
|
||||
for (const n of CONFIG_NAMES) {
|
||||
const f = join(dir, n);
|
||||
if (!existsSync(f)) continue;
|
||||
try {
|
||||
const text = readFileSync(f, 'utf8');
|
||||
const pkg = /\bpackageName\s*["']?\s*:\s*["']([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["']/.exec(text)?.[1];
|
||||
const appName = /\bapp\s*["']?\s*:\s*\{[^}]*?\bname\s*["']?\s*:\s*["']([^"'\n]+)["']/.exec(text)?.[1];
|
||||
if (pkg && !out.some(o => o.packageName === pkg)) out.push({ packageName: pkg, name: appName ?? name, source: dir });
|
||||
} catch { /* unreadable: skipped */ }
|
||||
return;
|
||||
}
|
||||
};
|
||||
fromFolder(root, '');
|
||||
for (const p of reg.projects) fromFolder(p.path, p.name);
|
||||
for (const a of reg.storeApps ?? []) if (a.store === 'play' && !out.some(o => o.packageName === a.appId)) out.push({ packageName: a.appId, name: a.name, source: 'store-only entry' });
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function checkAccount(store: Store, root: string): Promise<AccountCheck> {
|
||||
|
|
@ -123,16 +161,43 @@ export async function checkAccount(store: Store, root: string): Promise<AccountC
|
|||
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
|
||||
|
||||
/** apps folders / add / remove: the machine's app folder list (cli/lib/registry.ts). */
|
||||
function folders(sub: string, args: Args): void {
|
||||
async function folders(sub: string, args: Args): Promise<void> {
|
||||
if (sub === 'folders') {
|
||||
const r = readRegistry();
|
||||
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })) }); return; }
|
||||
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })), storeApps: r.storeApps ?? [] }); return; }
|
||||
if (r.storeApps?.length) {
|
||||
console.log(textTable(['store app', 'store', 'app id', 'profile', ''], r.storeApps.map(a => [a.name, a.store, a.appId, a.account ?? '(default)', 'no local project'])));
|
||||
console.log('');
|
||||
}
|
||||
if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; }
|
||||
console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found'])));
|
||||
console.log(`
|
||||
${tilde(registryFile())}: the same list as the desktop app's App folders`);
|
||||
return;
|
||||
}
|
||||
const store = args.get('store');
|
||||
if (sub === 'add' && store !== undefined) {
|
||||
if (store !== 'apple' && store !== 'play') throw new Error('--store: apple or play');
|
||||
const appId = args.get('app-id');
|
||||
if (!appId) throw new Error('storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <name>]');
|
||||
let name = args.get('name'), bundleId = args.get('bundle-id') ?? null;
|
||||
// Apple: the name and bundle id from App Store Connect when the profile's key can read them (read-only; offline is fine)
|
||||
if (store === 'apple' && (!name || !bundleId) && /^\d{6,12}$/.test(appId)) {
|
||||
try {
|
||||
const a = (await (await appleGet(process.cwd()))(`/v1/apps/${appId}?fields[apps]=name,bundleId`)).data;
|
||||
name ||= a?.attributes?.name; bundleId ||= a?.attributes?.bundleId ?? null;
|
||||
} catch { /* no key or no access: the entry keeps what was given */ }
|
||||
}
|
||||
const r = addStoreApp({ store, appId, account: args.get('account') ?? (process.env.STOREBOUND_PROFILE || null), name, bundleId });
|
||||
log('apps', r.added ? `added ${store} app ${r.app.appId} (${r.app.name}) with no local project${r.app.account ? `, profile ${r.app.account}` : ''}: the desktop shows it read-only (status, reviews, analytics)`
|
||||
: r.changed ? `updated ${store} app ${r.app.appId}` : `${store} app ${r.app.appId} is already in the list`);
|
||||
return;
|
||||
}
|
||||
if (sub === 'remove' && args._[1] && (/^(apple|play):/.test(args._[1]) || (readRegistry().storeApps ?? []).some(a => a.id === args._[1]))) {
|
||||
const removed = removeStoreApp(args._[1]);
|
||||
log('apps', removed ? `removed ${removed.store} app ${removed.appId} from the list` : `${args._[1]} is not in the list`);
|
||||
return;
|
||||
}
|
||||
const dir = resolve(args._[1] ?? '.');
|
||||
if (sub === 'add') {
|
||||
if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new Error(`${dir}: no storebound config here (storebound init starts one)`);
|
||||
|
|
@ -181,6 +246,6 @@ export async function run(args: Args): Promise<void> {
|
|||
if (out.some(c => c.status !== 'ok')) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps remove <folder>');
|
||||
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps add --store apple|play --app-id <id> [--account <profile>] | apps remove <folder> | apps remove apple:<id>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,8 +220,8 @@ async function suggest(ctx: Ctx): Promise<void> {
|
|||
const skipped = r.candidates.filter(c => c.status !== 'candidate' && c.kind === 'word').slice(0, 12);
|
||||
if (skipped.length) log(TAG, `left out: ${skipped.map(c => `${c.term} (${c.why})`).join(', ')}`);
|
||||
const p = r.proposal;
|
||||
if (p.after === p.before) { log(TAG, `${r.locale}: keyword field unchanged (${p.bytes}/100 bytes)`); continue; }
|
||||
console.log(`--- ${r.locale} keywords (store/appstore.md)\n- ${p.before || '(empty)'}\n+ ${p.after}\n ${p.bytes}/100 bytes · kept ${p.kept.length} · added ${p.added.join(', ') || 'none'}${p.dropped.length ? ` · dropped ${p.dropped.map(d => `${d.term} (${d.why})`).join(', ')}` : ''}`);
|
||||
if (p.after === p.before) { log(TAG, `${r.locale}: keyword field unchanged (${p.chars}/100 characters)`); continue; }
|
||||
console.log(`--- ${r.locale} keywords (store/appstore.md)\n- ${p.before || '(empty)'}\n+ ${p.after}\n ${p.chars}/100 characters · kept ${p.kept.length} · added ${p.added.join(', ') || 'none'}${p.dropped.length ? ` · dropped ${p.dropped.map(d => `${d.term} (${d.why})`).join(', ')}` : ''}`);
|
||||
}
|
||||
log(TAG, 'relevance: in your own text (+1) and in competitors\' names (+1); "in names": top apps that already have the word in their name; popularity only from Apple Ads, never estimated');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { ascClient, type J } from './apple/asc';
|
|||
import { type CatalogRow, catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from './lib/catalog';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { done, log, printJson } from './lib/log';
|
||||
import { fromMoney } from './lib/money';
|
||||
import { fromMoney, type Money } from './lib/money';
|
||||
import { listOneTimeProducts } from './play/products';
|
||||
import { playClient } from './play/client';
|
||||
import { appleSubPrices, basePlanId } from './lib/subscriptions';
|
||||
|
|
@ -25,7 +25,7 @@ import { textTable } from './lib/table';
|
|||
|
||||
const TAG = 'catalog';
|
||||
|
||||
async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
export async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
|
|
@ -37,11 +37,15 @@ async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
const hasSchedule = !!(await c.maybe(`/v2/inAppPurchases/${p.id}/iapPriceSchedule`))?.data;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
// today's price per territory: entries that started (or have no start), the latest start wins; manual over automatic
|
||||
const rows = hasSchedule ? (await appleCurrent(c, p.id, p.attributes.productId, true)).rows.filter(r => (r.startDate ?? '') <= today)
|
||||
.sort((x, y) => (x.kind === 'auto' ? 0 : 1) - (y.kind === 'auto' ? 0 : 1) || (x.startDate ?? '').localeCompare(y.startDate ?? '')) : [];
|
||||
const cur = hasSchedule ? await appleCurrent(c, p.id, p.attributes.productId, true) : null;
|
||||
const rows = (cur?.rows ?? []).filter(r => (r.startDate ?? '') <= today)
|
||||
.sort((x, y) => (x.kind === 'auto' ? 0 : 1) - (y.kind === 'auto' ? 0 : 1) || (x.startDate ?? '').localeCompare(y.startDate ?? ''));
|
||||
const byRegion = new Map(rows.map(r => [r.region, r] as const));
|
||||
// the price schedule's base territory (alpha-3) and its price today
|
||||
const base = cur?.base ? byRegion.get(regionOf(cur.base)) : undefined;
|
||||
out.push({ id: p.attributes.productId, kind: fromAppleType(p.attributes.inAppPurchaseType), state: p.attributes.state, locales,
|
||||
prices: [...byRegion.values()].map(r => ({ region: r.region, currency: r.currency, price: r.price })) });
|
||||
prices: [...byRegion.values()].map(r => ({ region: r.region, currency: r.currency, price: r.price })),
|
||||
...(base ? { base: { region: base.region, currency: base.currency, price: base.price } } : {}) });
|
||||
}
|
||||
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
|
||||
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
|
||||
|
|
@ -52,14 +56,14 @@ async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
const promos = (await c.all(`/v1/subscriptions/${s.id}/promotionalOffers?limit=200`)).map((o: J) => o.attributes.offerCode as string);
|
||||
const codes = (await c.all(`/v1/subscriptions/${s.id}/offerCodes?limit=200`)).map((o: J) => `code:${o.attributes.name}`);
|
||||
out.push({ id: s.attributes.productId, kind: 'subscription', state: s.attributes.state, period: fromApplePeriod(s.attributes.subscriptionPeriod),
|
||||
level: s.attributes.groupLevel, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })),
|
||||
level: s.attributes.groupLevel, group: g.attributes.referenceName, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })),
|
||||
offers: [...intro, ...promos, ...codes] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
export async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const play = await playClient(root, config.play!.packageName);
|
||||
const out: StoreItem[] = [];
|
||||
|
|
@ -69,7 +73,8 @@ async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
if (!p) continue;
|
||||
const opt = p.purchaseOptions?.find(o => o.purchaseOptionId === 'buy') ?? p.purchaseOptions?.[0];
|
||||
out.push({ id: productId, kind: 'one_time', state: opt?.state, locales: ((p.listings as { languageCode: string }[] | undefined) ?? []).map(l => l.languageCode),
|
||||
prices: (opt?.regionalPricingAndAvailabilityConfigs ?? []).filter(r => r.price && r.availability !== 'NO_LONGER_AVAILABLE').map(r => ({ region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) })) });
|
||||
prices: (opt?.regionalPricingAndAvailabilityConfigs ?? []).filter(r => r.price && r.availability !== 'NO_LONGER_AVAILABLE').map(r => ({ region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) })),
|
||||
...((opt?.newRegionsConfig as { usdPrice?: Money } | undefined)?.usdPrice ? { usd: fromMoney((opt!.newRegionsConfig as { usdPrice: Money }).usdPrice) } : {}) });
|
||||
}
|
||||
let page = '';
|
||||
do {
|
||||
|
|
@ -83,14 +88,15 @@ async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
const offers = bp ? ((await play.call<{ subscriptionOffers?: { offerId: string }[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.productId)}/basePlans/${bp.basePlanId}/offers`)).subscriptionOffers ?? []).map(o => o.offerId) : [];
|
||||
out.push({ id: s.productId, kind: 'subscription', state: bp?.state, period: bp?.autoRenewingBasePlanType?.billingPeriodDuration ?? bp?.prepaidBasePlanType?.billingPeriodDuration,
|
||||
locales: (s.listings ?? []).map((l: J) => l.languageCode),
|
||||
prices: (bp?.regionalConfigs ?? []).filter((x: J) => x.price).map((x: J) => ({ region: x.regionCode, currency: x.price.currencyCode, price: fromMoney(x.price) })), offers });
|
||||
prices: (bp?.regionalConfigs ?? []).filter((x: J) => x.price).map((x: J) => ({ region: x.regionCode, currency: x.price.currencyCode, price: fromMoney(x.price) })), offers,
|
||||
...(bp?.otherRegionsConfig?.usdPrice ? { usd: fromMoney(bp.otherRegionsConfig.usdPrice) } : {}) });
|
||||
}
|
||||
page = r.nextPageToken ?? '';
|
||||
} while (page);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
export async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
|
|
@ -106,7 +112,7 @@ async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
|||
return out;
|
||||
}
|
||||
|
||||
async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
export async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const play = await playClient(root, config.play!.packageName);
|
||||
const out: RemoteCatalog = {};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
|||
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
||||
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app'],
|
||||
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],
|
||||
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile>'],
|
||||
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile> | default <profile> (the machine default; --clear)'],
|
||||
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
|
||||
'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'],
|
||||
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],
|
||||
|
|
@ -30,6 +30,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
|
||||
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
|
||||
'onboard': [() => import('./onboard'), 'an app that is already live: the live products + subscriptions with their USD / base prices, contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page, from the stores, the code and the app\'s site -> config and .storebound/credentials.json (--write; read-only on the stores, never changes store prices); then the owner questions nothing proves'],
|
||||
'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'],
|
||||
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'],
|
||||
'triage': [() => import('./triage'), 'TestFlight crash / screenshot feedback and Play error reports grouped by signature, device, OS version, build; read-only, --json'],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
],
|
||||
"commands": {
|
||||
"accounts": [
|
||||
"clear",
|
||||
"dir",
|
||||
"name"
|
||||
],
|
||||
|
|
@ -261,7 +262,10 @@
|
|||
"with-automatic"
|
||||
],
|
||||
"apps": [
|
||||
"app-id",
|
||||
"bundle-id",
|
||||
"icons",
|
||||
"name",
|
||||
"store"
|
||||
],
|
||||
"aso": [
|
||||
|
|
@ -436,6 +440,36 @@
|
|||
"translate"
|
||||
],
|
||||
"mcp": [],
|
||||
"onboard": [
|
||||
"all",
|
||||
"allow-price-changes",
|
||||
"base",
|
||||
"capacitor",
|
||||
"ceiling",
|
||||
"cordova",
|
||||
"cordova-android",
|
||||
"cordova-ios",
|
||||
"csv",
|
||||
"expo",
|
||||
"file",
|
||||
"floor",
|
||||
"flutter",
|
||||
"godot",
|
||||
"no-catalog",
|
||||
"no-subscriptions",
|
||||
"no-web",
|
||||
"offline",
|
||||
"out",
|
||||
"react-native",
|
||||
"reconvert",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
"unity",
|
||||
"update-bigmac",
|
||||
"with-automatic",
|
||||
"write"
|
||||
],
|
||||
"originality": [
|
||||
"against",
|
||||
"threshold"
|
||||
|
|
@ -755,6 +789,7 @@
|
|||
"alpha",
|
||||
"apk",
|
||||
"app",
|
||||
"app-id",
|
||||
"apple",
|
||||
"apply",
|
||||
"apps",
|
||||
|
|
@ -767,6 +802,7 @@
|
|||
"body",
|
||||
"build",
|
||||
"build-number",
|
||||
"bundle-id",
|
||||
"by",
|
||||
"campaign",
|
||||
"cancel-submission",
|
||||
|
|
@ -775,6 +811,7 @@
|
|||
"ceiling",
|
||||
"channel",
|
||||
"chrome-profile",
|
||||
"clear",
|
||||
"codes",
|
||||
"command",
|
||||
"comments",
|
||||
|
|
@ -850,6 +887,7 @@
|
|||
"name",
|
||||
"new-version",
|
||||
"no-captions",
|
||||
"no-catalog",
|
||||
"no-declarations",
|
||||
"no-folder",
|
||||
"no-health",
|
||||
|
|
@ -866,6 +904,7 @@
|
|||
"no-validate",
|
||||
"no-variants",
|
||||
"no-wait",
|
||||
"no-web",
|
||||
"off",
|
||||
"offline",
|
||||
"older-than",
|
||||
|
|
@ -960,7 +999,8 @@
|
|||
"accounts": [
|
||||
"storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which",
|
||||
"storebound accounts import [--dir <project>] [--name <profile>] [--dry] [--yes]",
|
||||
"storebound accounts use <profile> [--dir <project>] this project uses that profile (\"account\" in its"
|
||||
"storebound accounts use <profile> [--dir <project>] this project uses that profile (\"account\" in its",
|
||||
"storebound accounts default <profile> | --clear the machine's default profile (a pointer file in the"
|
||||
],
|
||||
"ads": [
|
||||
"storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json]"
|
||||
|
|
@ -1101,7 +1141,8 @@
|
|||
"storebound apps list [--store apple|play] [--icons] [--json] every app the key can see (App Store Connect team /",
|
||||
"storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix",
|
||||
"storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,",
|
||||
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)"
|
||||
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)",
|
||||
"storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]"
|
||||
],
|
||||
"aso": [
|
||||
"storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]",
|
||||
|
|
@ -1173,6 +1214,9 @@
|
|||
"storebound listing check [--json] limits, keyword rules, untranslated and draft sections; exit 1 on a problem",
|
||||
"storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker"
|
||||
],
|
||||
"onboard": [
|
||||
"storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]"
|
||||
],
|
||||
"originality": [
|
||||
"storebound originality [--against <folder>[,<folder>]] [--threshold 0.7] [--json]"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -169,10 +169,10 @@ export function nextActions(a: AsoAudit, i: AuditInput): Action[] {
|
|||
const err = f.find(x => x.level === 'error');
|
||||
if (err) out.push({ what: `fix ${err.where}: ${err.msg}`, why: 'App Store Connect refuses the keyword field as it is', command: 'storebound check --aso' });
|
||||
if (a.drafts.length) out.push({ what: `review the draft keywords of ${a.drafts.join(', ')} in store/appstore.md and delete the draft line`, why: 'they were proposed by aso suggest, not reviewed yet' });
|
||||
const room = f.find(x => /bytes used; fill/.test(x.msg));
|
||||
if (room) out.push({ what: `fill the keyword field of ${room.where.replace('apple ', '')} (${room.msg.replace(/^keywords: /, '')})`, why: 'unused bytes are unused search reach', command: `storebound aso suggest --locale ${room.where.replace('apple ', '')}` });
|
||||
const room = f.find(x => /characters used; fill/.test(x.msg));
|
||||
if (room) out.push({ what: `fill the keyword field of ${room.where.replace('apple ', '')} (${room.msg.replace(/^keywords: /, '')})`, why: 'unused characters are unused search reach', command: `storebound aso suggest --locale ${room.where.replace('apple ', '')}` });
|
||||
const rep = f.find(x => /repeat words from the name/.test(x.msg));
|
||||
if (rep) out.push({ what: `${rep.where}: replace the words the name / subtitle already index (${rep.msg.split(': ').at(-1)})`, why: 'they take bytes without adding reach', command: `storebound aso suggest --locale ${rep.where.replace('apple ', '')}` });
|
||||
if (rep) out.push({ what: `${rep.where}: replace the words the name / subtitle already index (${rep.msg.split(': ').at(-1)})`, why: 'they take characters without adding reach', command: `storebound aso suggest --locale ${rep.where.replace('apple ', '')}` });
|
||||
const gap = a.competitors.gaps.find(g => g.gaps.length);
|
||||
if (gap) out.push({ what: `consider "${gap.gaps.slice(0, 3).map(g => g.word).join('", "')}" for ${gap.locale} (in the names of ${gap.gaps[0]!.developers} developers' top apps, not in your name / subtitle / keywords)`, why: 'words the top apps lead with; only if they describe your app', command: `storebound aso suggest --locale ${gap.locale}` });
|
||||
const lost = a.ranks.rows.filter(r => r.arrow === '↓' || r.arrow === 'lost');
|
||||
|
|
|
|||
|
|
@ -276,12 +276,12 @@ export function candidates(i: CandidateInput): Candidate[] {
|
|||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// The 100-byte keyword field
|
||||
|
||||
export interface FieldProposal { locale: string; before: string; after: string; bytes: number; kept: string[]; added: string[]; dropped: { term: string; why: string }[] }
|
||||
export interface FieldProposal { locale: string; before: string; after: string; chars: number; kept: string[]; added: string[]; dropped: { term: string; why: string }[] }
|
||||
|
||||
const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
|
||||
const chars = (s: string) => [...s].length;
|
||||
|
||||
/**
|
||||
* A keyword field for one locale that fits 100 bytes (UTF-8) with the existing rules: commas without spaces, no
|
||||
* A keyword field for one locale that fits 100 characters (App Store Connect counts characters, not bytes) with the existing rules: commas without spaces, no
|
||||
* duplicates (plural-insensitive), no words of the name / subtitle, no filler or category words, single words. Current
|
||||
* terms that are still valid are kept first (change a few terms per release, not all), unless `fresh`; then the best
|
||||
* candidates fill the room. Pure.
|
||||
|
|
@ -292,7 +292,7 @@ export function fitKeywordField(o: { locale: string; current: string; name: stri
|
|||
const blocked = new Map(o.ranked.filter(c => c.status !== 'candidate' && c.kind === 'word').map(c => [stem(c.term, L), c.why ?? c.status] as const));
|
||||
const out: string[] = [], kept: string[] = [], added: string[] = [], dropped: { term: string; why: string }[] = [];
|
||||
const seen: string[] = [];
|
||||
const fits = (t: string) => bytes([...out, t].join(',')) <= limit;
|
||||
const fits = (t: string) => chars([...out, t].join(',')) <= limit;
|
||||
const tryAdd = (t: string, into: string[]): string | null => {
|
||||
const ws = words(t, L);
|
||||
if (!ws.length) return 'empty';
|
||||
|
|
@ -301,12 +301,12 @@ export function fitKeywordField(o: { locale: string; current: string; name: stri
|
|||
if (ws.length === 1 ? coveredBy(ws[0]!, seen, L) : seen.includes(ws.join(' '))) return 'duplicate';
|
||||
const b = ws.length === 1 ? blocked.get(stem(ws[0]!, L)) : undefined;
|
||||
if (b) return b;
|
||||
if (!fits(t)) return 'no room in 100 bytes';
|
||||
if (!fits(t)) return 'no room in 100 characters';
|
||||
seen.push(ws.length === 1 ? ws[0]! : ws.join(' ')); out.push(t); into.push(t);
|
||||
return null;
|
||||
};
|
||||
if (!o.fresh) for (const t of o.current.split(',').map(s => s.trim().toLocaleLowerCase(L)).filter(Boolean)) {
|
||||
// keep a current phrase as the words it is made of: single words combine on their own and save bytes
|
||||
// keep a current phrase as the words it is made of: single words combine on their own and save characters
|
||||
const parts = /\s/.test(t) ? tokens(t, L) : [t];
|
||||
for (const p of parts) { const why = tryAdd(p, kept); if (why && why !== 'duplicate') dropped.push({ term: p, why }); }
|
||||
}
|
||||
|
|
@ -315,7 +315,7 @@ export function fitKeywordField(o: { locale: string; current: string; name: stri
|
|||
tryAdd(c.term, added);
|
||||
}
|
||||
const after = out.join(',');
|
||||
return { locale: L, before: o.current, after, bytes: bytes(after), kept, added, dropped };
|
||||
return { locale: L, before: o.current, after, chars: chars(after), kept, added, dropped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -27,12 +27,13 @@ export function auditAppleKeywords(locale: string, c: AppleCopy, appName: string
|
|||
const at = `apple ${locale}`;
|
||||
const kw = c.keywords ?? '';
|
||||
if (!kw) return [{ level: 'error', where: at, msg: 'keywords: empty' }];
|
||||
// App Store Connect limits the keyword field to 100 bytes (UTF-8), not characters: Cyrillic, Arabic, Hindi, Thai,
|
||||
// Japanese, Korean and Chinese words take 2-3 bytes per character, so both the limit and the fill hint count bytes.
|
||||
const bytes = Buffer.byteLength(kw, 'utf8');
|
||||
if (bytes > 100) out.push({ level: 'error', where: at, msg: `keywords: ${bytes} bytes > 100` });
|
||||
// App Store Connect limits the keyword field to 100 characters, not UTF-8 bytes: live versions (READY_FOR_SALE,
|
||||
// read 2026-10-13) hold Thai 95 characters / 265 bytes, Russian 91 / 169, Turkish 100 / 105, Polish 99 / 105
|
||||
// (store-pitfalls 43). Both the limit and the fill hint count characters.
|
||||
const chars = len(kw);
|
||||
if (chars > 100) out.push({ level: 'error', where: at, msg: `keywords: ${chars} characters > 100` });
|
||||
if (/,\s|\s,/.test(kw)) out.push({ level: 'error', where: at, msg: 'keywords: spaces around commas waste characters' });
|
||||
if (bytes < 90) out.push({ level: 'warn', where: at, msg: `keywords: ${bytes}/100 bytes used; fill at least 90 (${100 - bytes} bytes free)` });
|
||||
if (chars < 90) out.push({ level: 'warn', where: at, msg: `keywords: ${chars}/100 characters used; fill at least 90 (${100 - chars} characters free)` });
|
||||
const terms = kw.split(',').map(t => t.trim().toLocaleLowerCase(locale)).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
for (const t of terms) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export interface StoreItem {
|
|||
/** Offer ids (Play offer ids; Apple: "intro" when there is an introductory offer, promotional offer codes,
|
||||
* "code:<name>" for offer code campaigns). */
|
||||
offers?: string[];
|
||||
/** Apple in-app purchases: the price schedule's base territory (region, currency) and its price today. */
|
||||
base?: { region: string; currency: string; price: number };
|
||||
/** The store's own USD price for regions without one of their own (Play newRegionsConfig / otherRegionsConfig). */
|
||||
usd?: number;
|
||||
/** Apple subscriptions: the group's reference name. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface WantItem {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ export function parseAppStoreListing(md: string): Record<string, AppleCopy> {
|
|||
for (const k of APPLE_REQUIRED) if (!f[k]) errors.push(`${locale}: missing ${k}`);
|
||||
for (const [k, v] of Object.entries(f) as [AppleField, string][])
|
||||
if (len(v) > APPLE_LIMITS[k]) errors.push(`${locale}: ${k} is ${len(v)} > ${APPLE_LIMITS[k]}`);
|
||||
if (f.keywords && Buffer.byteLength(f.keywords) > 100) errors.push(`${locale}: keywords are ${Buffer.byteLength(f.keywords)} bytes > 100`);
|
||||
if (f.keywords && /,\s/.test(f.keywords)) errors.push(`${locale}: keywords: no spaces after commas (they count)`);
|
||||
out[locale] = f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
//
|
||||
// Which profile a project uses (first hit): --account <name> / env STOREBOUND_PROFILE (set but empty = none), the
|
||||
// config's `account`, "account" in <project>/.storebound/credentials.json (written by `storebound accounts import`),
|
||||
// else the default: the only profile on this machine, or the one named "default".
|
||||
// else the default: the one `storebound accounts default <name>` set (the pointer file <credentials>/default-profile),
|
||||
// else the only profile on this machine, else the one named "default".
|
||||
//
|
||||
// One value, first hit wins:
|
||||
// 1. environment: PLAY_SERVICE_ACCOUNT (path to the JSON key, or the JSON itself), ASC_KEY_ID, ASC_ISSUER_ID,
|
||||
|
|
@ -22,7 +23,7 @@
|
|||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
|
||||
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
|
||||
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
|
||||
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
|
|
@ -78,8 +79,31 @@ export function profileNames(): string[] {
|
|||
return names.filter(n => PROFILE_RE.test(n)).sort();
|
||||
}
|
||||
|
||||
/** The default profile: the only one on this machine, else the one named "default"; null when that is not clear. */
|
||||
export function defaultProfile(names = profileNames()): string | null {
|
||||
/** The pointer file `storebound accounts default <name>` writes: one profile name, nothing else (never a key). */
|
||||
export const defaultPointerFile = (): string => join(profileDir(), 'default-profile');
|
||||
|
||||
/** The profile the pointer file names, when it is a valid name; null without one. Not checked against the folder. */
|
||||
export function defaultPointer(): string | null {
|
||||
try {
|
||||
const n = readFileSync(defaultPointerFile(), 'utf8').trim();
|
||||
return PROFILE_RE.test(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Sets (or with null clears) the default profile pointer. Throws on a name that is not a profile on this machine. */
|
||||
export function setDefaultPointer(name: string | null): string {
|
||||
const f = defaultPointerFile();
|
||||
if (name === null) { rmSync(f, { force: true }); return f; }
|
||||
if (!profileNames().includes(name)) throw new Error(`no profile "${name}" on this machine (${profileNames().join(', ') || 'none yet'}; storebound accounts lists them)`);
|
||||
mkdirSync(profileDir(), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(f, name + '\n', { mode: 0o600 });
|
||||
return f;
|
||||
}
|
||||
|
||||
/** The default profile: the one the pointer names (when it exists), else the only one on this machine, else the one
|
||||
* named "default"; null when that is not clear. */
|
||||
export function defaultProfile(names = profileNames(), pointer = defaultPointer()): string | null {
|
||||
if (pointer && names.includes(pointer)) return pointer;
|
||||
return names.length === 1 ? names[0]! : names.includes('default') ? 'default' : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -307,7 +307,7 @@ const fence = (t: string) => '```\n' + t + '\n```';
|
|||
const https = (u: string | undefined): string | undefined => (u && /^https:\/\/\S+$/.test(u.trim()) ? u.trim() : undefined);
|
||||
const YOUTUBE = /^https:\/\/(www\.youtube\.com\/watch\?v=[\w-]{6,}|youtu\.be\/[\w-]{6,})$/;
|
||||
|
||||
/** Keywords "a, b ,c" -> "a,b,c" (spaces after commas count against the 100 bytes on the App Store). */
|
||||
/** Keywords "a, b ,c" -> "a,b,c" (spaces after commas count against the 100 characters on the App Store). */
|
||||
export const normalizeKeywords = (k: string): string => k.split(/[,\n]/).map(x => x.trim()).filter(Boolean).join(',');
|
||||
|
||||
/** Image size, null when the header is not PNG / JPEG. */
|
||||
|
|
@ -453,7 +453,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
|
|||
add('promotionalText', f.promotionalText);
|
||||
if (f.keywords) {
|
||||
const k = normalizeKeywords(f.keywords);
|
||||
if (k !== f.keywords) warnings.push(`${l.locale}: keywords normalized (spaces after commas removed; they count against 100 bytes)`);
|
||||
if (k !== f.keywords) warnings.push(`${l.locale}: keywords normalized (spaces after commas removed; they count against 100 characters)`);
|
||||
add('keywords', k);
|
||||
}
|
||||
add('description', f.description);
|
||||
|
|
@ -472,7 +472,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
|
|||
|
||||
Imported from fastlane deliver (\`storebound import fastlane\`). Source for \`storebound apple listing\`. One
|
||||
\`## <ASC locale>\` section per locale, one fenced block per \`### <field>\`: \`name\` (optional, defaults to \`apple.name\`, else \`app.name\`),
|
||||
\`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 bytes, comma separated, no spaces after commas),
|
||||
\`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 characters, comma separated, no spaces after commas),
|
||||
\`description\` (4000), \`whatsNew\` (optional). Privacy policy, support and marketing URL come from
|
||||
\`storebound.config.ts\`.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
// (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+).
|
||||
import type { Finding, Level } from './validate-rules';
|
||||
import { mdTable, pct, textTable } from './table';
|
||||
import { reportingEnableUrl } from './store-apps';
|
||||
|
||||
export const PLAY_THRESHOLDS = { crash: 0.0109, anr: 0.0047, perDevice: 0.08 } as const;
|
||||
/** Warn when a rate reaches this share of its threshold. */
|
||||
|
|
@ -181,13 +182,14 @@ const levelFor = (v: number | null, limit: number): Level => (v === null ? 'ok'
|
|||
/** A Google 403 for an API that is not enabled in the caller's Cloud project. */
|
||||
export const REPORTING_OFF = /SERVICE_DISABLED|has not been used in project|accessNotConfigured/;
|
||||
|
||||
export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level } = {}): Finding[] {
|
||||
export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; principal?: string | null } = {}): Finding[] {
|
||||
const f: Finding[] = [];
|
||||
const play = h.play;
|
||||
if (play && 'error' in play) {
|
||||
const on = reportingEnableUrl(play.error, o.principal ?? null);
|
||||
f.push(REPORTING_OFF.test(play.error)
|
||||
? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: 'vitals not readable: the Google Play Developer Reporting API is off in the service account\'s Google Cloud project',
|
||||
fix: 'Google Cloud console > APIs & Services > Library: enable "Google Play Developer Reporting API" in that project, wait a few minutes (store-health skill)' }
|
||||
? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the Google Play Developer Reporting API is off in the service account's Google Cloud project${on.project ? ` ${on.project}` : ''}`,
|
||||
fix: `enable "Google Play Developer Reporting API" for that project: ${on.url} (Enable), wait a few minutes (store-health skill)` }
|
||||
: { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${play.error.slice(0, 200)}`, fix: 'store-health skill (Reporting API access)' });
|
||||
}
|
||||
else if (play) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// yours once per field. Every generated section carries a marker line until a human reviewed it:
|
||||
// <!-- storebound-translation from=en-US status=translate --> source text copied in, still to translate
|
||||
// <!-- storebound-translation from=en-US status=draft --> machine-translated: review, then `listing reviewed`
|
||||
// Limits per store field (characters; App Store keywords also 100 bytes, as the existing listing check counts them):
|
||||
// Limits per store field, in characters (App Store keywords too: live fields of 265 UTF-8 bytes are accepted):
|
||||
// App Store (appStoreVersionLocalizations / appInfoLocalizations): name 30, subtitle 30, promotionalText 170,
|
||||
// keywords 100, description 4000. https://developer.apple.com/help/app-store-connect/reference/app-information
|
||||
// Google Play (edits.listings): title 30, shortDescription 80, fullDescription 4000.
|
||||
|
|
@ -108,7 +108,7 @@ export const unmark = (sec: string): string => sec.replace(/^<!--\s*storebound-t
|
|||
// ---- keyword rules ----
|
||||
|
||||
/** App Store keywords: split on commas (also 、,،), trimmed, deduplicated (case-insensitive), words of the name /
|
||||
* subtitle dropped, joined with "," and cut at whole keywords to fit 100 characters and 100 bytes. Pure. */
|
||||
* subtitle dropped, joined with "," and cut at whole keywords to fit 100 characters. Pure. */
|
||||
export function normalizeKeywords(text: string, o: { name?: string; subtitle?: string; limit?: number } = {}): { text: string; dropped: { kw: string; why: string }[] } {
|
||||
const limit = o.limit ?? APPLE_LIMITS.keywords;
|
||||
const taken = new Set(`${o.name ?? ''} ${o.subtitle ?? ''}`.toLocaleLowerCase().split(/[\s,.:;!?·|/–-]+/).filter(w => w.length > 1));
|
||||
|
|
@ -123,14 +123,14 @@ export function normalizeKeywords(text: string, o: { name?: string; subtitle?: s
|
|||
seen.add(key);
|
||||
if (taken.has(key)) { dropped.push({ kw, why: 'already in the name or subtitle' }); continue; }
|
||||
const next = [...kept, kw].join(',');
|
||||
if (len(next) > limit || Buffer.byteLength(next) > limit) { dropped.push({ kw, why: `over ${limit}` }); continue; }
|
||||
if (len(next) > limit) { dropped.push({ kw, why: `over ${limit}` }); continue; }
|
||||
kept.push(kw);
|
||||
}
|
||||
return { text: kept.join(','), dropped };
|
||||
}
|
||||
|
||||
/** `chars` is what the store counts: characters, except App Store keywords, which App Store Connect limits in UTF-8 bytes
|
||||
* (`unit` says which). */
|
||||
/** `chars` is what the store counts: characters, App Store keywords included (App Store Connect counts characters,
|
||||
* not UTF-8 bytes: store-pitfalls 43). `unit` stays for readers of the JSON and is always 'chars'. */
|
||||
export interface FieldCheck { store: 'apple' | 'play'; field: string; chars: number; unit: 'chars' | 'bytes'; limit: number; problems: string[] }
|
||||
|
||||
/** Limits and rules of one translated section. `source` = the source language's fields (identical text is flagged). */
|
||||
|
|
@ -141,11 +141,11 @@ export function checkFields(store: 'apple' | 'play', f: Record<string, string |
|
|||
const v = f[k];
|
||||
if (v === undefined) continue;
|
||||
const limit = (FIELD_LIMITS[store] as Record<string, number>)[k]!;
|
||||
const bytes = store === 'apple' && k === 'keywords';
|
||||
const n = bytes ? Buffer.byteLength(v, 'utf8') : len(v);
|
||||
const c: FieldCheck = { store, field: k, chars: n, unit: bytes ? 'bytes' : 'chars', limit, problems: [] };
|
||||
if (n > limit) c.problems.push(`${n} > ${limit} ${bytes ? 'bytes' : 'characters'}`);
|
||||
if (bytes) {
|
||||
const isKw = store === 'apple' && k === 'keywords';
|
||||
const n = len(v);
|
||||
const c: FieldCheck = { store, field: k, chars: n, unit: 'chars', limit, problems: [] };
|
||||
if (n > limit) c.problems.push(`${n} > ${limit} characters`);
|
||||
if (isKw) {
|
||||
if (/,\s|\s,/.test(v)) c.problems.push('spaces around commas');
|
||||
const words = v.split(',').map(w => w.trim().toLocaleLowerCase()).filter(Boolean);
|
||||
const dup = words.filter((w, i) => words.indexOf(w) !== i);
|
||||
|
|
|
|||
304
cli/lib/onboard.ts
Normal file
304
cli/lib/onboard.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
// Onboarding an app that is already live: everything the stores and the code can prove goes into the config, so the
|
||||
// owner is asked only for what nothing proves. Pure parts of `storebound onboard` (cli/onboard.ts reads the stores):
|
||||
// - importCatalog: the live one-time products and subscriptions of both stores (ids, type, listings per language,
|
||||
// the price in USD: the US price, Apple's base territory as an override when it is not the USA) as the config's
|
||||
// products / subscriptions, so `storebound catalog diff` comes out clean. Store prices are never changed.
|
||||
// - contactProposal: app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app
|
||||
// details and App Store Connect, replacing only template placeholders; the App Review contact for
|
||||
// .storebound/credentials.json (personal data: masked in every output).
|
||||
// - signInDecision: reviewAccess.signInRequired from Apple's review detail and the code's auth signals, else a
|
||||
// question for the owner.
|
||||
// - deletionCandidates / findDeletionUrl: an account deletion page on the app's own website, never invented.
|
||||
import { type Language, playCodes, type Product, type ProductType, type ShipConfig, type Subscription, type SubscriptionGroup, type SubscriptionPeriod } from './config';
|
||||
import type { StoreItem } from './catalog';
|
||||
import { isPlaceholder, maskUser, type ReviewContact } from './credentials';
|
||||
import type { RemoteCatalog } from './listing-pull';
|
||||
|
||||
type Store = 'apple' | 'play';
|
||||
|
||||
export interface ImportNote { id: string; level: 'info' | 'warn'; msg: string }
|
||||
export interface CatalogImport {
|
||||
products: Product[];
|
||||
subscriptions: SubscriptionGroup[];
|
||||
/** ids added (not in the config before) / updated (price, type or listings changed) / kept as they were */
|
||||
added: string[];
|
||||
updated: string[];
|
||||
notes: ImportNote[];
|
||||
/** items that could not be written, with the reason (only when no store has a usable price or text) */
|
||||
skipped: { id: string; why: string }[];
|
||||
}
|
||||
|
||||
const cents = (n: number) => Math.round(n * 100) / 100;
|
||||
const langOf = (langs: Language[], store: Store, code: string) => langs.find(l => (store === 'apple' ? l.apple === code : playCodes(l).includes(code)))?.id;
|
||||
const PERIODS: SubscriptionPeriod[] = ['P1W', 'P1M', 'P2M', 'P3M', 'P6M', 'P1Y'];
|
||||
|
||||
/** The USD price a store item shows today: the US region, else the store's own USD price for other regions. */
|
||||
export function usdOf(i: StoreItem | undefined): number | undefined {
|
||||
if (!i) return undefined;
|
||||
const us = i.prices.find(p => p.region === 'US' && p.currency === 'USD');
|
||||
return us?.price ?? i.usd;
|
||||
}
|
||||
|
||||
/**
|
||||
* The config's products and subscriptions from what both stores sell today. Items in the config keep everything that
|
||||
* is not on the store (review screenshots, notes, offers); their USD price, type, platforms and listings follow the
|
||||
* store. An item on one store only is kept and marked for that store (`platforms`). Pure.
|
||||
*/
|
||||
export function importCatalog(config: Pick<ShipConfig, 'languages' | 'products' | 'subscriptions' | 'apple' | 'play'>,
|
||||
s: { apple: StoreItem[] | null; play: StoreItem[] | null; appleTexts?: RemoteCatalog; playTexts?: RemoteCatalog }): CatalogImport {
|
||||
const out: CatalogImport = { products: [], subscriptions: [], added: [], updated: [], notes: [], skipped: [] };
|
||||
const note = (id: string, msg: string, level: ImportNote['level'] = 'info') => out.notes.push({ id, level, msg });
|
||||
const both = !!config.apple && !!config.play;
|
||||
const byId = (xs: StoreItem[] | null) => new Map((xs ?? []).map(x => [x.id, x] as const));
|
||||
const A = byId(s.apple), P = byId(s.play);
|
||||
const oldProducts = new Map((config.products ?? []).map(p => [p.id, p] as const));
|
||||
const oldSubs = new Map((config.subscriptions ?? []).flatMap(g => g.subscriptions.map(x => [x.id, { group: g, sub: x }] as const)));
|
||||
const ids = [...new Set([...A.keys(), ...P.keys()])];
|
||||
const groups = new Map<string, SubscriptionGroup>();
|
||||
|
||||
for (const id of ids) {
|
||||
const a = A.get(id), p = P.get(id);
|
||||
const isSub = a ? a.kind === 'subscription' : p!.kind === 'subscription';
|
||||
if (a && p && (a.kind === 'subscription') !== (p.kind === 'subscription')) { out.skipped.push({ id, why: `a subscription on one store and a one-time product on the other: one id cannot be both (the stores keep it; fix by hand)` }); continue; }
|
||||
// price: US first (Apple's, else Play's), else the store's own USD price for new regions
|
||||
const aUsd = usdOf(a), pUsd = usdOf(p);
|
||||
const usd = aUsd ?? pUsd;
|
||||
if (usd === undefined) {
|
||||
const base = a?.base ?? p?.prices[0];
|
||||
out.skipped.push({ id, why: `no USD price on ${a && p ? 'either store' : a ? 'the App Store' : 'Google Play'}${base ? ` (base ${base.region} ${base.price} ${base.currency})` : ''}: the config prices in USD, so it is not written (catalog diff lists it as on the store only)` });
|
||||
continue;
|
||||
}
|
||||
if (aUsd !== undefined && pUsd !== undefined && cents(aUsd) !== cents(pUsd))
|
||||
note(id, `US price ${aUsd.toFixed(2)} on the App Store, ${pUsd.toFixed(2)} on Google Play: usd follows the App Store; catalog diff shows the Play difference (store prices are never changed here)`, 'warn');
|
||||
// Apple's base territory (not the USA): kept as an override when every store that sells the item has that price
|
||||
const overrides: { region: string; currency: string; price: number }[] = [];
|
||||
if (a?.base && a.base.region !== 'US') {
|
||||
const same = !p || p.prices.some(x => x.region === a.base!.region && x.currency === a.base!.currency && cents(x.price) === cents(a.base!.price));
|
||||
if (same) overrides.push({ region: a.base.region, currency: a.base.currency, price: a.base.price });
|
||||
else note(id, `App Store base territory ${a.base.region} (${a.base.price} ${a.base.currency}) differs from Google Play there: not kept as an override`, 'warn');
|
||||
}
|
||||
// listings: the config's, then Play's texts, then Apple's (Apple's limits are the config's)
|
||||
const old = isSub ? oldSubs.get(id)?.sub : oldProducts.get(id);
|
||||
const listings: Record<string, { title: string; description: string; benefits?: string[] }> = {};
|
||||
for (const [lang, l] of Object.entries(old?.listings ?? {})) listings[lang] = { ...l };
|
||||
for (const store of ['play', 'apple'] as const) {
|
||||
const texts = (store === 'apple' ? s.appleTexts : s.playTexts)?.[id] ?? {};
|
||||
for (const [code, t] of Object.entries(texts)) {
|
||||
const lang = langOf(config.languages, store, code);
|
||||
if (!lang) { note(id, `${store} language ${code} is not in languages: its texts are left out`); continue; }
|
||||
if (!t.title) continue;
|
||||
if ([...t.title].length > 30) { note(id, `${store} ${code}: title "${t.title}" is over the config's 30 characters: kept the ${listings[lang] ? 'other store\'s' : 'store'} text out of the config`, 'warn'); continue; }
|
||||
listings[lang] = { ...listings[lang], title: t.title, description: t.description || listings[lang]?.description || '', ...(store === 'play' && t.benefits?.length ? { benefits: t.benefits } : {}) };
|
||||
}
|
||||
}
|
||||
for (const [lang, l] of Object.entries(listings)) if (!l.description) { note(id, `${lang}: no description on the store: the title is used as the description`, 'warn'); l.description = l.title; }
|
||||
if (!Object.keys(listings).length) { out.skipped.push({ id, why: 'no title in any language of the config on the stores' }); continue; }
|
||||
const platforms = both && !(a && p) ? { platforms: [a ? 'ios' as const : 'android' as const] } : {};
|
||||
if (both && !(a && p)) note(id, `only on ${a ? 'the App Store' : 'Google Play'}: kept and marked platforms: ['${a ? 'ios' : 'android'}']`);
|
||||
if (isSub) {
|
||||
const period = (a?.period ?? p?.period) as SubscriptionPeriod | undefined;
|
||||
if (!period || !PERIODS.includes(period)) { out.skipped.push({ id, why: `billing period ${period ?? 'unknown'} is not one the config supports (${PERIODS.join(', ')})` }); continue; }
|
||||
if (a?.period && p?.period && a.period !== p.period) note(id, `bills every ${a.period} on the App Store, ${p.period} on Google Play: period follows the App Store`, 'warn');
|
||||
const prev = oldSubs.get(id);
|
||||
const sub: Subscription = { ...(prev?.sub ?? {}), id, period, level: a?.level ?? prev?.sub.level ?? 1, usd: cents(usd), listings, ...platforms,
|
||||
...(overrides.length || prev?.sub.overrides ? { overrides: mergeOverrides(prev?.sub.overrides, overrides) } : {}) } as Subscription;
|
||||
if (!both || (a && p)) delete sub.platforms;
|
||||
const gname = a?.group ?? prev?.group.name ?? 'Subscriptions';
|
||||
const g = groups.get(gname) ?? { ...(prev?.group ?? { name: gname, listings: {} }), name: gname, subscriptions: [] };
|
||||
if (!Object.keys(g.listings).length) {
|
||||
const first = Object.keys(listings)[0]!;
|
||||
g.listings = { [first]: { name: gname } };
|
||||
note(id, `group "${gname}": display name set to its reference name for ${first} (App Store group localizations are not read; edit if they differ)`);
|
||||
}
|
||||
g.subscriptions.push(sub);
|
||||
groups.set(gname, g);
|
||||
track(out, id, prev?.sub, sub);
|
||||
} else {
|
||||
const prev = oldProducts.get(id);
|
||||
let type: ProductType;
|
||||
if (a && a.kind !== 'one_time' && a.kind !== 'subscription') type = a.kind;
|
||||
else if (prev) type = prev.type;
|
||||
else { type = 'non_consumable'; note(id, 'Google Play does not say whether a one-time product is consumable: type non_consumable; change it to consumable if people buy it again and again', 'warn'); }
|
||||
const prod: Product = { ...(prev ?? {}), id, type, usd: cents(usd), listings: listings as Product['listings'], ...platforms,
|
||||
...(overrides.length || prev?.overrides ? { overrides: mergeOverrides(prev?.overrides, overrides) } : {}) } as Product;
|
||||
if (!both || (a && p)) delete prod.platforms;
|
||||
out.products.push(prod);
|
||||
track(out, id, prev, prod);
|
||||
}
|
||||
}
|
||||
// config items no store has (not created yet): kept as they are
|
||||
for (const p of config.products ?? []) if (!A.has(p.id) && !P.has(p.id)) { out.products.push(p); note(p.id, 'in the config, on no store yet: kept'); }
|
||||
for (const g of config.subscriptions ?? []) for (const x of g.subscriptions) if (!A.has(x.id) && !P.has(x.id)) {
|
||||
const grp = groups.get(g.name) ?? { ...g, subscriptions: [] };
|
||||
grp.subscriptions.push(x);
|
||||
groups.set(g.name, grp);
|
||||
note(x.id, 'in the config, on no store yet: kept');
|
||||
}
|
||||
out.subscriptions = [...groups.values()];
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeOverrides(prev: { region: string; currency: string; price: number; territory?: string }[] | undefined, add: { region: string; currency: string; price: number }[]) {
|
||||
const m = new Map((prev ?? []).map(o => [o.region, o] as const));
|
||||
for (const o of add) m.set(o.region, { ...m.get(o.region), ...o });
|
||||
return [...m.values()];
|
||||
}
|
||||
|
||||
function track(out: CatalogImport, id: string, prev: unknown, next: unknown): void {
|
||||
if (!prev) out.added.push(id);
|
||||
else if (JSON.stringify(prev) !== JSON.stringify(next)) out.updated.push(id);
|
||||
}
|
||||
|
||||
// ---- contact, URLs and the App Review contact ----
|
||||
|
||||
export interface StoreContact {
|
||||
play?: { contactEmail?: string; contactWebsite?: string; contactPhone?: string } | null;
|
||||
apple?: { supportUrl?: string; marketingUrl?: string; privacyPolicyUrl?: string; review?: AppleReviewDetail | null } | null;
|
||||
}
|
||||
/** appStoreReviewDetails attributes (App Store Connect), as read. */
|
||||
export interface AppleReviewDetail {
|
||||
contactFirstName?: string | null; contactLastName?: string | null; contactPhone?: string | null; contactEmail?: string | null;
|
||||
demoAccountName?: string | null; demoAccountPassword?: string | null; demoAccountRequired?: boolean | null; notes?: string | null;
|
||||
}
|
||||
|
||||
export interface FieldProposal { field: string; from: string | null; to: string; source: string; replace: boolean }
|
||||
|
||||
/** A value that is a template placeholder (example.com / example.org, "your-…"), or missing. */
|
||||
export const isTemplateValue = (v: string | undefined | null): boolean =>
|
||||
// the templates (`storebound init`) write example.com: https://example.com/..., support@example.com
|
||||
!v || isPlaceholder(v, { example: false }) || /^(https?:\/\/)?(www\.)?example\.com(\/|$)/i.test(v.trim()) || /@example\.com$/i.test(v.trim());
|
||||
|
||||
const httpsUrl = (u: string | undefined | null): string | null => {
|
||||
const s = (u ?? '').trim();
|
||||
if (!s) return null;
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`;
|
||||
return /^https:\/\/[^\s/]+\.[^\s/]+/i.test(withScheme) ? withScheme : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* app.* values from the stores. `replace` is true only where the config holds a template value (example.com,
|
||||
* support@example.com) or nothing; a real value that differs from the store is reported, never overwritten. Pure.
|
||||
*/
|
||||
export function contactProposal(app: Partial<ShipConfig['app']>, s: StoreContact): FieldProposal[] {
|
||||
const out: FieldProposal[] = [];
|
||||
const want = (field: keyof ShipConfig['app'], value: string | null | undefined, source: string) => {
|
||||
if (!value) return;
|
||||
if (out.some(o => o.field === field)) return;
|
||||
const cur = app[field] as string | undefined;
|
||||
if (cur === value) return;
|
||||
out.push({ field, from: cur ?? null, to: value, source, replace: isTemplateValue(cur) });
|
||||
};
|
||||
const email = s.play?.contactEmail?.trim();
|
||||
if (email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) want('contactEmail', email, 'Google Play app details (contactEmail)');
|
||||
want('website', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)');
|
||||
want('website', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)');
|
||||
want('supportUrl', httpsUrl(s.apple?.supportUrl), 'App Store version (supportUrl)');
|
||||
want('supportUrl', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)');
|
||||
want('privacyPolicyUrl', httpsUrl(s.apple?.privacyPolicyUrl), 'App Store app information (privacyPolicyUrl)');
|
||||
want('marketingUrl', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The App Review contact from Apple's review detail, for .storebound/credentials.json (personal data). */
|
||||
export function reviewContactOf(r: AppleReviewDetail | null | undefined): ReviewContact | null {
|
||||
if (!r?.contactEmail) return null;
|
||||
return { firstName: r.contactFirstName ?? '', lastName: r.contactLastName ?? '', phone: r.contactPhone ?? '', email: r.contactEmail };
|
||||
}
|
||||
|
||||
/** For logs: the name's initials, the masked e-mail, the phone as its last 2 digits only. Never the full values. */
|
||||
export function maskContact(c: ReviewContact): string {
|
||||
const digits = c.phone.replace(/\D/g, '');
|
||||
return `${c.firstName ? c.firstName[0] + '.' : ''}${c.lastName ? ` ${c.lastName[0]}.` : ''} ${maskUser(c.email)}${digits ? `, phone ***${digits.slice(-2)}` : ''}`.trim();
|
||||
}
|
||||
|
||||
// ---- sign-in for review ----
|
||||
|
||||
export interface SignInDecision {
|
||||
signInRequired: boolean | null;
|
||||
/** why, in one line */
|
||||
because: string;
|
||||
/** Apple's review detail holds a demo account (name + password): import it, never print it */
|
||||
demoAccount: { username: string; password: string } | null;
|
||||
/** the owner question, only when nothing proves it */
|
||||
question: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* reviewAccess.signInRequired from what is proven: Apple's live review detail (demoAccountRequired, set by whoever
|
||||
* submitted the app and accepted by App Review) wins; else no auth code in the project means no sign-in; else
|
||||
* it is an owner question. Never defaults to yes. Pure.
|
||||
*/
|
||||
export function signInDecision(o: { auth: string[]; apple: AppleReviewDetail | null | undefined; configured?: boolean }): SignInDecision {
|
||||
const r = o.apple;
|
||||
const demo = r?.demoAccountName && r.demoAccountPassword ? { username: r.demoAccountName, password: r.demoAccountPassword } : null;
|
||||
if (r && r.demoAccountRequired === true)
|
||||
return { signInRequired: true, because: `App Store review detail: demo account required${demo ? ' (a demo account is set there)' : ' (but none is set there)'}`, demoAccount: demo, question: null };
|
||||
if (r && r.demoAccountRequired === false)
|
||||
return { signInRequired: false, because: `App Store review detail: no demo account required${o.auth.length ? ` (the code has sign-in, ${o.auth[0]}, so it is optional for reviewers)` : ''}`, demoAccount: demo, question: null };
|
||||
if (!o.auth.length) return { signInRequired: false, because: 'no sign-in or account code in the project (scan auth signals)', demoAccount: demo, question: null };
|
||||
return { signInRequired: null, because: `the code has sign-in (${o.auth.slice(0, 2).join('; ')}) and no store says whether reviewers need it`, demoAccount: demo,
|
||||
question: 'Can a reviewer use every main feature without signing in? (no: reviewAccess.signInRequired true and a demo account in .storebound/credentials.json reviewDemoAccount)' };
|
||||
}
|
||||
|
||||
// ---- account deletion page ----
|
||||
|
||||
/** Pages to try on the app's own site, in order (Play's account deletion requirement: a web page to request it). */
|
||||
export function deletionCandidates(urls: (string | null | undefined)[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const u of urls) {
|
||||
let base: URL;
|
||||
try { base = new URL(u ?? ''); } catch { continue; }
|
||||
if (base.protocol !== 'https:' || isTemplateValue(base.href)) continue;
|
||||
for (const p of ['/delete-account', '/account/delete', '/delete', '/account-deletion', '/delete-account.html'])
|
||||
{ const c = new URL(p, base.origin).href; if (!out.includes(c)) out.push(c); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DELETE_WORDS = /delet|deletion|remove (my|your) (account|data)|hesab[ıi]n[ıi]? sil|konto l[öo]schen|supprim/i;
|
||||
|
||||
/** Links on a page (privacy policy) whose text or address speaks of deleting an account or data. Pure. */
|
||||
export function deletionLinks(html: string, pageUrl: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of html.matchAll(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)) {
|
||||
const href = m[1]!, text = m[2]!.replace(/<[^>]+>/g, ' ');
|
||||
if (!DELETE_WORDS.test(text) && !DELETE_WORDS.test(href)) continue;
|
||||
let abs: string;
|
||||
try { abs = new URL(href, pageUrl).href; } catch { continue; }
|
||||
if (!/^https:/.test(abs) || out.includes(abs)) continue;
|
||||
out.push(abs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DeletionResult { url: string | null; how: string; tried: string[] }
|
||||
/** GET of a page: status and text (a timeout or network error is status null). */
|
||||
export type PageGet = (url: string) => Promise<{ status: number | null; text: string; finalUrl?: string }>;
|
||||
|
||||
/**
|
||||
* Looks for an account deletion page: the usual paths on the website and the privacy policy's host, then links in the
|
||||
* privacy policy page that mention delete or deletion, then an anchor on the privacy page itself (#delete...). A
|
||||
* page counts only when it answers 200 and speaks of deleting. Never invents a URL.
|
||||
*/
|
||||
export async function findDeletionUrl(o: { website?: string | null; privacyUrl?: string | null }, get: PageGet): Promise<DeletionResult> {
|
||||
const tried: string[] = [];
|
||||
const ok = async (u: string) => {
|
||||
tried.push(u);
|
||||
const r = await get(u).catch(() => ({ status: null, text: '' }));
|
||||
return r.status === 200 && DELETE_WORDS.test(r.text.replace(/<script[\s\S]*?<\/script>/gi, '')) ? r : null;
|
||||
};
|
||||
for (const c of deletionCandidates([o.website, o.privacyUrl])) if (await ok(c)) return { url: c, how: `the page ${new URL(c).pathname} on the site answers and speaks of deletion`, tried };
|
||||
if (o.privacyUrl && !isTemplateValue(o.privacyUrl)) {
|
||||
tried.push(o.privacyUrl);
|
||||
const page = await get(o.privacyUrl).catch(() => ({ status: null, text: '' }));
|
||||
if (page.status === 200) {
|
||||
for (const l of deletionLinks(page.text, o.privacyUrl)) {
|
||||
if (l.split('#')[0] === o.privacyUrl.split('#')[0]) return { url: l, how: 'a section of the privacy policy linked as account / data deletion', tried };
|
||||
if (await ok(l)) return { url: l, how: 'linked from the privacy policy as account / data deletion', tried };
|
||||
}
|
||||
const anchor = /\bid\s*=\s*["']([^"']*delet[^"']*)["']/i.exec(page.text)?.[1];
|
||||
if (anchor) return { url: `${o.privacyUrl.split('#')[0]}#${anchor}`, how: 'a deletion section of the privacy policy page', tried };
|
||||
}
|
||||
}
|
||||
return { url: null, how: 'not found: a real gap, Play requires a web page to request account deletion (account-deletion skill)', tried };
|
||||
}
|
||||
|
|
@ -2,7 +2,16 @@
|
|||
// the Android manifest (source or the merged one of a local build) requests or removes the advertising ID permission,
|
||||
// and code that suggests App Privacy types without a Play Data safety equivalent (customer support, AR scanning).
|
||||
// Pure over the scan facts; null fields mean "not found", never "not scanned" (that is `undefined` signals).
|
||||
import { grep, type ProjectFacts, where } from './scan/project';
|
||||
import { grep, grepOwn, type ProjectFacts, where } from './scan/project';
|
||||
import { SIGNALS } from './scan/rules';
|
||||
|
||||
/** Sign-in evidence in the project's own code (scan's auth signals: auth SDKs and sign-in calls / fields / endpoints;
|
||||
* import lines and module paths never count): "file:line what" per hit, empty when the code has no sign-in. */
|
||||
export function authEvidence(p: ProjectFacts): string[] {
|
||||
const sdk = p.sdks.filter(s => s.sdk.category === 'auth').flatMap(s => s.deps.slice(0, 1).map(d => `${d.file}:${d.line} ${s.sdk.name}`));
|
||||
const code = grepOwn(p, SIGNALS.signIn, 3).hits.map(h => `${h.file}:${h.line}`);
|
||||
return [...sdk, ...code];
|
||||
}
|
||||
|
||||
export const AD_ID_PERMISSION = 'com.google.android.gms.permission.AD_ID';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
// the desktop's App folders screen add to it; `storebound apps folders` and the desktop show it; `storebound ws ...`
|
||||
// without a storebound.workspace.json works over it. The desktop keeps its per-folder extras (mapped accounts, saved
|
||||
// reads) in its own state by the same id. Only paths and names: no keys, nothing from inside the folders.
|
||||
// { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }] }
|
||||
// { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }],
|
||||
// "storeApps": [{ "id": "<16 hex>", "store": "apple", "appId": "6400000000", "account": "teamA", "name": "Demo App",
|
||||
// "bundleId": "com.example.demo", "added": "<iso>" }] }
|
||||
// storeApps: apps that are live on a store but have no project on this machine (`storebound apps add --store apple
|
||||
// --app-id <id> --account <profile>`). The desktop shows them read-only (status, reviews, analytics) under that
|
||||
// profile's account, noted "no local project"; they carry no path. Absent when there are none (older readers ignore it).
|
||||
// Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept).
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
|
|
@ -11,7 +16,10 @@ import { homedir } from 'node:os';
|
|||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
|
||||
export interface RegistryProject { id: string; path: string; name: string; added: string }
|
||||
export interface Registry { version: 1; projects: RegistryProject[] }
|
||||
/** A store app without a local project: store + app id (Apple: the numeric app id; Play: the package name), the
|
||||
* credential profile that reads it. */
|
||||
export interface StoreOnlyApp { id: string; store: 'apple' | 'play'; appId: string; account: string | null; name: string; bundleId: string | null; added: string }
|
||||
export interface Registry { version: 1; projects: RegistryProject[]; storeApps?: StoreOnlyApp[] }
|
||||
|
||||
export const registryFile = (env: Record<string, string | undefined> = process.env): string => env.STOREBOUND_REGISTRY || join(homedir(), '.storebound', 'projects.json');
|
||||
|
||||
|
|
@ -20,6 +28,26 @@ export const folderId = (absPath: string): string => createHash('sha256').update
|
|||
|
||||
const ID = /^[0-9a-f]{16}$/;
|
||||
|
||||
/** The id of a store-only entry: stable per store and app id. */
|
||||
export const storeAppId = (store: 'apple' | 'play', appId: string): string => createHash('sha256').update(`store:${store}:${appId}`).digest('hex').slice(0, 16);
|
||||
export const APPLE_APP_ID = /^\d{6,12}$/;
|
||||
export const PACKAGE_NAME = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
||||
|
||||
/** Store-only entries of the file's JSON; malformed ones are dropped. Pure. */
|
||||
function parseStoreApps(list: unknown): StoreOnlyApp[] {
|
||||
const out: StoreOnlyApp[] = [];
|
||||
if (Array.isArray(list)) for (const x of list) {
|
||||
const o = x as Partial<StoreOnlyApp>;
|
||||
if (o?.store !== 'apple' && o?.store !== 'play') continue;
|
||||
const appId = typeof o.appId === 'string' ? o.appId.trim() : '';
|
||||
if (!(o.store === 'apple' ? APPLE_APP_ID : PACKAGE_NAME).test(appId) || out.some(a => a.store === o.store && a.appId === appId)) continue;
|
||||
out.push({ id: storeAppId(o.store, appId), store: o.store, appId, account: typeof o.account === 'string' && o.account ? o.account : null,
|
||||
name: typeof o.name === 'string' && o.name.trim() ? o.name : appId, bundleId: typeof o.bundleId === 'string' && o.bundleId ? o.bundleId : o.store === 'play' ? appId : null,
|
||||
added: typeof o.added === 'string' ? o.added : '' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parses the file's text; drops entries that are not well formed (an id, an absolute path). Pure. */
|
||||
export function parseRegistry(text: string): Registry {
|
||||
let raw: unknown;
|
||||
|
|
@ -33,7 +61,8 @@ export function parseRegistry(text: string): Registry {
|
|||
out.push({ id: typeof o.id === 'string' && ID.test(o.id) && !out.some(x => x.id === o.id) ? o.id : folderId(o.path), path: o.path,
|
||||
name: typeof o.name === 'string' && o.name.trim() ? o.name : basename(o.path), added: typeof o.added === 'string' ? o.added : '' });
|
||||
}
|
||||
return { version: 1, projects: out };
|
||||
const storeApps = parseStoreApps((raw as { storeApps?: unknown })?.storeApps);
|
||||
return storeApps.length ? { version: 1, projects: out, storeApps } : { version: 1, projects: out };
|
||||
}
|
||||
|
||||
export function readRegistry(file = registryFile()): Registry {
|
||||
|
|
@ -48,7 +77,7 @@ export function writeRegistry(r: Registry, file = registryFile()): void {
|
|||
try { JSON.parse(readFileSync(file, 'utf8')); } catch { copyFileSync(file, `${file}.unreadable`); }
|
||||
}
|
||||
const tmp = `${file}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify({ version: 1, projects: r.projects }, null, 2) + '\n');
|
||||
writeFileSync(tmp, JSON.stringify({ version: 1, projects: r.projects, ...(r.storeApps?.length ? { storeApps: r.storeApps } : {}) }, null, 2) + '\n');
|
||||
renameSync(tmp, file);
|
||||
}
|
||||
|
||||
|
|
@ -58,14 +87,48 @@ export function withFolder(r: Registry, path: string, name: string | undefined,
|
|||
const have = r.projects.find(p => p.path === abs);
|
||||
if (have) return { registry: r, project: have, added: false };
|
||||
const project = { id: folderId(abs), path: abs, name: name?.trim() || basename(abs), added: now };
|
||||
return { registry: { version: 1, projects: [...r.projects, project] }, project, added: true };
|
||||
return { registry: { ...r, version: 1, projects: [...r.projects, project] }, project, added: true };
|
||||
}
|
||||
|
||||
/** Adds a store-only app (the same store + app id again updates its account, name and bundle id). Pure. */
|
||||
export function withStoreApp(r: Registry, a: { store: 'apple' | 'play'; appId: string; account?: string | null; name?: string; bundleId?: string | null }, now: string): { registry: Registry; app: StoreOnlyApp; added: boolean } {
|
||||
const appId = a.appId.trim();
|
||||
if (!(a.store === 'apple' ? APPLE_APP_ID : PACKAGE_NAME).test(appId)) throw new Error(a.store === 'apple' ? `--app-id ${appId}: the numeric Apple ID of the app (App Store Connect > App Information)` : `--app-id ${appId}: the Play package name (com.example.app)`);
|
||||
const list = r.storeApps ?? [];
|
||||
const have = list.find(x => x.store === a.store && x.appId === appId);
|
||||
const app: StoreOnlyApp = { id: storeAppId(a.store, appId), store: a.store, appId, account: a.account ?? have?.account ?? null, name: a.name?.trim() || have?.name || appId,
|
||||
bundleId: a.bundleId ?? have?.bundleId ?? (a.store === 'play' ? appId : null), added: have?.added ?? now };
|
||||
if (have && JSON.stringify(have) === JSON.stringify(app)) return { registry: r, app: have, added: false };
|
||||
return { registry: { ...r, version: 1, storeApps: [...list.filter(x => x !== have), app] }, app, added: !have };
|
||||
}
|
||||
|
||||
/** Removes a store-only app by id or "<store>:<app id>". Pure. */
|
||||
export function withoutStoreApp(r: Registry, key: string): { registry: Registry; removed: StoreOnlyApp | null } {
|
||||
const removed = (r.storeApps ?? []).find(x => x.id === key || `${x.store}:${x.appId}` === key) ?? null;
|
||||
if (!removed) return { registry: r, removed };
|
||||
const rest = (r.storeApps ?? []).filter(x => x !== removed);
|
||||
const { storeApps: _, ...base } = r;
|
||||
return { registry: rest.length ? { ...base, storeApps: rest } : base, removed };
|
||||
}
|
||||
|
||||
export function addStoreApp(a: Parameters<typeof withStoreApp>[1], file = registryFile()): { app: StoreOnlyApp; added: boolean; changed: boolean } {
|
||||
const cur = readRegistry(file);
|
||||
const { registry, app, added } = withStoreApp(cur, a, new Date().toISOString());
|
||||
if (registry !== cur) writeRegistry(registry, file);
|
||||
return { app, added, changed: registry !== cur };
|
||||
}
|
||||
|
||||
export function removeStoreApp(key: string, file = registryFile()): StoreOnlyApp | null {
|
||||
const { registry, removed } = withoutStoreApp(readRegistry(file), key);
|
||||
if (removed) writeRegistry(registry, file);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Removes a folder by path or id. Pure. */
|
||||
export function withoutFolder(r: Registry, pathOrId: string): { registry: Registry; removed: RegistryProject | null } {
|
||||
const abs = ID.test(pathOrId) ? '' : resolve(pathOrId);
|
||||
const removed = r.projects.find(p => p.id === pathOrId || p.path === abs) ?? null;
|
||||
return { registry: removed ? { version: 1, projects: r.projects.filter(p => p !== removed) } : r, removed };
|
||||
return { registry: removed ? { ...r, version: 1, projects: r.projects.filter(p => p !== removed) } : r, removed };
|
||||
}
|
||||
|
||||
/** Reads, adds and writes (only when it changed). */
|
||||
|
|
|
|||
|
|
@ -44,15 +44,17 @@ export function toTs(v: unknown, ind = ''): string {
|
|||
return one.length <= 110 && !one.includes('\n') && entries.every(([, x]) => typeof x !== 'object' || Array.isArray(x)) ? one : `{\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}}`;
|
||||
}
|
||||
|
||||
/** The config text with `key` set to `value` (plus an optional comment line above it). */
|
||||
/** The config text with the top-level `key` set to `value` (an object, or an array: `products`, `subscriptions`;
|
||||
* plus an optional comment line above it when it is new). The `key:` with the smallest indent is the top-level one. */
|
||||
export function upsertBlock(text: string, file: string, key: string, value: unknown, comment?: string): string {
|
||||
if (file.endsWith('.json')) {
|
||||
const j = JSON.parse(text) as Record<string, unknown>;
|
||||
j[key] = value;
|
||||
return JSON.stringify(j, null, 2) + '\n';
|
||||
}
|
||||
const re = new RegExp(`^([ \\t]*)${key}\\s*:\\s*\\{`, 'm');
|
||||
const m = re.exec(text);
|
||||
const re = new RegExp(`^([ \\t]*)${key}\\s*:\\s*[{[]`, 'gm');
|
||||
let m: RegExpExecArray | null = null;
|
||||
for (let x = re.exec(text); x; x = re.exec(text)) if (!m || x[1]!.length < m[1]!.length) m = x;
|
||||
if (m) {
|
||||
const ind = m[1]!;
|
||||
const open = m.index + m[0].length - 1;
|
||||
|
|
|
|||
|
|
@ -146,6 +146,32 @@ export function appleCheck(outcome: { apps: number } | { error: unknown }): Acco
|
|||
return { store: 'ios', status: 'error', principal: null, apps: null, message: `Could not reach App Store Connect: ${msg}`, fix: 'Check the connection and try again.', docs: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to switch the Play Developer Reporting API on for the caller's Cloud project: the project from Google's 403
|
||||
* ("has not been used in project 123…", consumer "projects/123…", activationUrl "?project=…"), else from the service
|
||||
* account e-mail (<name>@<project-id>.iam.gserviceaccount.com). Pure.
|
||||
*/
|
||||
export function reportingEnableUrl(errorText: string, principal: string | null): { project: string | null; url: string } {
|
||||
const project = /[?&]project=([a-z0-9-]+)/i.exec(errorText)?.[1] ?? /projects\/([a-z0-9-]+)/i.exec(errorText)?.[1] ?? /in project ([a-z0-9-]+)/i.exec(errorText)?.[1]
|
||||
?? (principal ? /@([a-z][a-z0-9-]{4,61}[a-z0-9])\.iam\.gserviceaccount\.com$/.exec(principal)?.[1] : undefined) ?? null;
|
||||
return { project, url: `${REPORTING_API_URL}${project ? `?project=${project}` : ''}` };
|
||||
}
|
||||
|
||||
/** Is this error Google's "API not enabled in the Cloud project" 403 (SERVICE_DISABLED)? */
|
||||
export const isServiceDisabled = (e: unknown): boolean => {
|
||||
const full = String((e as Error)?.message ?? e);
|
||||
return statusOf(e) === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full);
|
||||
};
|
||||
|
||||
/** Play apps known on this machine without the Reporting API: the package names of the app folders' configs and the
|
||||
* store-only Play entries of ~/.storebound/projects.json. Unverified on the store (marked in `sku`). Pure. */
|
||||
export function localPlayApps(known: { packageName: string; name: string; source: string }[]): StoreApp[] {
|
||||
const out: StoreApp[] = [];
|
||||
for (const k of known) if (k.packageName && !out.some(a => a.id === k.packageName))
|
||||
out.push({ store: 'android', id: k.packageName, bundleId: k.packageName, name: k.name || k.packageName, platforms: ['ANDROID'], sku: null, primaryLocale: null, latest: null, icon: null });
|
||||
return out.sort((x, y) => x.name.localeCompare(y.name));
|
||||
}
|
||||
|
||||
/** Play service account health from the token exchange and an `apps:search` outcome. */
|
||||
export function playCheck(principal: string | null, outcome: { apps: number } | { error: unknown }): AccountCheck {
|
||||
const who = principal ?? 'the service account';
|
||||
|
|
@ -156,9 +182,11 @@ export function playCheck(principal: string | null, outcome: { apps: number } |
|
|||
: { store: 'android', status: 'permission', principal, apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: invite, docs: PLAY_USERS_URL };
|
||||
}
|
||||
const code = statusOf(outcome.error), msg = short(outcome.error), full = String((outcome.error as Error)?.message ?? outcome.error);
|
||||
if (code === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full))
|
||||
return { store: 'android', status: 'api-disabled', principal, apps: null, message: 'The Google Play Developer Reporting API is off in this service account\'s Google Cloud project.',
|
||||
fix: 'Google Cloud console > APIs & Services > Library: enable "Google Play Developer Reporting API" (and "Google Play Android Developer API") in the service account\'s project, wait a few minutes, test again.', docs: REPORTING_API_URL };
|
||||
if (code === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full)) {
|
||||
const on = reportingEnableUrl(full, principal);
|
||||
return { store: 'android', status: 'api-disabled', principal, apps: null, message: `The Google Play Developer Reporting API is off in this service account's Google Cloud project${on.project ? ` (${on.project})` : ''}.`,
|
||||
fix: `Enable "Google Play Developer Reporting API" for that project: ${on.url} (press Enable; also "Google Play Android Developer API" if it is off), wait a few minutes, test again.`, docs: on.url };
|
||||
}
|
||||
if (code === null && /invalid_grant|Invalid JWT|account not found|disabled/i.test(full))
|
||||
return { store: 'android', status: 'revoked', principal, apps: null, message: `Google refused the key: ${msg}`,
|
||||
fix: 'The JSON key was deleted or the service account disabled. Google Cloud console > IAM & Admin > Service accounts > Keys: add a new JSON key, then add it here again.', docs: null };
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@
|
|||
// these functions turn it into an ordered list of findings. Order = the order you would fix things in.
|
||||
|
||||
export type Level = 'blocker' | 'warn' | 'ok';
|
||||
|
||||
/** Screenshot sets App Store Connect takes instead of a required one (App Store Connect Help, "Screenshot
|
||||
* specifications", checked 2026-10-13): iPhone needs the 6.9" set or, without it, the 6.5" set; iPad needs the 13" set
|
||||
* (either 13" display type). The smaller sizes are scaled down from these. A live app with only the 6.5" set is fine
|
||||
* (store-pitfalls 44). */
|
||||
export const ACCEPTED_INSTEAD: Record<string, string[]> = {
|
||||
APP_IPHONE_67: ['APP_IPHONE_65'],
|
||||
APP_IPHONE_65: ['APP_IPHONE_67'],
|
||||
APP_IPAD_PRO_3GEN_129: ['APP_IPAD_PRO_129'],
|
||||
APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'],
|
||||
};
|
||||
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string }
|
||||
|
||||
export interface UrlCheck { url: string; status: number | null; error?: string; body?: string }
|
||||
|
|
@ -154,7 +165,12 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
for (const l of s.locales) {
|
||||
const miss = (['description', 'keywords', 'subtitle'] as const).filter(k => !l[k]);
|
||||
if (miss.length) add('blocker', 'metadata', `${l.locale}: missing ${miss.join(', ')}`, 'store/appstore.md, then storebound apple listing');
|
||||
for (const t of s.requiredDisplayTypes) if (!l.screenshots[t]) add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots`, 'storebound apple screenshots');
|
||||
for (const t of s.requiredDisplayTypes) {
|
||||
if (l.screenshots[t]) continue;
|
||||
const alt = (ACCEPTED_INSTEAD[t] ?? []).find(x => l.screenshots[x]);
|
||||
if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots');
|
||||
else add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots${ACCEPTED_INSTEAD[t] ? ` (or ${ACCEPTED_INSTEAD[t]!.join(' / ')})` : ''}`, 'storebound apple screenshots');
|
||||
}
|
||||
}
|
||||
if (s.locales.length && !f.some(x => x.code === 'metadata' || x.code === 'screenshots' || x.code === 'locale')) add('ok', 'metadata', `${s.locales.length} locales with text and screenshots`);
|
||||
|
||||
|
|
@ -209,14 +225,27 @@ export function playRules(s: PlaySnapshot): Finding[] {
|
|||
const f: Finding[] = [];
|
||||
const add = (level: Level, code: string, msg: string, fix?: string) => f.push({ store: 'play', level, code, msg, fix });
|
||||
const have = new Map(s.listings.map(l => [l.language, l] as const));
|
||||
const fallback: Record<string, string[]> = {};
|
||||
if (!have.has(s.defaultLanguage)) add('blocker', 'listing', `no listing for the default language ${s.defaultLanguage}`, 'storebound play listing');
|
||||
for (const lang of s.expectedLanguages) if (!have.has(lang)) add('blocker', 'listing', `${lang}: no listing`, 'storebound play listing');
|
||||
for (const l of s.listings) {
|
||||
const miss = (['title', 'shortDescription', 'fullDescription'] as const).filter(k => !l[k]);
|
||||
if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing');
|
||||
for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) if ((l.images[type] ?? 0) < n)
|
||||
for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) {
|
||||
if ((l.images[type] ?? 0) >= n) continue;
|
||||
// Play shows the default language's graphics in a language that has none of its own (store-pitfalls 45): only
|
||||
// the default language's missing graphics block; a language with fewer than needed but more than none is
|
||||
// a real gap (Play takes them as they are).
|
||||
const def = have.get(s.defaultLanguage);
|
||||
if (l.language !== s.defaultLanguage && !(l.images[type] ?? 0) && def && (def.images[type] ?? 0) >= n) {
|
||||
(fallback[type] ??= []).push(l.language);
|
||||
continue;
|
||||
}
|
||||
add('blocker', 'images', `${l.language}: ${type} ${l.images[type] ?? 0}/${n}${PLAY_TABLET_IMAGES[type] ? ' (play.tablets is on)' : ''}`, 'storebound play images');
|
||||
}
|
||||
}
|
||||
for (const [type, langs] of Object.entries(fallback))
|
||||
add('ok', 'images-fallback', `${type}: ${langs.length} language(s) without their own use the default language's (${s.defaultLanguage}) graphics: ${langs.slice(0, 6).join(', ')}${langs.length > 6 ? ' ...' : ''}`);
|
||||
if (s.listings.length && !f.some(x => x.code === 'listing' || x.code === 'images')) add('ok', 'listing', `${s.listings.length} listings with text and graphics`);
|
||||
|
||||
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
|
||||
|
|
|
|||
|
|
@ -328,6 +328,9 @@ export const TOOLS: ToolDef[] = [
|
|||
{ name: 'catalog_pull', title: 'Pull IAP and subscription texts', command: ['catalog', 'pull'], write: true, network: true, idempotent: true,
|
||||
description: 'Store titles and descriptions (Play: benefits) of in-app purchases and subscriptions vs the config listings, as a proposal diff. storebound.config.ts is never rewritten; with dry_run false the proposal is saved to .storebound/pull/catalog.json.',
|
||||
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||
{ name: 'onboard', title: 'Onboard a live app', command: ['onboard'], write: true, network: true, idempotent: true,
|
||||
description: 'For an app that is already live: reads the live products and subscriptions (USD / base prices), contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page from the stores, the code and the app\'s site, and lists the owner questions nothing proves. Read-only on the stores, never changes store prices. dry_run shows the plan; with dry_run false storebound.config.ts and .storebound/credentials.json are updated (personal data masked, never printed).',
|
||||
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||
{ name: 'whats_new_languages', title: 'Release notes per language', command: ['whats-new'], write: true, network: false, idempotent: true,
|
||||
description: 'Draft release notes from git into one file per language (store/whats-new/<language id>.md): the first language gets the draft, the others status=translate files for you to translate (keep the header line, set status=translated). Then whats_new_check and whats_new_apply. Local files only.',
|
||||
props: { languages: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated language ids or store codes, source language first' }, since: { type: 'string', description: 'Git tag or ref (default: latest tag)' } },
|
||||
|
|
@ -419,7 +422,7 @@ export const FORBIDDEN_ARGS = ['--write', '--yes', '--manual-release', '--cancel
|
|||
* `scan --write` (compliance answers into the config) stays unreachable: scan is not in this list.
|
||||
*/
|
||||
export const LOCAL_WRITE_TOOLS: Record<string, readonly string[]> = {
|
||||
apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'],
|
||||
apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'], onboard: ['--write'],
|
||||
};
|
||||
|
||||
export function inputSchema(t: ToolDef): Record<string, unknown> {
|
||||
|
|
|
|||
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