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:
parent
f898d642d0
commit
e500e88e40
50 changed files with 381 additions and 92 deletions
|
|
@ -5,6 +5,7 @@
|
|||
// the App Store analytics snapshots (.storebound/analytics, impressions / downloads / conversion) and the screenshot
|
||||
// variants of the config. Output: the report, experiment ideas (suggestions only: nothing is started) and the next 3
|
||||
// concrete actions. No network, no writes.
|
||||
import { consoleLink } from './console-links';
|
||||
import type { AppleDay } from './analytics';
|
||||
import type { AsoFinding } from './aso';
|
||||
import { words } from './aso';
|
||||
|
|
@ -155,7 +156,7 @@ export function experimentIdeas(a: AsoAudit, i: AuditInput): Experiment[] {
|
|||
for (const p of a.play.slice(0, 1)) {
|
||||
out.push({ store: 'play', what: `short description variant (${p.lang})`,
|
||||
why: p.shortRoom > 30 ? `the short description uses ${p.shortChars}/80 characters` : !p.titleWordsInShort ? 'the short description shares no word with the title' : 'the short description is the first text on the listing',
|
||||
how: 'Play Console > Grow users > Store listing experiments (no API: storebound only prepares the text; the store-screenshots skill has the steps)' });
|
||||
how: `${consoleLink('play.listing-experiments')} (no API: storebound only prepares the text; the store-screenshots skill has the steps)` });
|
||||
if (i.variants.length) out.push({ store: 'play', what: `screenshot variant "${i.variants[0]}"`, why: 'graphics experiments need no new version',
|
||||
how: `storebound shots render --variant ${i.variants[0]} --size play, then upload it as the experiment's variant in Play Console` });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Per-project config (storebound.config.ts at the project root). Everything app-specific lives here; credentials never
|
||||
// do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional.
|
||||
import { consoleIdsFromConfig, consoleLink, setConsoleIds } from './console-links';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import * as rt from './rt';
|
||||
|
|
@ -1074,7 +1075,7 @@ export function validateConfig(c: unknown): string[] {
|
|||
e.push('play.video: a plain YouTube video URL (https://www.youtube.com/watch?v=... or https://youtu.be/...), no playlist, channel or extra parameters');
|
||||
e.push(...validatePlayBuild(cfg.play?.build), ...validateOriginality(cfg.originality));
|
||||
if (cfg.play?.reportsBucket && !/^(gs:\/\/)?pubsite_prod(_rev)?_\d+\/?$/.test(cfg.play.reportsBucket))
|
||||
e.push('play.reportsBucket: the Play reports bucket, e.g. "pubsite_prod_rev_<number>" (Play Console > Download reports > Copy Cloud Storage URI)');
|
||||
e.push(`play.reportsBucket: the Play reports bucket, e.g. "pubsite_prod_rev_<number>" (${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
|
||||
if (cfg.play?.targetAges !== undefined && !(Array.isArray(cfg.play.targetAges) && cfg.play.targetAges.length && cfg.play.targetAges.every(a => PLAY_AGE_GROUPS.includes(a))))
|
||||
e.push(`play.targetAges: a list of ${PLAY_AGE_GROUPS.join(' | ')}`);
|
||||
if (cfg.play?.category !== undefined) {
|
||||
|
|
@ -1096,7 +1097,7 @@ export function validateConfig(c: unknown): string[] {
|
|||
const an = cfg.apple.name;
|
||||
if (an !== undefined && (typeof an !== 'string' || !an.trim())) e.push('apple.name: the App Store name (text), or leave it out to use app.name');
|
||||
else if (an !== undefined && [...an].length > APPLE_NAME_MAX) e.push(`apple.name: ${[...an].length} characters > ${APPLE_NAME_MAX} (App Store limit)`);
|
||||
if (cfg.apple.appId && !/^\d+$/.test(cfg.apple.appId)) e.push('apple.appId: numeric Apple ID of the app (App Store Connect > App Information)');
|
||||
if (cfg.apple.appId && !/^\d+$/.test(cfg.apple.appId)) e.push(`apple.appId: numeric Apple ID of the app (${consoleLink('asc.app-info')})`);
|
||||
const enc = cfg.apple.usesNonExemptEncryption;
|
||||
if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false');
|
||||
if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67');
|
||||
|
|
@ -1230,6 +1231,7 @@ export async function loadConfig(explicit?: string, start = process.cwd()): Prom
|
|||
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));
|
||||
const root = dirname(path);
|
||||
setConfigAccount(root, config.account);
|
||||
setConsoleIds(consoleIdsFromConfig(config));
|
||||
return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
// https://support.google.com/googleplay/android-developer/answer/9859152 (create and set up your app)
|
||||
// https://support.google.com/googleplay/android-developer/answer/9859655 (content ratings)
|
||||
// https://support.google.com/googleplay/android-developer/answer/9867159 (target audience and content)
|
||||
import { consoleLink } from './console-links';
|
||||
import { AGE_RATING_NONE } from '../apple/appinfo';
|
||||
import { type AgeGroup, AGE_GROUPS, appleName, IARC_DETAIL_KEYS, IARC_DETAIL_SECTIONS, IARC_QUESTIONS, type IarcDetailSection, type IarcQuestion, profileOf, type ShipConfig } from './config';
|
||||
import { APPLE_PRIVACY, applePurpose } from './declarations';
|
||||
|
|
@ -91,11 +92,11 @@ export function formMeta(id: FormId, config: ShipConfig): FormMeta {
|
|||
'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.',
|
||||
] };
|
||||
case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Send changes for review', fillable: true,
|
||||
open: [pickApp, 'Policy > App content > Content ratings > Start (app-content/content-rating-overview > Start questionnaire)'],
|
||||
open: [pickApp, `${consoleLink('play.content-rating', '> Start questionnaire')}`],
|
||||
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it. Tick it before the run (page 1, under the email address and the category): Next does nothing until it is ticked (verified 2026-10-13).',
|
||||
'The Summary\'s button is "Save", not "Submit" (verified 2026-10-12). storebound presses it: it stores the rating declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
|
||||
case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Send changes for review', fillable: true,
|
||||
open: [pickApp, 'Policy > App content > Target audience and content > Start (or Manage); the Ads declaration is App content > Ads'],
|
||||
open: [pickApp, `${consoleLink('play.target-audience', '> Start (or Manage)')}; the Ads declaration is ${consoleLink('play.ads')}`],
|
||||
notes: ['storebound presses the last page\'s Save: it stores the declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// The names are modeled on Apple's and Google's public help pages (sources in console-answers.ts), not recorded from a
|
||||
// signed-in console: the exact App Privacy dialog titles, the IARC question wording and the Play category names could
|
||||
// not be verified without an account, which is why every field has candidates and the engine falls back on a miss.
|
||||
import { consoleLink } from './console-links';
|
||||
import { AGE_GROUPS, appleName, IARC_DETAIL_SECTIONS, type ShipConfig } from './config';
|
||||
import { AGE_LABELS, type Answer, APPLE_PURPOSES, type FormId, iarcSectionName } from './console-answers';
|
||||
|
||||
|
|
@ -483,7 +484,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
|||
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
|
||||
],
|
||||
'play-content-rating': [
|
||||
{ button: 'Start / Start questionnaire', class: 'navigation', where: 'App content > Content ratings (content-rating-overview): opens the questionnaire', auto: true },
|
||||
{ button: 'Start / Start questionnaire', class: 'navigation', where: `${consoleLink('play.content-rating')}: opens the questionnaire`, auto: true },
|
||||
{ button: 'Next', class: 'navigation', where: 'after the category, and from the questions to the Summary', auto: true },
|
||||
{ button: 'Save draft / Save (questionnaire page)', class: 'draft-save', where: 'questionnaire: stores the answers as the one draft of the app; nothing goes to IARC before the Summary\'s Save', auto: true },
|
||||
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound (the owner ticks it before the run: Next does nothing until it is ticked, verified 2026-10-13)', auto: false },
|
||||
|
|
|
|||
116
cli/lib/console-links.ts
Normal file
116
cli/lib/console-links.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Absolute deep links to App Store Connect and Play Console pages. Every message that sends the owner (or an agent) to a
|
||||
// console page goes through here: "App Information > Age Rating: https://appstoreconnect.apple.com/apps/<id>/...",
|
||||
// never plain directions alone (pitfall 71). With unknown ids the link falls back to the best page that exists (the
|
||||
// app list, the developer chooser), so a message always carries an absolute https URL.
|
||||
//
|
||||
// Ids: apple.appId, play.developerId and play.appId from the config (loadConfig calls setConsoleIds), or explicit ids.
|
||||
// URL shapes reused from console-forms / console-plan (app-dashboard, app-content/*, create-new-app, app-list) are
|
||||
// verified; the others follow the consoles' current routes (see UNVERIFIED in the tests' page list).
|
||||
|
||||
export const ASC = 'https://appstoreconnect.apple.com';
|
||||
export const PLAY_CONSOLE = 'https://play.google.com/console';
|
||||
|
||||
export interface ConsoleIds { appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null }
|
||||
|
||||
let current: ConsoleIds = {};
|
||||
/** Remember the loaded config's ids so every later message deep-links to this app. */
|
||||
export function setConsoleIds(ids: ConsoleIds): void { current = { ...ids }; }
|
||||
export function consoleIdsFromConfig(c: { apple?: { appId?: string }; play?: { developerId?: string; appId?: string } } | null | undefined): ConsoleIds {
|
||||
return { appleAppId: c?.apple?.appId, playDeveloperId: c?.play?.developerId, playAppId: c?.play?.appId };
|
||||
}
|
||||
|
||||
type AppleScope = 'app' | 'account';
|
||||
type PlayScope = 'app' | 'developer';
|
||||
interface ApplePage { store: 'apple'; label: string; scope: AppleScope; path: string }
|
||||
interface PlayPage { store: 'play'; label: string; scope: PlayScope; path: string }
|
||||
|
||||
/** Every console page storebound points to: readable path + URL path under the app (or account). */
|
||||
export const CONSOLE_PAGES = {
|
||||
// App Store Connect, per app (/apps/<appId>/...)
|
||||
'asc.apps': { store: 'apple', label: 'App Store Connect > Apps', scope: 'account', path: '/apps' },
|
||||
'asc.new-app': { store: 'apple', label: 'App Store Connect > Apps > + > New App', scope: 'account', path: '/apps' },
|
||||
'asc.app-info': { store: 'apple', label: 'App Store Connect > App Information', scope: 'app', path: '/distribution/info' },
|
||||
'asc.age-rating': { store: 'apple', label: 'App Store Connect > App Information > Age Rating', scope: 'app', path: '/distribution/info' },
|
||||
'asc.encryption': { store: 'apple', label: 'App Store Connect > App Information > App Encryption Documentation', scope: 'app', path: '/distribution/info' },
|
||||
'asc.app-privacy': { store: 'apple', label: 'App Store Connect > App Privacy', scope: 'app', path: '/distribution/privacy' },
|
||||
'asc.pricing': { store: 'apple', label: 'App Store Connect > Pricing and Availability', scope: 'app', path: '/distribution/pricing' },
|
||||
'asc.version': { store: 'apple', label: 'App Store Connect > the iOS version page', scope: 'app', path: '/distribution/ios/version/inflight' },
|
||||
'asc.app-review': { store: 'apple', label: 'App Store Connect > App Review', scope: 'app', path: '/distribution/reviewsubmissions' },
|
||||
'asc.testflight': { store: 'apple', label: 'App Store Connect > TestFlight', scope: 'app', path: '/testflight/ios' },
|
||||
'asc.iaps': { store: 'apple', label: 'App Store Connect > In-App Purchases', scope: 'app', path: '/distribution/iaps' },
|
||||
'asc.subscriptions': { store: 'apple', label: 'App Store Connect > Subscriptions', scope: 'app', path: '/distribution/subscriptions' },
|
||||
'asc.ppo': { store: 'apple', label: 'App Store Connect > Product Page Optimization', scope: 'app', path: '/distribution/product-page-optimization' },
|
||||
// App Store Connect, account
|
||||
'asc.payments': { store: 'apple', label: 'App Store Connect > Payments and Financial Reports', scope: 'account', path: '/itc/payments_and_financial_reports' },
|
||||
'asc.users': { store: 'apple', label: 'App Store Connect > Users and Access', scope: 'account', path: '/access/users' },
|
||||
'asc.keys': { store: 'apple', label: 'App Store Connect > Users and Access > Integrations > App Store Connect API', scope: 'account', path: '/access/integrations/api' },
|
||||
// Play Console, per app (/u/0/developers/<dev>/app/<app>/...)
|
||||
'play.dashboard': { store: 'play', label: 'Play Console > Dashboard', scope: 'app', path: 'app-dashboard' },
|
||||
'play.app-content': { store: 'play', label: 'Play Console > Policy and programs > App content', scope: 'app', path: 'app-content/overview' },
|
||||
'play.data-safety': { store: 'play', label: 'Play Console > App content > Data safety', scope: 'app', path: 'app-content/data-privacy-security' },
|
||||
'play.content-rating': { store: 'play', label: 'Play Console > App content > Content ratings', scope: 'app', path: 'app-content/content-rating-overview' },
|
||||
'play.target-audience': { store: 'play', label: 'Play Console > App content > Target audience and content', scope: 'app', path: 'app-content/target-audience-content' },
|
||||
'play.ads': { store: 'play', label: 'Play Console > App content > Ads', scope: 'app', path: 'app-content/ads-declaration' },
|
||||
'play.app-access': { store: 'play', label: 'Play Console > App content > App access', scope: 'app', path: 'app-content/testing-credentials' },
|
||||
'play.foreground-service': { store: 'play', label: 'Play Console > App content > Foreground service permissions', scope: 'app', path: 'app-content/foreground-services' },
|
||||
'play.sensitive-permissions': { store: 'play', label: 'Play Console > App content > Sensitive app permissions', scope: 'app', path: 'app-content/overview' },
|
||||
'play.health-apps': { store: 'play', label: 'Play Console > App content > Health apps', scope: 'app', path: 'app-content/overview' },
|
||||
'play.full-screen-intent': { store: 'play', label: 'Play Console > App content > Full-screen intent', scope: 'app', path: 'app-content/overview' },
|
||||
'play.store-listing': { store: 'play', label: 'Play Console > Store presence > Main store listing', scope: 'app', path: 'main-store-listing' },
|
||||
'play.store-settings': { store: 'play', label: 'Play Console > Store presence > Store settings', scope: 'app', path: 'store-settings' },
|
||||
'play.listing-experiments': { store: 'play', label: 'Play Console > Grow users > Store listing experiments', scope: 'app', path: 'store-listing-experiments' },
|
||||
'play.releases': { store: 'play', label: 'Play Console > Test and release', scope: 'app', path: 'releases/overview' },
|
||||
'play.production': { store: 'play', label: 'Play Console > Test and release > Production', scope: 'app', path: 'tracks/production' },
|
||||
'play.internal-testing': { store: 'play', label: 'Play Console > Test and release > Internal testing', scope: 'app', path: 'tracks/internal-testing' },
|
||||
'play.publishing': { store: 'play', label: 'Play Console > Publishing overview', scope: 'app', path: 'publishing' },
|
||||
'play.app-signing': { store: 'play', label: 'Play Console > Test and release > App integrity > Play app signing', scope: 'app', path: 'keymanagement' },
|
||||
'play.bundle-explorer': { store: 'play', label: 'Play Console > Test and release > App bundle explorer', scope: 'app', path: 'bundle-explorer-selector' },
|
||||
'play.products': { store: 'play', label: 'Play Console > Monetize with Play > Products > In-app products', scope: 'app', path: 'managed-products' },
|
||||
'play.subscriptions': { store: 'play', label: 'Play Console > Monetize with Play > Products > Subscriptions', scope: 'app', path: 'subscriptions' },
|
||||
'play.promo-codes': { store: 'play', label: 'Play Console > Monetize with Play > Promo codes', scope: 'app', path: 'promotions' },
|
||||
'play.vitals': { store: 'play', label: 'Play Console > Monitor and improve > Android vitals', scope: 'app', path: 'vitals/crashes' },
|
||||
'play.policy-status': { store: 'play', label: 'Play Console > Policy and programs > Policy status', scope: 'app', path: 'policy-center/issues' },
|
||||
'play.pre-registration': { store: 'play', label: 'Play Console > Test and release > Production > Countries / regions (pre-registration)', scope: 'app', path: 'tracks/production' },
|
||||
// Play Console, developer account (/u/0/developers/<dev>/...)
|
||||
'play.app-list': { store: 'play', label: 'Play Console > Home (All apps)', scope: 'developer', path: 'app-list' },
|
||||
'play.create-app': { store: 'play', label: 'Play Console > Home > Create app', scope: 'developer', path: 'create-new-app' },
|
||||
'play.reports-statistics': { store: 'play', label: 'Play Console > Download reports > Statistics', scope: 'developer', path: 'download-reports/statistics' },
|
||||
'play.reports-financial': { store: 'play', label: 'Play Console > Download reports > Financial', scope: 'developer', path: 'download-reports/financial' },
|
||||
'play.users': { store: 'play', label: 'Play Console > Users and permissions', scope: 'developer', path: 'users-and-permissions' },
|
||||
'play.api-access': { store: 'play', label: 'Play Console > Setup > API access', scope: 'developer', path: 'api-access' },
|
||||
'play.internal-sharing': { store: 'play', label: 'Play Console > Internal app sharing', scope: 'developer', path: 'internal-app-sharing' },
|
||||
} as const satisfies Record<string, ApplePage | PlayPage>;
|
||||
|
||||
export type ConsolePage = keyof typeof CONSOLE_PAGES;
|
||||
|
||||
/** The absolute URL of a console page. Unknown ids: the best page that exists (app list / developer chooser). */
|
||||
export function consoleUrl(page: ConsolePage, ids: ConsoleIds = current): string {
|
||||
const p: ApplePage | PlayPage = CONSOLE_PAGES[page];
|
||||
const id = (v: string | null | undefined) => v && /^\d+$/.test(v) ? v : null;
|
||||
if (p.store === 'apple') {
|
||||
if (p.scope === 'account') return `${ASC}${p.path}`;
|
||||
const app = id(ids.appleAppId ?? current.appleAppId);
|
||||
return app ? `${ASC}/apps/${app}${p.path}` : `${ASC}/apps`;
|
||||
}
|
||||
const dev = id(ids.playDeveloperId ?? current.playDeveloperId), app = id(ids.playAppId ?? current.playAppId);
|
||||
if (!dev) return PLAY_CONSOLE;
|
||||
const base = `${PLAY_CONSOLE}/u/0/developers/${dev}`;
|
||||
if (p.scope === 'developer') return `${base}/${p.path}`;
|
||||
return app ? `${base}/app/${app}/${p.path}` : `${base}/app-list`;
|
||||
}
|
||||
|
||||
/** True when the URL is the page itself (ids known), false when it is a fallback (app list, chooser). */
|
||||
export function consoleUrlExact(page: ConsolePage, ids: ConsoleIds = current): boolean {
|
||||
const u = consoleUrl(page, ids);
|
||||
return u !== PLAY_CONSOLE && !(CONSOLE_PAGES[page].scope === 'app' && (u === `${ASC}/apps` || u.endsWith('/app-list')));
|
||||
}
|
||||
|
||||
/** "Readable > path: https://..." (the path suffix, e.g. "> Copy Cloud Storage URI", is appended to the label). */
|
||||
export function consoleLink(page: ConsolePage, suffix = '', ids: ConsoleIds = current): string {
|
||||
return `${CONSOLE_PAGES[page].label}${suffix ? ` ${suffix}` : ''}: ${consoleUrl(page, ids)}`;
|
||||
}
|
||||
|
||||
/** Play track page: production / internal have their own page; other tracks open Test and release. */
|
||||
export function playTrackPage(track: string | undefined): ConsolePage {
|
||||
return track === 'production' ? 'play.production' : track === 'internal' ? 'play.internal-testing' : 'play.releases';
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
// The scripted engine (`console fill`) uses the same answers and click rules and stays an optional fast path. Pure.
|
||||
// UI facts marked "verified" were read on a real console (App Privacy, 2026-10-12, /apps/<id>/distribution/privacy);
|
||||
// everything else is modeled on Apple's and Google's help pages (sources in console-answers.ts): the agent adapts.
|
||||
import { consoleLink, consoleUrl } from './console-links';
|
||||
import { playCategoryOf, type ShipConfig } from './config';
|
||||
import type { ProjectSignals } from './project-signals';
|
||||
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers';
|
||||
|
|
@ -258,7 +259,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
|
|||
const collected = types.filter(a => a.value === true).map(a => a.id.slice(5));
|
||||
const steps: PlanStep[] = [];
|
||||
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
|
||||
add({ text: appId ? 'Open the App Privacy page of the app (deep link).' : 'Open App Store Connect > Apps > the app > App Privacy (sidebar).' });
|
||||
add({ text: appId ? 'Open the App Privacy page of the app (deep link).' : `Open App Store Connect > Apps > the app > App Privacy (sidebar): ${consoleUrl('asc.apps')}` });
|
||||
add({ text: 'First time: click "Get Started" and answer the Data Collection question; then Next.', click: ['Get Started'], answers: ['collects'] });
|
||||
add({ text: 'Data types: when the Data Types section does not list exactly the collected types below, click the "Edit" next to the "Data Types" heading (never the Privacy Policy Edit), tick exactly these types and untick the rest, then Save (a draft: nothing is public before Publish). Console labels: Purchase History shows as "Purchases", Other Data Types as "Other Data".', click: ['Edit', 'Save'], answers: ids(answers, a => a.id.startsWith('type:')) });
|
||||
for (const t of collected) {
|
||||
|
|
@ -328,9 +329,10 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
const steps: PlanStep[] = [];
|
||||
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
|
||||
const all = (pred: (a: PlanAnswer) => boolean) => ids(answers, pred);
|
||||
const planIds = { playDeveloperId: p.playDeveloperId ?? config.play?.developerId, playAppId: p.playAppId ?? config.play?.appId };
|
||||
switch (form) {
|
||||
case 'apple-app-record':
|
||||
add({ text: 'Open App Store Connect > Apps.' });
|
||||
add({ text: `Open ${consoleLink('asc.apps')}.` });
|
||||
add({ text: 'Click the add button (+, labelled "Add Apps") at the top left, then "New App" in the menu. The New App dialog opens.', click: ['+', 'New App'] });
|
||||
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access. Name and SKU: click the field and TYPE the text (your browser tool\'s type action, key by key); never set the value programmatically (form_input, el.value = …): the dialog then keeps an empty name / SKU (verified 2026-10-13). Read both fields back.', answers: all(() => true) });
|
||||
add({ text: 'Stop. Do NOT press Create: tell the owner to review and press Create (it creates the app record for good; the bundle id and SKU cannot change).' });
|
||||
|
|
@ -377,7 +379,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
};
|
||||
case 'play-content-rating': {
|
||||
const section = (sec: string) => all(a => a.id.startsWith(`d:${sec}:`));
|
||||
add({ text: 'Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire.', click: ['Start questionnaire'] });
|
||||
add({ text: `Open ${consoleLink('play.content-rating', '', planIds)}, then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire.`, click: ['Start questionnaire'] });
|
||||
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it. The owner ticked it before the run (owner batch); read its aria-checked. Still unticked: Next does nothing (verified 2026-10-13), so do not press it again and again: say once that the box is waiting for the owner and poll aria-checked every 5 s (up to 15 min). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
|
||||
add({ text: 'The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section\'s Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.', answers: all(a => a.id.startsWith('q:')) });
|
||||
add({ text: 'Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.', answers: section('violence') });
|
||||
|
|
@ -391,7 +393,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
start: playStart(form, config, p), steps,
|
||||
uiFacts: [
|
||||
'Verified 2026-10-12 (an agent with a browser tool on a real game):',
|
||||
'App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.',
|
||||
`App content > Content ratings is /app-content/content-rating-overview (${consoleUrl('play.content-rating', planIds)}); "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.`,
|
||||
'Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.',
|
||||
'Verified 2026-10-13: page 1\'s Next does nothing until the IARC Terms of Use box is ticked. So the box is in the ONE owner batch before the run (the owner ticks it on page 1 first), never a stop in the middle.',
|
||||
'The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound\'s: match by topic and the console\'s own labels.',
|
||||
|
|
@ -405,10 +407,10 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
|
|||
}
|
||||
case 'play-target-audience': {
|
||||
const teens = answers.filter(a => a.id.startsWith('age:')).every(a => a.status === 'certain' && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
|
||||
add({ text: 'Open App content > Target audience and content (/app-content/target-audience-content) > Start (or Manage).', click: ['Start'] });
|
||||
add({ text: `Open ${consoleLink('play.target-audience', '', planIds)} > Start (or Manage).`, click: ['Start'] });
|
||||
add({ text: 'Target age: tick exactly the age groups of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back). Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
|
||||
if (answers.some(a => a.id === 'appeal')) add({ text: `Appeal to children: pick the answer, then Next.${teens ? ' With 13+ age groups only the page skipped this question and went straight to the Summary (verified 2026-10-12): answer it only if it shows.' : ''}`, click: ['Next'], answers: ['appeal'] });
|
||||
add({ text: 'Ads (also App content > Ads, /app-content/ads-declaration): pick whether the app contains ads, if this section shows it.', answers: ['ads'] });
|
||||
add({ text: `Ads (also ${consoleLink('play.ads', '', planIds)}): pick whether the app contains ads, if this section shows it.`, answers: ['ads'] });
|
||||
add({ text: `Summary: press Save (the agent's button, a declaration save: Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, the owner's). Read the summary back.`, click: ['Save'] });
|
||||
add({ text: 'Verify: read the section\'s summary as text and pipe it to `storebound console verify play-target-audience --from-text -`.' });
|
||||
return {
|
||||
|
|
@ -433,7 +435,7 @@ const IARC_TERMS = 'Tick the IARC Terms of Use box';
|
|||
/** Where the IARC Terms box is (verified 2026-10-12 / 2026-10-13). */
|
||||
function iarcWhere(cfg: ShipConfig, p: PlanIds): string {
|
||||
const dev = p.playDeveloperId ?? cfg.play?.developerId, app = p.playAppId ?? cfg.play?.appId;
|
||||
const url = dev && app ? playAppPage(dev, app, 'app-content/content-rating-iarc-questionnaire') : '/app-content/content-rating-iarc-questionnaire';
|
||||
const url = dev && app ? playAppPage(dev, app, 'app-content/content-rating-iarc-questionnaire') : `${consoleUrl('play.content-rating', { playDeveloperId: dev, playAppId: app })} (then /app-content/content-rating-iarc-questionnaire)`;
|
||||
return `Play Console > the app > Policy and programs > App content > Content ratings > "Start questionnaire" (${url}), page 1: the IARC Terms of Use checkbox under the Email address and the Category. Tick it, leave the tab open; the agent fills the rest of page 1 and presses Next (Next does nothing until it is ticked).`;
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +444,7 @@ function ownerBatchOf(form: PlanFormId, cfg: ShipConfig, p: PlanIds, answers: Pl
|
|||
const out: Omit<OwnerStep, 'n'>[] = [];
|
||||
const open = answers.filter(a => (a.status === 'ask-owner' || a.status === 'owner-check') && !a.id.endsWith('credentials'));
|
||||
if (open.length) out.push({ when: 'before', kind: 'answers', text: `Answer ${open.length} question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: ${open.map(a => a.question).join('; ')}` });
|
||||
if (answers.some(a => a.status === 'ask-owner' && a.id.endsWith('credentials'))) out.push({ when: 'before', kind: 'credentials', text: 'Put the App Review demo account into .storebound/credentials.json (reviewDemoAccount: username, password) so the agent types it without printing it; or type the username and password into the Sign in details card yourself', where: 'App content > Sign in details > Add instructions' });
|
||||
if (answers.some(a => a.status === 'ask-owner' && a.id.endsWith('credentials'))) out.push({ when: 'before', kind: 'credentials', text: 'Put the App Review demo account into .storebound/credentials.json (reviewDemoAccount: username, password) so the agent types it without printing it; or type the username and password into the Sign in details card yourself', where: `${consoleLink('play.app-access', '> Add instructions')}` });
|
||||
const iarc = answers.some(a => a.status === 'agreement' && /iarcTerms$/.test(a.id));
|
||||
if (iarc) out.push({ when: 'before', kind: 'agreement', text: IARC_TERMS, where: iarcWhere(cfg, p) });
|
||||
if (form === 'play-app-record') {
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ function privacyPolicy(config: ShipConfig, p: PlanIds): Part {
|
|||
title: 'Play Console: Privacy policy (App content)',
|
||||
start: start(config, p, 'app-content/privacy-policy', appContentReach('Privacy policy')),
|
||||
steps: steps([
|
||||
{ text: 'Open App content > Privacy policy (/app-content/privacy-policy).' },
|
||||
{ text: `Open App content > Privacy policy: ${start(config, p, 'app-content/privacy-policy', []).url}` },
|
||||
{ text: 'Type the privacy policy URL into the Privacy policy URL field (replace what is there).', answers: ['url'] },
|
||||
{ text: DECLARATION_SAVE },
|
||||
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-privacy-policy --from-text -`.' },
|
||||
|
|
@ -176,7 +176,7 @@ function privacyPolicy(config: ShipConfig, p: PlanIds): Part {
|
|||
...declarationButtons('Privacy policy'),
|
||||
uiFacts: ['Verified 2026-10-12: the card\'s page is /app-content/privacy-policy (one URL field).', 'The URL must be public (no PDF, no sign-in): `storebound validate` checks it.', PLAY_MATERIAL_FACT],
|
||||
verify: pageVerify('play-privacy-policy', ['The field shows app.privacyPolicyUrl.']),
|
||||
guidance: ['Open: Policy and programs > App content > Privacy policy'],
|
||||
guidance: [`Open: Policy and programs > App content > Privacy policy: ${start(config, p, 'app-content/privacy-policy', []).url}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +195,7 @@ function adsDeclaration(config: ShipConfig, p: PlanIds): Part {
|
|||
title: 'Play Console: Ads (App content)',
|
||||
start: start(config, p, 'app-content/ads-declaration', appContentReach('Ads')),
|
||||
steps: steps([
|
||||
{ text: 'Open App content > Ads (/app-content/ads-declaration).' },
|
||||
{ text: `Open App content > Ads: ${start(config, p, 'app-content/ads-declaration', []).url}` },
|
||||
{ text: 'Pick the radio (JS click on the inner [role=radio]); read aria-checked back.', answers: ['ads'] },
|
||||
{ text: DECLARATION_SAVE },
|
||||
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-ads --from-text -`.' },
|
||||
|
|
@ -205,7 +205,7 @@ function adsDeclaration(config: ShipConfig, p: PlanIds): Part {
|
|||
...declarationButtons('Ads'),
|
||||
uiFacts: ['Verified 2026-10-12: /app-content/ads-declaration.', 'Rewarded ads count as ads.', PLAY_MATERIAL_FACT],
|
||||
verify: pageVerify('play-ads', ['The selected radio matches the sheet.']),
|
||||
guidance: ['Open: Policy and programs > App content > Ads'],
|
||||
guidance: [`Open: Policy and programs > App content > Ads: ${start(config, p, 'app-content/ads-declaration', []).url}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
|
|||
title: 'Play Console: Sign in details (App access)',
|
||||
start: start(config, p, 'app-content/testing-credentials', appContentReach('Sign in details')),
|
||||
steps: steps([
|
||||
{ text: 'Open App content > Sign in details (/app-content/testing-credentials).' },
|
||||
{ text: `Open App content > Sign in details: ${start(config, p, 'app-content/testing-credentials', []).url}` },
|
||||
{ text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] },
|
||||
ra?.signInRequired && { text: p.demoAccount
|
||||
? 'Restricted: press "Add instructions", type the instructions, then type the Username and Password from .storebound/credentials.json (reviewDemoAccount; click each field and type: never echo them in chat or logs).'
|
||||
|
|
@ -244,7 +244,7 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
|
|||
...declarationButtons('Sign in details', [{ button: 'Add instructions', class: 'navigation', where: 'Sign in details: opens the instructions dialog' }]),
|
||||
uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config, the plan or chat: the agent types them from .storebound/credentials.json (reviewDemoAccount) without printing them; without that entry the owner types them.', PLAY_MATERIAL_FACT],
|
||||
verify: pageVerify('play-sign-in-details', ['The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']),
|
||||
guidance: ['Open: Policy and programs > App content > Sign in details (App access)'],
|
||||
guidance: [`Open: Policy and programs > App content > Sign in details (App access): ${start(config, p, 'app-content/testing-credentials', []).url}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -292,7 +292,7 @@ function advertisingId(config: ShipConfig, p: PlanIds): Part {
|
|||
title: 'Play Console: Advertising ID (App content)',
|
||||
start: start(config, p, 'app-content/ad-id-declaration', appContentReach('Advertising ID')),
|
||||
steps: steps([
|
||||
{ text: 'Open App content > Advertising ID (/app-content/ad-id-declaration).' },
|
||||
{ text: `Open App content > Advertising ID: ${start(config, p, 'app-content/ad-id-declaration', []).url}` },
|
||||
{ text: 'Pick Yes or No (material radio: JS click on the inner [role=radio]).', answers: ['uses'] },
|
||||
uses.v === true && { text: 'Tick exactly the purposes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back).', answers: out.filter(a => a.id.startsWith('purpose')).map(a => a.id) },
|
||||
{ text: DECLARATION_SAVE },
|
||||
|
|
@ -302,7 +302,7 @@ function advertisingId(config: ShipConfig, p: PlanIds): Part {
|
|||
...declarationButtons('Advertising ID'),
|
||||
uiFacts: ['Verified 2026-10-12: /app-content/ad-id-declaration.', 'Yes needs the com.google.android.gms.permission.AD_ID permission in the merged manifest (ad SDKs usually add it); `storebound scan` reports the SDKs.', PLAY_MATERIAL_FACT],
|
||||
verify: pageVerify('play-advertising-id', ['The selected radio and the ticked purposes match the sheet.']),
|
||||
guidance: ['Open: Policy and programs > App content > Advertising ID'],
|
||||
guidance: [`Open: Policy and programs > App content > Advertising ID: ${start(config, p, 'app-content/ad-id-declaration', []).url}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +312,7 @@ function government(config: ShipConfig, p: PlanIds): Part {
|
|||
title: 'Play Console: Government apps (App content)',
|
||||
start: start(config, p, 'app-content/government-apps', appContentReach('Government apps')),
|
||||
steps: steps([
|
||||
{ text: 'Open App content > Government apps (/app-content/government-apps).' },
|
||||
{ text: `Open App content > Government apps: ${start(config, p, 'app-content/government-apps', []).url}` },
|
||||
{ text: 'Pick whether the app is developed by or on behalf of a government (material radio).', answers: ['government'] },
|
||||
{ text: DECLARATION_SAVE },
|
||||
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-government-apps --from-text -`.' },
|
||||
|
|
@ -322,7 +322,7 @@ function government(config: ShipConfig, p: PlanIds): Part {
|
|||
...declarationButtons('Government apps'),
|
||||
uiFacts: ['Verified 2026-10-12: /app-content/government-apps.', PLAY_MATERIAL_FACT],
|
||||
verify: pageVerify('play-government-apps', ['The selected answer matches the sheet.']),
|
||||
guidance: ['Open: Policy and programs > App content > Government apps'],
|
||||
guidance: [`Open: Policy and programs > App content > Government apps: ${start(config, p, 'app-content/government-apps', []).url}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -343,7 +343,7 @@ function featureCard(config: ShipConfig, p: PlanIds, kind: 'finance' | 'health')
|
|||
title: `Play Console: ${name} (App content)`,
|
||||
start: start(config, p, `app-content/${kind}`, appContentReach(name)),
|
||||
steps: steps([
|
||||
{ text: `Open App content > ${name} (/app-content/${kind}).` },
|
||||
{ text: `Open App content > ${name}: ${start(config, p, `app-content/${kind}`, []).url}` },
|
||||
{ text: 'Step 1: read it and press Next.', click: ['Next'] },
|
||||
{ text: `Last step: tick exactly the boxes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back)${fin ? '; no financial features: "My app doesn\'t provide any financial features"' : '; no health features: "Other" (as on the verified run) or the none option the page offers'}. Then Next if the page shows one.`, click: ['Next'], answers: out.map(a => a.id) },
|
||||
{ text: DECLARATION_SAVE },
|
||||
|
|
@ -353,7 +353,7 @@ function featureCard(config: ShipConfig, p: PlanIds, kind: 'finance' | 'health')
|
|||
...declarationButtons(name, [{ button: 'Next', class: 'navigation', where: `${name}: between the two steps` }]),
|
||||
uiFacts: [`Verified 2026-10-12: /app-content/${kind}, two steps; the last step holds ${fin ? '"My app doesn\'t provide any financial features"' : 'the health feature boxes, "Other" among them'}; then Next and Save.`, PLAY_MATERIAL_FACT],
|
||||
verify: pageVerify(form, ['The ticked boxes match the sheet.']),
|
||||
guidance: [`Open: Policy and programs > App content > ${name}`],
|
||||
guidance: [`Open: Policy and programs > App content > ${name}: ${start(config, p, `app-content/${kind}`, []).url}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// (App Privacy has no API at all: the owner enters it in App Store Connect), app info (categories, age rating,
|
||||
// content rights), the export compliance answer and the App Review details (presence only, masked). Every answer the
|
||||
// config does not prove is an "ask the owner" item, never a guess (AGENTS.md rule 5). Pure.
|
||||
import { consoleLink, consoleUrl } from './console-links';
|
||||
import { parseCsv } from './csv';
|
||||
import { type AccountSignUp, type DataSafety, type PlayPurpose, profileOf, type ShipConfig } from './config';
|
||||
import { CATEGORIES, DATA_DELETION_RESPONSE, dataDeletionResponse, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from './data-safety';
|
||||
|
|
@ -163,8 +164,8 @@ export function declarations(config: ShipConfig, inp: DeclarationInputs): Declar
|
|||
fromDataSafety: !!ds,
|
||||
} : null;
|
||||
if (apple) {
|
||||
if (!ds) ask.push({ area: 'appPrivacy', id: 'ap-unknown', text: 'App Privacy (App Store Connect > App Privacy) cannot be suggested without dataSafety answers in the config.', fix: 'Answer the data collection once in dataSafety; storebound then lists the matching App Privacy answers to enter.' });
|
||||
else ask.push({ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy for each type below (tracking also needs App Tracking Transparency in the app).' });
|
||||
if (!ds) ask.push({ area: 'appPrivacy', id: 'ap-unknown', text: `App Privacy (${consoleLink('asc.app-privacy')}) cannot be suggested without dataSafety answers in the config.`, url: consoleUrl('asc.app-privacy'), fix: 'Answer the data collection once in dataSafety; storebound then lists the matching App Privacy answers to enter.' });
|
||||
else ask.push({ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: `Answer both in ${consoleLink('asc.app-privacy')} for each type below (tracking also needs App Tracking Transparency in the app).`, url: consoleUrl('asc.app-privacy') });
|
||||
}
|
||||
|
||||
const info = config.apple?.info;
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ export interface PricesCurrent { csv: string; products: string[]; stores: ('appl
|
|||
export interface PriceDiffRow { product: string; store: 'apple' | 'play'; region: string; startDate?: string; currency: string; from: number | null; to: number; change: 'new' | 'change' | 'same' }
|
||||
export interface PricesDiff { csv: string; diff: PriceDiffRow[]; changed: string[] }
|
||||
/** One "ask the owner" item: the config does not prove the answer (AGENTS.md rule 5). */
|
||||
export interface DeclAsk { area: 'dataSafety' | 'appPrivacy' | 'appInfo' | 'encryption' | 'review'; id: string; text: string; fix: string }
|
||||
export interface DeclAsk { area: 'dataSafety' | 'appPrivacy' | 'appInfo' | 'encryption' | 'review'; id: string; text: string; fix: string; url?: string }
|
||||
/** project.declarations (`inspect --declarations --json`): store declarations from the config and local files. */
|
||||
export interface DeclarationsResult {
|
||||
profile: string;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,7 @@
|
|||
// run, only plain `key("value")` lines are taken, everything else is reported as "set by hand".
|
||||
// Secrets and personal data never enter the plan's files: key files are named as references to move into .storebound/,
|
||||
// App Review contact and demo account files are reported (the owner copies them), team / app ids become placeholders.
|
||||
import { consoleLink } from './console-links';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
||||
import { imageInfo } from '../shots/spec';
|
||||
|
|
@ -426,7 +427,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
|
|||
else notMapped.push({ what: `${f}: "${raw}"`, where: 'apple.info.categories in storebound.config.ts (App Store Connect ids such as GAMES, PRODUCTIVITY, GAMES_PUZZLE)' });
|
||||
}
|
||||
for (const f of ['secondary_first_sub_category', 'secondary_second_sub_category'])
|
||||
if (scan.deliver.root[f]) notMapped.push({ what: `${f}.txt`, where: 'set in App Store Connect > App Information (storebound sets primary sub-categories only)' });
|
||||
if (scan.deliver.root[f]) notMapped.push({ what: `${f}.txt`, where: `set in ${consoleLink('asc.app-info')} (storebound sets primary sub-categories only)` });
|
||||
const profile: AppProfile = o.profile ?? (cats.primary === 'GAMES' ? 'game' : 'app');
|
||||
const style: 'game' | 'app' = profile === 'game' ? 'game' : 'app';
|
||||
|
||||
|
|
@ -440,7 +441,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
|
|||
if (demo.length) secrets.push({ what: `review_information/${demo.join(', ')} (App Review demo account)`, where: 'not copied: the owner puts it in .storebound/credentials.json "reviewDemoAccount": { username, password } (review-access skill); reviewAccess.signInRequired was set to true' });
|
||||
const signIn = demo.includes('demo_user.txt');
|
||||
if (scan.deliver.metadata && isDir(join(scan.deliver.metadata, 'trade_representative_contact_information')))
|
||||
notMapped.push({ what: 'trade_representative_contact_information/ (Korea)', where: 'App Store Connect > App Information (personal data, not imported)' });
|
||||
notMapped.push({ what: 'trade_representative_contact_information/ (Korea)', where: `${consoleLink('asc.app-info')} (personal data, not imported)` });
|
||||
|
||||
// --- App Store listing
|
||||
const appleSections: string[] = [];
|
||||
|
|
@ -535,8 +536,8 @@ ${secs.join('\n\n')}
|
|||
const id = idOfPlay(l.code)!;
|
||||
const fg = l.images.featureGraphic;
|
||||
if (fg) { const p = `store/renders/${id}/feature/1024x500${extname(fg).toLowerCase().replace('.jpeg', '.jpg')}`; files.push({ path: p, from: fg, src: rel(fg), kind: 'feature' }); mapped.push({ from: rel(fg), to: p }); }
|
||||
for (const k of ['promoGraphic', 'tvBanner']) if (l.images[k]) notMapped.push({ what: rel(l.images[k]!), where: 'Play Console > Store listing (storebound uploads icon, feature graphic and phone / tablet screenshots)' });
|
||||
for (const k of ['tv', 'wear'] as const) if (l.shots[k].length) notMapped.push({ what: `metadata/android/${l.folder}/images/${k}Screenshots (${l.shots[k].length})`, where: `Play Console > Store listing (${k === 'tv' ? 'Android TV' : 'Wear OS'} screenshots)` });
|
||||
for (const k of ['promoGraphic', 'tvBanner']) if (l.images[k]) notMapped.push({ what: rel(l.images[k]!), where: `${consoleLink('play.store-listing')} (storebound uploads icon, feature graphic and phone / tablet screenshots)` });
|
||||
for (const k of ['tv', 'wear'] as const) if (l.shots[k].length) notMapped.push({ what: `metadata/android/${l.folder}/images/${k}Screenshots (${l.shots[k].length})`, where: `${consoleLink('play.store-listing')} (${k === 'tv' ? 'Android TV' : 'Wear OS'} screenshots)` });
|
||||
}
|
||||
|
||||
// screenshots -> frames: <frames>/<lang id>/<phone|tablet>/NN.png; iOS shots win over Play shots for the same language
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
// https://developer.apple.com/documentation/appstoreconnectapi/power-and-performance-metrics-and-logs
|
||||
// GET /v1/apps/{id}/perfPowerMetrics (application/vnd.apple.xcode-metrics+json), GET /v1/builds/{id}/diagnosticSignatures
|
||||
// (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+).
|
||||
import { CONSOLE_PAGES, consoleLink, consoleUrl } from './console-links';
|
||||
import { type CrashHint, crashHint } from './crash-hints';
|
||||
import type { Finding, Level } from './validate-rules';
|
||||
import { mdTable, pct, textTable } from './table';
|
||||
|
|
@ -265,7 +266,7 @@ export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; princ
|
|||
f.push(g?.kind === 'api-disabled' || REPORTING_OFF.test(raw)
|
||||
? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the ${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'} is off in the service account's Google Cloud project${(g?.kind === 'api-disabled' ? g.projectId : on.project) ? ` ${g?.kind === 'api-disabled' ? g.projectId : on.project}` : ''}`,
|
||||
fix: `enable "${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'}" for that project: ${g?.kind === 'api-disabled' ? g.activationUrl : on.url} (Enable), wait a few minutes (store-health skill)` }
|
||||
: { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${g ? findingText(g) : plainError(raw).slice(0, 200)}`, fix: g?.kind === 'no-app-access' ? `Play Console > Users and permissions: ${g.usersUrl}` : 'store-health skill (Reporting API access)' });
|
||||
: { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${g ? findingText(g) : plainError(raw).slice(0, 200)}`, fix: g?.kind === 'no-app-access' ? `${CONSOLE_PAGES['play.users'].label}: ${g.usersUrl}` : 'store-health skill (Reporting API access)' });
|
||||
}
|
||||
else if (play) {
|
||||
const crash = lastValue(play.crash, 'userPerceivedCrashRate28dUserWeighted') ?? weightedAverage(play.crash, 'userPerceivedCrashRate');
|
||||
|
|
@ -276,7 +277,7 @@ export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; princ
|
|||
f.push({ store: 'play', level: levelFor(crash, PLAY_THRESHOLDS.crash), code: 'vitals-crash', msg: `user-perceived crash rate (28 d) ${pct(crash)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.crash)})`, fix });
|
||||
f.push({ store: 'play', level: levelFor(anr, PLAY_THRESHOLDS.anr), code: 'vitals-anr', msg: `user-perceived ANR rate (28 d) ${pct(anr)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.anr)})`, fix });
|
||||
}
|
||||
if (play.issuesError) f.push({ store: 'play', level: 'warn', code: 'vitals-issues', msg: `top crash / ANR issues not read: ${play.issuesError.slice(0, 200)}`, fix: 'Play Console > Android vitals > Crashes and ANRs' });
|
||||
if (play.issuesError) f.push({ store: 'play', level: 'warn', code: 'vitals-issues', msg: `top crash / ANR issues not read: ${play.issuesError.slice(0, 200)}`, fix: consoleLink('play.vitals', '> Crashes and ANRs'), url: consoleUrl('play.vitals') });
|
||||
for (const d of play.devices ?? []) {
|
||||
const worst = Math.max(d.crash ?? 0, d.anr ?? 0);
|
||||
const lv = levelFor(worst, PLAY_THRESHOLDS.perDevice);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
// --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 { consoleLink } from './console-links';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
|
@ -93,7 +94,7 @@ export function withFolder(r: Registry, path: string, name: string | undefined,
|
|||
/** 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)`);
|
||||
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 (${consoleLink('asc.app-info')})` : `--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,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
// - Impersonation: answer/9888374 (don't imply a relation with someone you are not, e.g. in the title or developer
|
||||
// name). The brand list below is storebound's own short list of well-known app brands, reported as low "check".
|
||||
// Play Integrity / anti-tamper is not a policy: no rule.
|
||||
import { consoleLink } from '../console-links';
|
||||
import { PLAY_AGE_GROUPS } from '../config';
|
||||
import { allSubscriptions } from '../subscriptions';
|
||||
import { grep, type Hit, type ProjectFacts } from './project';
|
||||
|
|
@ -164,7 +165,7 @@ export function playRules(inp: ScanInput, x: PlayCtx): void {
|
|||
if (bg)
|
||||
add({ rule: 'play-background-location', guideline: 'Play: Location permissions', severity: 'high', title: 'ACCESS_BACKGROUND_LOCATION needs a core feature that uses location in the background, the Location permissions declaration and a video',
|
||||
evidence: [ev(bg, bg.name)], ref: PLAY_REFS.backgroundLocation, skill: 'store-declarations',
|
||||
fix: 'Keep it only for one core, user-visible feature (never for ads or analytics alone); fill App content > Sensitive app permissions > Location permissions (one feature, a video of 30 s or less showing the prominent disclosure, the runtime prompt and the feature working in the background), and name the feature in the store description. Otherwise remove it (tools:node="remove" if a library adds it).' });
|
||||
fix: 'Keep it only for one core, user-visible feature (never for ads or analytics alone); fill ' + consoleLink('play.sensitive-permissions', '> Location permissions') + ' (one feature, a video of 30 s or less showing the prominent disclosure, the runtime prompt and the feature working in the background), and name the feature in the store description. Otherwise remove it (tools:node="remove" if a library adds it).' });
|
||||
const media = ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO'].map(n => perms.get(n)).filter(q => !!q);
|
||||
if (media.length)
|
||||
add({ rule: 'play-photo-video', guideline: 'Play: Photo and Video Permissions', severity: 'high', title: `${media.map(q => permName(q.name)).join(' / ')}: only for apps whose core purpose needs broad photo / video access; others use the photo picker`,
|
||||
|
|
@ -193,12 +194,12 @@ export function playRules(inp: ScanInput, x: PlayCtx): void {
|
|||
if (health.length)
|
||||
add({ rule: 'play-health-connect', guideline: 'Play: Health Connect permissions', severity: 'high', title: `Health Connect permissions (${health.slice(0, 3).map(q => permName(q.name).replace(/^health\./, '')).join(', ')}${health.length > 3 ? ', ...' : ''}) need a justification for each one and a permitted use case`,
|
||||
evidence: health.slice(0, 3).map(q => ev(q, q.name)), ref: PLAY_REFS.healthConnect, skill: 'store-declarations',
|
||||
fix: 'Request only the data types a feature uses, justify each permission in the Health Connect declaration, show the privacy policy in the app, and answer App content > Health apps (answer/14738291). Ads, data sales and child-only apps are not allowed to use it.' });
|
||||
fix: 'Request only the data types a feature uses, justify each permission in the Health Connect declaration, show the privacy policy in the app, and answer ' + consoleLink('play.health-apps') + ' (answer/14738291). Ads, data sales and child-only apps are not allowed to use it.' });
|
||||
const fsi = perms.get('USE_FULL_SCREEN_INTENT');
|
||||
if (fsi && (target === null || target >= 34))
|
||||
add({ rule: 'play-full-screen-intent', guideline: 'Play: Full-screen intent', severity: 'high', title: 'USE_FULL_SCREEN_INTENT: on Android 14+ only calling and alarm apps get it by default, and every app declares it in Play Console',
|
||||
evidence: [ev(fsi, fsi.name)], ref: PLAY_REFS.fullScreenIntent, skill: 'store-declarations',
|
||||
fix: 'Remove it unless the core function is alarms or phone / video calls (an SDK may add it: check the merged manifest); otherwise fill App content > Full-screen intent, ask the user with ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT and handle a denial. Never for ads.' });
|
||||
fix: 'Remove it unless the core function is alarms or phone / video calls (an SDK may add it: check the merged manifest); otherwise fill ' + consoleLink('play.full-screen-intent') + ', ask the user with ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT and handle a denial. Never for ads.' });
|
||||
const vpn = a.components.filter(q => q.kind === 'service' && (q.permission === 'android.permission.BIND_VPN_SERVICE' || q.actions.includes('android.net.VpnService')));
|
||||
if (vpn.length)
|
||||
add({ rule: 'play-vpn', guideline: 'Play: VpnService', severity: 'high', title: `VpnService ${vpn[0]!.name}: only for VPN apps and the listed exceptions, with a disclosure, consent and the VpnService declaration`,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
|
||||
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
|
||||
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
|
||||
import { consoleLink, consoleUrl } from '../console-links';
|
||||
import type { FixAction } from '../fix-actions';
|
||||
import { templateBlockers } from '../template-answers';
|
||||
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
||||
|
|
@ -57,6 +58,8 @@ export interface ScanFinding {
|
|||
severity: Severity;
|
||||
title: string;
|
||||
evidence: Hit[];
|
||||
/** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */
|
||||
url?: string;
|
||||
fix: string;
|
||||
skill: string;
|
||||
/** Policy page. */
|
||||
|
|
@ -222,7 +225,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
}
|
||||
if (play && ra?.signInRequired && !inp.demoAccount)
|
||||
add({ rule: 'play-app-access', guideline: 'Play: App access', store: 'play', severity: 'high', title: 'Sign-in is required but there are no reviewer credentials for Play App access', evidence: ev,
|
||||
fix: 'Play Console > App content > App access: add the demo account and instructions (keep the credentials in .storebound/credentials.json only).', skill: 'review-access', ref: `${PLAY}9859455` });
|
||||
fix: `${consoleLink('play.app-access')}: add the demo account and instructions (keep the credentials in .storebound/credentials.json only).`, url: consoleUrl('play.app-access'), skill: 'review-access', ref: `${PLAY}9859455` });
|
||||
}
|
||||
if (creates.length) {
|
||||
if (apple && !deletionCode.length && deletionClaim.length)
|
||||
|
|
@ -264,7 +267,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
const termsUrl = c?.app.termsUrl;
|
||||
if (!(termsUrl && all.includes(termsUrl)) && !/terms of use|terms of service|\beula\b|license agreement/i.test(all))
|
||||
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'high', title: 'App Store description has no Terms of Use (EULA) link', evidence: desc.slice(0, 1),
|
||||
fix: 'Add a line "Terms of Use: <app.termsUrl>" (and the privacy policy) to the description of every locale, or set a custom EULA in App Store Connect > App Information.', skill: 'store-subscriptions' });
|
||||
fix: 'Add a line "Terms of Use: <app.termsUrl>" (and the privacy policy) to the description of every locale, or set a custom EULA in ' + consoleLink('asc.app-info') + '.', url: consoleUrl('asc.app-info'), skill: 'store-subscriptions' });
|
||||
if (!/renew|cancel/i.test(all))
|
||||
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'medium', title: 'App Store description does not say the subscription renews automatically or how to cancel', evidence: desc.slice(0, 1),
|
||||
fix: 'Describe what the subscription includes, the length and price per period, that it renews automatically unless cancelled at least 24 hours before the end of the period, and where to manage it.', skill: 'store-subscriptions' });
|
||||
|
|
@ -469,7 +472,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
||||
else if (!a.targetSdk && a.targetSdkFrom)
|
||||
add({ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'low', title: `targetSdk comes from ${a.targetSdkFrom}: check that the built AAB targets ${need} or higher`, evidence: a.manifests.slice(0, 1).map(f => ({ file: f, line: 1, text: 'AndroidManifest.xml' })),
|
||||
fix: `Check with \`aapt2 dump badging app.aab\` or Play Console > App bundle explorer; Play refuses uploads below ${need}.`, skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
||||
fix: `Check with \`aapt2 dump badging app.aab\` or ${consoleLink('play.bundle-explorer')}; Play refuses uploads below ${need}.`, skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
||||
const perms = new Map(a.permissions.map(x => [x.name.replace(/^android\.permission\./, ''), x] as const));
|
||||
const fgs = perms.get('FOREGROUND_SERVICE');
|
||||
if (fgs && (!a.targetSdk || a.targetSdk.value >= 34)) {
|
||||
|
|
@ -477,7 +480,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
const untyped = a.services.filter(s => !s.fgsType);
|
||||
if (untyped.length && !typed.length)
|
||||
add({ rule: 'play-fgs', guideline: 'Play: Foreground service types', store: 'play', severity: 'high', title: 'FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (required when targeting Android 14+)', evidence: [{ ...fgs.at, text: 'FOREGROUND_SERVICE' }, ...untyped.slice(0, 2).map(s => ({ ...s.at, text: `service ${s.name}` }))],
|
||||
fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill App content > Foreground service permissions (description, user impact, video).', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill ' + consoleLink('play.foreground-service') + ' (description, user impact, video).', url: consoleUrl('play.foreground-service'), skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
for (const s of typed) {
|
||||
const types = s.fgsType!.split('|').map(x => x.trim()).filter(Boolean);
|
||||
const missing = types.filter(t => FGS_PERMISSION[t] && !perms.has(`FOREGROUND_SERVICE_${FGS_PERMISSION[t]}`));
|
||||
|
|
@ -487,7 +490,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
}
|
||||
if (typed.length)
|
||||
add({ rule: 'play-fgs', guideline: 'Play: Foreground service types', store: 'play', severity: 'medium', title: `Foreground service types need a Play Console declaration: ${[...new Set(typed.flatMap(s => s.fgsType!.split('|')))].join(', ')}`, evidence: typed.slice(0, 3).map(s => ({ ...s.at, text: `${s.name}: ${s.fgsType}` })),
|
||||
fix: 'App content > Foreground service permissions: describe each use, the user impact if it is deferred, and add a short video; shortService needs none.', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
fix: consoleLink('play.foreground-service') + ': describe each use, the user impact if it is deferred, and add a short video; shortService needs none.', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
}
|
||||
const exact = perms.get('USE_EXACT_ALARM');
|
||||
if (exact)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
// Google Play: the Play Developer Reporting API `apps:search` lists every app the service account can see, across all
|
||||
// developer accounts it was invited to.
|
||||
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/apps/search
|
||||
import { consoleLink } from './console-links';
|
||||
import type { AccountCheck, StoreApp } from './desktop-protocol';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -175,7 +176,7 @@ export function localPlayApps(known: { packageName: string; name: string; source
|
|||
/** 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';
|
||||
const invite = `Play Console > Users and permissions > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`;
|
||||
const invite = `${consoleLink('play.users')} > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`;
|
||||
if ('apps' in outcome) {
|
||||
return outcome.apps > 0
|
||||
? { store: 'android', status: 'ok', principal, apps: outcome.apps, message: `The service account can see ${outcome.apps} app${outcome.apps === 1 ? '' : 's'}.`, fix: null, docs: null }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// re-exports these) and `storebound release status | plan` (cli/release.ts) share this file, so both show the same
|
||||
// stage for the same store state. Pure; the store reads are `apple status --json` / `play status --json`, turned into
|
||||
// StoreRelease rows by cli/lib/portfolio.ts.
|
||||
import { CONSOLE_PAGES, consoleLink, consoleUrl, playTrackPage } from './console-links';
|
||||
import type { AppStatus, ReleaseTrain, StoreRelease } from './desktop-protocol';
|
||||
import { cmpVersion, displayVersion, PHASE_RANK, playReleaseLabel, REACHING, releaseTrains, sameVersion, versionKey } from './portfolio';
|
||||
import type { Finding } from './validate-rules';
|
||||
|
|
@ -151,6 +152,8 @@ export type PlanKind = 'config' | 'store' | 'blocker' | 'owner' | 'wait' | 'warn
|
|||
export interface PlanStep {
|
||||
kind: PlanKind;
|
||||
text: string;
|
||||
/** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */
|
||||
url?: string;
|
||||
/** The command or console place that does it. Owner steps name what the owner runs; storebound never does them. */
|
||||
fix?: string;
|
||||
code?: string;
|
||||
|
|
@ -217,18 +220,18 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null,
|
|||
break;
|
||||
case 'draft':
|
||||
steps.push(store === 'ios'
|
||||
? { kind: 'owner', text: `submit ${v} for App Review (owner)`, fix: 'App Store Connect > the version > Add for Review' }
|
||||
? { kind: 'owner', text: `submit ${v} for App Review (owner)`, fix: consoleLink('asc.version', '> Add for Review'), url: consoleUrl('asc.version') }
|
||||
: r!.track && r!.track !== 'production'
|
||||
? { kind: 'owner', text: `roll out the ${r!.track} draft of ${v} to its testers (owner)`, fix: `Play Console > Test and release > ${r!.track}` }
|
||||
: { kind: 'owner', text: `send the production draft of ${v} for review (owner)`, fix: 'Play Console > Publishing overview > Send for review' });
|
||||
? { kind: 'owner', text: `roll out the ${r!.track} draft of ${v} to its testers (owner)`, fix: `${CONSOLE_PAGES[playTrackPage(r!.track)].label} (${r!.track}): ${consoleUrl(playTrackPage(r!.track))}`, url: consoleUrl(playTrackPage(r!.track)) }
|
||||
: { kind: 'owner', text: `send the production draft of ${v} for review (owner)`, fix: consoleLink('play.publishing', '> Send for review'), url: consoleUrl('play.publishing') });
|
||||
break;
|
||||
case 'testing':
|
||||
steps.push(store === 'ios'
|
||||
? { kind: 'store', text: `${v} is in TestFlight (build ${r!.build ?? '?'}) but has no App Store version yet: create it with that build and its texts`, fix: `storebound apple listing --version ${v} --build ${r!.build ?? '<N>'} --dry` }
|
||||
: { kind: 'owner', text: `promote ${v} from ${r!.track ?? 'testing'} to production (owner)`, fix: 'Play Console > Test and release > Production, or `storebound play release <aab> --production draft` in a terminal' });
|
||||
: { kind: 'owner', text: `promote ${v} from ${r!.track ?? 'testing'} to production (owner)`, fix: `${consoleLink('play.production')}, or \`storebound play release <aab> --production draft\` in a terminal`, url: consoleUrl('play.production') });
|
||||
break;
|
||||
case 'rejected':
|
||||
steps.push({ kind: 'owner', text: `resubmit ${v} after the fix (owner)`, fix: store === 'ios' ? 'App Store Connect > App Review > reply / resubmit' : 'Play Console > Publishing overview' });
|
||||
steps.push({ kind: 'owner', text: `resubmit ${v} after the fix (owner)`, fix: store === 'ios' ? consoleLink('asc.app-review', '> reply / resubmit') : consoleLink('play.publishing'), url: consoleUrl(store === 'ios' ? 'asc.app-review' : 'play.publishing') });
|
||||
break;
|
||||
case 'sent':
|
||||
case 'released':
|
||||
|
|
@ -236,13 +239,13 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null,
|
|||
break;
|
||||
case 'review':
|
||||
if (store === 'android' && r!.track === 'production') {
|
||||
steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? 'Play Console > Publishing overview (publish once approved)' : 'storebound play status' });
|
||||
steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? `${consoleLink('play.publishing')} (publish once approved)` : 'storebound play status', ...(r!.managed ? { url: consoleUrl('play.publishing') } : {}) });
|
||||
break;
|
||||
}
|
||||
steps.push({ kind: 'wait', text: `${v} is in review (${r!.state}); nothing to do until the store answers`, fix: store === 'ios' ? 'storebound apple status --wait-review' : 'storebound play status' });
|
||||
break;
|
||||
case 'approved':
|
||||
steps.push({ kind: 'owner', text: `${v} is approved (${r!.state}): release it (owner)`, fix: store === 'ios' ? 'App Store Connect > Release this version, or `storebound apple release --manual-release` in a terminal' : 'Play Console > Publishing overview' });
|
||||
steps.push({ kind: 'owner', text: `${v} is approved (${r!.state}): release it (owner)`, fix: store === 'ios' ? `${consoleLink('asc.version', '> Release this version')}, or \`storebound apple release --manual-release\` in a terminal` : consoleLink('play.publishing'), url: consoleUrl(store === 'ios' ? 'asc.version' : 'play.publishing') });
|
||||
break;
|
||||
case 'rollout':
|
||||
steps.push({ kind: 'owner', text: `${v} reaches ${pctText(r!.fraction) || 'part'} of users: raise or complete the rollout when health is good (owner)`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
// sha256_cert_fingerprints ["AB:CD:..."] }; https://<domain>/.well-known/assetlinks.json, content-type
|
||||
// application/json, HTTPS, no redirects; relation_extensions.dynamic_app_link_components on Android 15+)
|
||||
// https://developer.android.com/training/app-links/verify-android-applinks (autoVerify, per-host lookup)
|
||||
import { consoleLink, consoleUrl } from './console-links';
|
||||
import type { ShipConfig } from './config';
|
||||
|
||||
export const AASA_PATH = '/.well-known/apple-app-site-association';
|
||||
|
|
@ -25,7 +26,7 @@ export const HANDLE_ALL_URLS = 'delegate_permission/common.handle_all_urls';
|
|||
export const AASA_MAX_BYTES = 128 * 1024;
|
||||
|
||||
export type Level = 'ok' | 'problem' | 'info';
|
||||
export interface Finding { level: Level; message: string; fix?: string }
|
||||
export interface Finding { level: Level; message: string; fix?: string; url?: string }
|
||||
|
||||
type Cfg = Pick<ShipConfig, 'app' | 'apple' | 'play' | 'web'>;
|
||||
|
||||
|
|
@ -187,7 +188,7 @@ export function validateAssetLinks(json: unknown, ids: { packageName?: string; f
|
|||
f.push({ level: 'ok', message: `${ids.packageName} has ${HANDLE_ALL_URLS}` });
|
||||
const live = new Set(linked.flatMap(s => ((s.target as Record<string, unknown>).sha256_cert_fingerprints as string[] | undefined) ?? []).map(x => normalizeFingerprint(x) ?? x));
|
||||
const want = (ids.fingerprints ?? []).map(x => normalizeFingerprint(x) ?? x);
|
||||
if (!want.length) { f.push({ level: 'info', message: 'web.androidSha256 not in the config: fingerprints not compared', fix: 'copy the SHA-256 from Play Console > App integrity > Play app signing' }); return f; }
|
||||
if (!want.length) { f.push({ level: 'info', message: 'web.androidSha256 not in the config: fingerprints not compared', fix: `copy the SHA-256 from ${consoleLink('play.app-signing')}`, url: consoleUrl('play.app-signing') }); return f; }
|
||||
const missing = want.filter(x => !live.has(x)), extra = [...live].filter(x => !want.includes(x));
|
||||
for (const m of missing) bad(`fingerprint ${m.slice(0, 11)}... from web.androidSha256 is missing: app links do not verify for builds signed with it`);
|
||||
if (!missing.length) f.push({ level: 'ok', message: `all ${want.length} fingerprint(s) from web.androidSha256 are listed` });
|
||||
|
|
@ -277,10 +278,10 @@ export function planWeb(c: Cfg, o: { landing: boolean; template?: string; marks?
|
|||
const fps = o.fingerprints ?? c.web?.androidSha256 ?? [];
|
||||
const links = assetLinksFor(c, fps);
|
||||
if (links) files.push({ path: '.well-known/assetlinks.json', content: JSON.stringify(links, null, 2) + '\n' });
|
||||
else if (c.play) skipped.push('.well-known/assetlinks.json: no app signing fingerprint: copy the SHA-256 from Play Console > Test and release > App integrity > Play app signing into web.androidSha256, or run `storebound web init --version-code <uploaded versionCode>` to read it from the Play Developer API');
|
||||
else if (c.play) skipped.push('.well-known/assetlinks.json: no app signing fingerprint: copy the SHA-256 from ' + consoleLink('play.app-signing') + ' into web.androidSha256, or run `storebound web init --version-code <uploaded versionCode>` to read it from the Play Developer API');
|
||||
const tag = smartBannerTag(c);
|
||||
if (tag) files.push({ path: 'smart-app-banner.html', content: `<!-- Smart App Banner: paste this tag into the <head> of every page that should show it (Safari on iPhone and iPad). -->\n${tag}\n` });
|
||||
else if (c.apple) skipped.push('smart-app-banner.html: apple.appId is not set (App Store Connect > App Information > Apple ID)');
|
||||
else if (c.apple) skipped.push(`smart-app-banner.html: apple.appId is not set (${consoleLink('asc.app-info', '> Apple ID')})`);
|
||||
if (o.landing) {
|
||||
if (!o.template) skipped.push('index.html: landing template not found');
|
||||
else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue