Console pages as absolute deep links: cli/lib/console-links.ts builds the URL of every App Store Connect / Play Console page from apple.appId, play.developerId and play.appId (app list / developer chooser when unknown); every plain 'App Store Connect > ...' / 'Play Console > ...' direction in cli/ and templates/ now carries it, validate / scan / declarations / release plan JSON carry url, the desktop shows an Open button; tests/console-links.test.ts guards it; AGENTS rule 11, pitfall 71

This commit is contained in:
CRTkafa 2026-10-15 19:55:37 +08:00
parent f898d642d0
commit e500e88e40
50 changed files with 381 additions and 92 deletions

View file

@ -1,6 +1,7 @@
// Store readiness rules for `storebound validate`. Pure: the command collects a snapshot from both APIs (read-only),
// these functions turn it into an ordered list of findings. Order = the order you would fix things in.
import { consoleLink, consoleUrl } from './console-links';
import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions';
export type Level = 'blocker' | 'warn' | 'ok';
@ -16,6 +17,8 @@ export const ACCEPTED_INSTEAD: Record<string, string[]> = {
APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'],
};
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string;
/** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */
url?: string;
/** The desktop's Fix button: a write fully determined by an existing command (lib/fix-actions.ts). */
action?: FixAction }
@ -181,7 +184,7 @@ export function copyrightRules(s: Pick<AppleSnapshot, 'version' | 'configCopyrig
export function appleRules(s: AppleSnapshot): Finding[] {
const f: Finding[] = [];
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}) });
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction, url?: string) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}), ...(url ? { url } : {}) });
const listing = s.version && EDITABLE.includes(s.version.state) ? fixAppleListing(s.version.versionString) : undefined;
if (!s.version) add('blocker', 'version', 'no App Store version being prepared', 'storebound apple listing --version x.y.z');
@ -230,11 +233,11 @@ export function appleRules(s: AppleSnapshot): Finding[] {
const { open, soft } = ageOpen(s.ageRating);
if (open.length) add('blocker', 'age-rating', `age rating unanswered: ${open.join(', ')}`, 'storebound apple appinfo');
else add('ok', 'age-rating', 'age rating answered');
if (soft.length) add('warn', 'age-rating', `age rating question not answered yet: ${soft.join(', ')} (App Store Connect > App Information > Age Rating)`);
if (soft.length) add('warn', 'age-rating', `age rating question not answered yet: ${soft.join(', ')} (${consoleLink('asc.age-rating')})`, undefined, undefined, consoleUrl('asc.age-rating'));
}
add(s.priceSchedule ? 'ok' : 'blocker', 'price', s.priceSchedule ? 'app price set' : 'no app price schedule', 'storebound apple appinfo');
add(s.availability ? 'ok' : 'blocker', 'availability', s.availability ? 'availability set' : 'no territory availability',
'storebound apple appinfo; if the API refuses the first availability record, set it once in App Store Connect > Pricing and Availability');
'storebound apple appinfo; if the API refuses the first availability record, set it once in ' + consoleLink('asc.pricing'));
const iaps = new Map(s.iaps.map(i => [i.productId, i] as const));
for (const id of s.expectedProducts) if (!iaps.has(id)) add('blocker', 'iap', `${id}: not on App Store Connect`, 'storebound apple iap');
@ -380,7 +383,7 @@ export function appleSubscriptionRules(s: Pick<AppleSnapshot, 'subscriptions' |
*/
export function profileRules(store: 'apple' | 'play', p: ProfileFacts, o: { reviewDetail?: AppleSnapshot['reviewDetail']; descriptions?: { locale: string; text: string }[] }): Finding[] {
const f: Finding[] = [];
const add = (level: Level, code: string, msg: string, fix?: string) => f.push({ store, level, code, msg, fix });
const add = (level: Level, code: string, msg: string, fix?: string, _action?: undefined, url?: string) => f.push({ store, level, code, msg, fix, ...(url ? { url } : {}) });
if (p.accounts) {
if (store === 'play') {
if (!p.deletionUrl) add('blocker', 'account-deletion', 'the app has accounts but accounts.deletionUrl is not set (Play Data safety asks for a delete-account URL)', 'account-deletion skill');
@ -393,7 +396,7 @@ export function profileRules(store: 'apple' | 'play', p: ProfileFacts, o: { revi
if (!p.demoAccountLocal) add('blocker', 'demo-account', 'sign-in required for review but no demo account in .storebound/credentials.json (reviewDemoAccount) or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD', 'review-access skill');
if (store === 'apple' && o.reviewDetail && !(o.reviewDetail.demoAccountRequired && o.reviewDetail.demoAccountName))
add('blocker', 'demo-account', 'App Review information has no demo account although the app needs a sign-in (Guideline 2.1)', 'storebound apple review-details');
if (store === 'play') add('warn', 'app-access', 'sign-in required: Play Console > App content > App access must list the demo credentials and steps (no API)', 'review-access skill');
if (store === 'play') add('warn', 'app-access', `sign-in required: ${consoleLink('play.app-access')} must list the demo credentials and steps (no API)`, 'review-access skill', undefined, consoleUrl('play.app-access'));
if (!f.some(x => x.code === 'demo-account')) add('ok', 'demo-account', 'demo account for review present');
}
if (p.subscriptions) {