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
|
|
@ -68,6 +68,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -68,6 +68,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **Console pages as absolute deep links.** Every finding, fix hint, error and plan step that points to an App Store
|
||||
Connect or Play Console page now prints the readable path plus the absolute URL of that page for the app
|
||||
(`cli/lib/console-links.ts`; apple.appId, play.developerId and play.appId pick the app, the app list otherwise).
|
||||
validate, scan, declarations and release plan JSON carry `url`, and the desktop shows it as an Open button. A test
|
||||
fails on a plain console direction without a URL; AGENTS.md rule 11 and pitfall 71 tell agents the same.
|
||||
|
||||
## 0.8.7 · 2026-10-15
|
||||
|
||||
- **First in-app purchases left out of a review are caught.** Apple reviews the first in-app purchase of a type (and
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const DECLARATIONS: Record<string, DeclarationsResult> = {
|
|||
review: { signInRequired: false, accounts: false, contact: 's***@example.com', demoAccount: null, notes: 'Offline puzzle game, no sign-in. Remove Ads is restored from Settings > Restore purchases. The daily drop unlocks at midnight local time.', notesBytes: 148, notesMax: 4000 },
|
||||
ask: [
|
||||
{ area: 'appInfo', id: 'ai-age', text: 'No age rating answers: storebound would answer every content question "none".', fix: 'Confirm that is true (ads, user-generated content, chat, loot boxes, web access count), or set apple.info.ageRating overrides.' },
|
||||
{ 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).' },
|
||||
{ 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: https://appstoreconnect.apple.com/apps/1000000001/distribution/privacy for each type below (tracking also needs App Tracking Transparency in the app).', url: 'https://appstoreconnect.apple.com/apps/1000000001/distribution/privacy' },
|
||||
],
|
||||
},
|
||||
'p-tidy': {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ let overrides: Overrides = {
|
|||
};
|
||||
if (import.meta.env.VITE_FIXTURES === 'empty') overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] };
|
||||
let settings: Settings = { ...DEFAULT_SETTINGS, refresh: { ...DEFAULT_SETTINGS.refresh, autoMinutes: 60 } };
|
||||
const PLAY_INVITE = (who: string) => `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 PLAY_INVITE = (who: string) => `Play Console > Users and permissions: https://play.google.com/console > 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 CHECKS: Record<string, AccountCheck> = {
|
||||
[P1]: { store: 'android', status: 'ok', principal: 'play-bot@example.com', apps: 8, message: 'The service account can see 8 apps.', fix: null, docs: null },
|
||||
[P2]: { store: 'android', status: 'permission', principal: 'sample-labs-bot@example.com', apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: PLAY_INVITE('sample-labs-bot@example.com'), docs: 'https://play.google.com/console/developers/users-and-permissions' },
|
||||
|
|
@ -344,7 +344,7 @@ const VALIDATE: Record<string, ValidateResult> = {
|
|||
{ store: 'apple', level: 'warn', code: 'screenshots-ipad', msg: 'ar-SA has no 13" iPad screenshots', fix: 'storebound shots render --lang ar, then storebound apple screenshots --only ar-SA' },
|
||||
{ store: 'apple', level: 'warn', code: 'whats-new', msg: '2.4.0 has no "What\'s New" for ja', fix: 'storebound whats-new --since v2.3.0' },
|
||||
// a long unbreakable path (a merged manifest of a local build) must wrap inside the card
|
||||
{ store: 'play', level: 'warn', code: 'scan', msg: 'Play: Foreground service types: FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (apps/native/android/app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml:74)', fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission.' },
|
||||
{ store: 'play', level: 'warn', code: 'scan', msg: 'Play: Foreground service types: FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (apps/native/android/app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml:74)', fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill Play Console > App content > Foreground service permissions: https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services', url: 'https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services' },
|
||||
],
|
||||
},
|
||||
'p-tidy': { blockers: 0, findings: [] },
|
||||
|
|
|
|||
|
|
@ -1470,6 +1470,8 @@ export const en = {
|
|||
'cp.failed': 'Could not make the plan for {form}',
|
||||
'cp.agentFirst': 'Agent-first: your AI agent drives the page with this plan; storebound never clicks the final button',
|
||||
'cp.openPage': 'Open the page',
|
||||
'link.open': 'Open',
|
||||
'link.openFailed': 'Could not open the page',
|
||||
'cp.openFailed': 'Could not open the page',
|
||||
'cp.steps': '{count} steps',
|
||||
'cp.steps_one': '1 step',
|
||||
|
|
|
|||
|
|
@ -1472,6 +1472,8 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'cp.failed': '{form} için plan oluşturulamadı',
|
||||
'cp.agentFirst': 'Önce ajan: yapay zekâ aracınız sayfayı bu planla yönetir; storebound son düğmeye asla basmaz',
|
||||
'cp.openPage': 'Sayfayı aç',
|
||||
'link.open': 'Aç',
|
||||
'link.openFailed': 'Sayfa açılamadı',
|
||||
'cp.openFailed': 'Sayfa açılamadı',
|
||||
'cp.steps': '{count} adım',
|
||||
'cp.steps_one': '1 adım',
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ export interface ParityRow { id: string; region: string; currency: string; apple
|
|||
export interface CatalogDiff { errors: number; rows: CatalogRow[]; parity: ParityRow[] }
|
||||
/** A finding's machine-readable fix (cli/lib/fix-actions.ts): the write the Fix button runs (dry run, dialog, apply). */
|
||||
export interface FixAction { method: string; args: Record<string, string> }
|
||||
export interface Finding { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string; action?: FixAction }
|
||||
export interface Finding { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string; url?: string; action?: FixAction }
|
||||
export interface ValidateResult { blockers: number; findings: Finding[] }
|
||||
/** `storebound scan --json` (offline rejection-risk scan of the project files), the parts the Checks tab shows. */
|
||||
export type ScanSeverity = 'blocker' | 'high' | 'medium' | 'low';
|
||||
export interface ScanFinding {
|
||||
rule: string; guideline: string; store: 'apple' | 'play'; severity: ScanSeverity; title: string;
|
||||
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction; aiFix?: boolean;
|
||||
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; url?: string; accepted?: string; action?: FixAction; aiFix?: boolean;
|
||||
}
|
||||
export interface ScanResult {
|
||||
stacks: string[];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// (no API: the answers to enter in App Store Connect), app info, export compliance and the App Review details.
|
||||
// The forms without an API (App Privacy, age rating, the Play questionnaires, a missing App Store app record) have a
|
||||
// "Fill in console" button (ui/ConsoleFill.tsx): storebound fills them in a visible browser, the owner saves.
|
||||
import { OpenLink } from '../../ui/OpenLink';
|
||||
import { useState } from 'react';
|
||||
import { api, type ConsoleForm, type DeclarationsResult, type DeclAsk, type Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
|
|
@ -38,6 +39,7 @@ function AskCard({ ask }: { ask: DeclAsk[] }) {
|
|||
<div className="li" key={a.id}>
|
||||
<span className="li-icon tone-bg-warn"><Icon name="question" /></span>
|
||||
<div className="li-main"><span className="li-title">{a.text}</span><span className="fix"><Icon name="wand" />{a.fix}</span></div>
|
||||
<OpenLink url={a.url} />
|
||||
<Chip tone="outline">{t(AREA[a.area])}</Chip>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { AiFix, aiFinding } from '../../ui/AiFix';
|
|||
import { api } from '../../api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { OpenLink } from '../../ui/OpenLink';
|
||||
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { exportName, validateMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
|
@ -70,6 +71,7 @@ function ScanCard({ project }: { project: Project }) {
|
|||
{ev.slice(0, 2).map((e, j) => <span className="li-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
|
||||
{f.accepted ? <span className="li-sub">{t('checks.accepted', { reason: f.accepted })}</span>
|
||||
: <span className="fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
|
||||
{!f.accepted && <OpenLink url={f.url} />}
|
||||
{!f.accepted && f.aiFix && !fixable(f.action) && <AiFix projectId={project.id} f={f} claude={claude} busy={aiBusy} setBusy={setAiBusy} rescan={() => { s.refresh(); }}
|
||||
stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />}
|
||||
</div>
|
||||
|
|
@ -131,6 +133,7 @@ export function ChecksTab({ project }: { project: Project }) {
|
|||
<span className="li-title">{f.msg}</span>
|
||||
{f.fix && <span className="fix"><Icon name="wand" /><code className="selectable">{f.fix}</code></span>}
|
||||
</div>
|
||||
<OpenLink url={f.url} />
|
||||
{f.level !== 'ok' && fixable(f.action) && <FixButton action={f.action} what={f.msg} w={w} />}
|
||||
<Chip tone="outline">{f.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>
|
||||
</div>
|
||||
|
|
|
|||
17
apps/desktop/src/ui/OpenLink.tsx
Normal file
17
apps/desktop/src/ui/OpenLink.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// The "Open" button for a finding, notice or ask that carries an absolute console deep link (`url` from the CLI's
|
||||
// console-links module, pitfall 71): opens it in the browser through the open-external command.
|
||||
import { api, errorText } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { useData } from '../store';
|
||||
import { Icon } from './icons';
|
||||
|
||||
export function OpenLink({ url }: { url?: string | null }) {
|
||||
const d = useData();
|
||||
if (!url || !/^https:\/\//.test(url)) return null;
|
||||
return (
|
||||
<button className="btn btn-sm btn-ghost" title={url} data-testid="open-link"
|
||||
onClick={() => api.openExternal(url).catch(e => d.toast({ kind: 'bad', title: t('link.openFailed'), detail: errorText(e) }))}>
|
||||
<Icon name="external" />{t('link.open')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
// Download reports > Statistics > Copy Cloud Storage URI) are read with the service account (store listing visitors,
|
||||
// acquisitions, daily user installs). compare lines the snapshots up with the git history of the store files (listing
|
||||
// text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`.
|
||||
import { consoleLink } from './lib/console-links';
|
||||
import { storeReadError } from './lib/google-errors';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
|
@ -71,7 +72,7 @@ async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ da
|
|||
async function playSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: PlayDay[] }> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const bucket = config.play?.reportsBucket;
|
||||
if (!bucket) throw new Error('play.reportsBucket is not set (Play Console > Download reports > Statistics > Copy Cloud Storage URI)');
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
|
||||
const g = googleClient(root, STORAGE_READ_SCOPE);
|
||||
const sp: Record<string, string>[] = [], inst: Record<string, string>[] = [];
|
||||
for (const month of monthsBetween(start, end)) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// storebound apple bundle [--name "My App"] [--dry]
|
||||
// The App Store Connect app record itself can NOT be created through the API: do that in the web UI afterwards
|
||||
// (Apps > + > New App, pick this bundle id), see the appstore-first-release skill.
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
import { done, log } from '../lib/log';
|
||||
|
|
@ -25,6 +26,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
const apps = (await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`)).data;
|
||||
if (apps.length) log('apple', `app record: ${apps[0].attributes.name} (Apple ID ${apps[0].id}); put it in apple.appId`);
|
||||
else log('apple', 'no app record yet: App Store Connect > Apps > + > New App (UI only), then re-run `apple status`');
|
||||
else log('apple', `no app record yet: ${consoleLink('asc.new-app')} (UI only), then re-run \`apple status\``);
|
||||
done('apple', 'bundle done');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
// with documents where required). This command lists the declarations and links the approved one to the builds; it
|
||||
// never creates a declaration or claims an exemption for you (export-compliance skill).
|
||||
// Not set: refuses. storebound never guesses this answer.
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
import { done, log, warn } from '../lib/log';
|
||||
|
|
@ -39,7 +40,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
for (const d of decls) log('apple', `declaration ${d.id}: ${d.attributes.appEncryptionDeclarationState} exempt=${d.attributes.exempt} code=${d.attributes.codeValue ?? '-'}`);
|
||||
const approved = decls.find(d => d.attributes.appEncryptionDeclarationState === 'APPROVED');
|
||||
if (!approved) {
|
||||
warn('apple', 'no APPROVED encryption declaration: create it in App Store Connect > App Information > App Encryption Documentation (export-compliance skill), then re-run');
|
||||
warn('apple', `no APPROVED encryption declaration: create it in ${consoleLink('asc.encryption')} (export-compliance skill), then re-run`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
// screenshot sets, MD5-idempotent like `apple screenshots`. start / stop change what users see (stop is final, a test
|
||||
// cannot be restarted) and ask first; they are not MCP tools. Google Play store listing experiments have no API: they
|
||||
// stay in Play Console (store-screenshots skill). Pure planners: cli/lib/ppo.ts.
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
|
|
@ -66,7 +67,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log(TAG, `"${x.name}" treatment "${t.attributes.name}": ${locs.length} locale(s), ${shots} screenshot(s)`);
|
||||
}
|
||||
}
|
||||
log(TAG, 'results (conversion per treatment) are in App Store Connect > Product Page Optimization; no API returns them');
|
||||
log(TAG, `results (conversion per treatment) are in ${consoleLink('asc.ppo')}; no API returns them`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
// exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review)
|
||||
// Also compares the App ID's capabilities, apple.signing.capabilities and the iOS project's entitlements and warns,
|
||||
// naming what is missing where.
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { log, printJson, warn } from '../lib/log';
|
||||
|
|
@ -37,7 +38,7 @@ async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
|||
const outcome = classifyReviewState(st);
|
||||
if (st !== last) { log('apple', `${v.attributes.versionString}: ${st}`); last = st; }
|
||||
if (outcome === 'approved') { log('apple', 'approved (release it yourself, or it goes out automatically if the release type says so)'); process.exit(WAIT_EXIT.approved); }
|
||||
if (outcome === 'rejected') { log('apple', 'rejected: read the message in App Store Connect > App Review, then the review-rejections skill'); process.exit(WAIT_EXIT.rejected); }
|
||||
if (outcome === 'rejected') { log('apple', `rejected: read the message in ${consoleLink('asc.app-review')}, then the review-rejections skill`); process.exit(WAIT_EXIT.rejected); }
|
||||
if (outcome === 'not-submitted') { log('apple', 'nothing is waiting for review'); process.exit(2); }
|
||||
delay = nextPollDelay(delay);
|
||||
if (Date.now() + delay > deadline) { log('apple', 'timeout, still in review'); process.exit(WAIT_EXIT.timeout); }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
// or a place on the Console's uploader list. See skills/adhoc-distribution/SKILL.md.
|
||||
// Sources (checked October 2026): https://developers.google.com/android-publisher/api-ref/rest/v3/internalappsharingartifacts
|
||||
// and https://support.google.com/googleplay/android-developer/answer/9844679 (details in cli/lib/play-share.ts).
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
|
|
@ -61,7 +62,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
res = await play.call('POST', shareUrl(pkg, kind), new Uint8Array(readFileSync(file)), { ...SHARE_HEADERS });
|
||||
} catch (e) {
|
||||
const m = (e as Error).message;
|
||||
if (/-> 403/.test(m)) throw new Error(`${m}\n\nThe service account needs "Release apps to testing tracks" for this app, or a place on the internal app sharing uploader list (Play Console > Internal app sharing).`);
|
||||
if (/-> 403/.test(m)) throw new Error(`${m}\n\nThe service account needs "Release apps to testing tracks" for this app, or a place on the internal app sharing uploader list (${consoleLink('play.internal-sharing')}).`);
|
||||
throw e;
|
||||
} finally { clearInterval(beat); }
|
||||
const art = parseShareArtifact(res);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
// fetched and fetches nothing. Apple needs a team API key (not an individual key) with access to reports and the vendor
|
||||
// number (ASC_VENDOR_NUMBER in env or .storebound/credentials.json; --vendor). Play needs play.reportsBucket and the
|
||||
// service account with "View financial data". Read-only on both stores. Pure parts and sources: cli/lib/reports.ts.
|
||||
import { consoleLink } from './lib/console-links';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { ascClient } from './apple/asc';
|
||||
|
|
@ -51,7 +52,7 @@ function save(dir: string, name: string, raw: string | Uint8Array, rows: Summary
|
|||
/** What a failed Sales and Trends / finance download usually means. Seen live: a wrong vendor number gets a 500
|
||||
* (after the retries) from salesReports and a 400 PARAMETER_ERROR.INVALID from financeReports. Pure. */
|
||||
export function appleReportHint(msg: string): string | null {
|
||||
if (/salesReports -> 500|financeReports -> 400 .*vendor number/i.test(msg)) return 'check the vendor number (App Store Connect > Payments and Financial Reports, the number next to the legal entity name); reports also need a team key with Sales or Finance access';
|
||||
if (/salesReports -> 500|financeReports -> 400 .*vendor number/i.test(msg)) return `check the vendor number (${consoleLink('asc.payments')}, the number next to the legal entity name); reports also need a team key with Sales or Finance access`;
|
||||
if (/-> 403/.test(msg)) return 'the API key may not read reports: it needs the Admin, Finance or Sales role (Users and Access > Integrations)';
|
||||
return null;
|
||||
}
|
||||
|
|
@ -67,7 +68,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
if (sub === 'apple-sales' || sub === 'apple-finance') {
|
||||
const vendor = a.get('vendor') ?? ascVendorNumber(root);
|
||||
if (!vendor || !/^\d{5,12}$/.test(vendor)) throw new Error('vendor number missing: ASC_VENDOR_NUMBER in env, .storebound/credentials.json or the credential profile (--account), or --vendor (App Store Connect > Payments and Financial Reports)');
|
||||
if (!vendor || !/^\d{5,12}$/.test(vendor)) throw new Error(`vendor number missing: ASC_VENDOR_NUMBER in env, .storebound/credentials.json or the credential profile (--account), or --vendor (${consoleLink('asc.payments')})`);
|
||||
let path: string, name: string;
|
||||
if (sub === 'apple-sales') {
|
||||
const type = a.str('type', 'SALES').toUpperCase();
|
||||
|
|
@ -100,7 +101,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
if (sub === 'play-earnings' || sub === 'play-sales') {
|
||||
const bucket = config.play?.reportsBucket;
|
||||
if (!bucket) throw new Error('play.reportsBucket is not set (Play Console > Download reports > Financial > Copy Cloud Storage URI)');
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`);
|
||||
const month = need(a.get('month'), '--month YYYY-MM');
|
||||
const kind = sub === 'play-earnings' ? 'earnings' : 'sales';
|
||||
const prefix = playReportPrefix(kind, month);
|
||||
|
|
|
|||
|
|
@ -63,3 +63,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 71 | 2026-10 | Messages sent the owner to console pages as plain text paths ("App Store Connect > App Information > Age Rating", "Play Console > App content") without a link; the owner had to find the app and the page by hand. | `cli/lib/console-links.ts`: every message carries the absolute deep link (app list / developer chooser when ids are unknown), `url` in finding JSON, an Open button in the desktop; `tests/console-links.test.ts` fails on a console path in cli/ or templates/ without an https URL; agent rule 11 in AGENTS.md. |
|
||||
| 70 | 2026-10 | A game: the app's first in-app purchase (a starter pack) was not in the 1.0.0 review submission (its items held only the appStoreVersion); it stayed READY_TO_SUBMIT and was noticed after two days in WAITING_FOR_REVIEW. Apple reviews the first product of a type only with an app version; approved without it, it is not sellable. | `firstProductGaps` (`cli/lib/review-submit.ts`, read by `cli/apple/first-products.ts`): WARN in `apple status` (text and --json), `release status` and the desktop checks; blocker in `validate` / `release plan` and the `apple submit` plan; `iap-review` says "ready, not submitted yet". `tests/first-products.test.ts`. |
|
||||
| 69 | 2026-10 | A web app: `bun add -d storebound` plus the scaffolded `storebound.config.ts` broke `next build`: the package exported only its TypeScript source and shipped no declarations; Bun imports .ts, so every storebound test passed, but tsc never takes a package's .ts source as its types. | The package ships declarations for ES module (`dist/types`) and CommonJS projects (`dist/types-cjs`) and a Node entry (`dist/config.js`); Bun keeps the source (`buildEntry`, `pruneDeclarations` in `tools/build-node.ts`, run by prepack). `tests/package-types.test.ts` type-checks the template against the packed entry under bundler / node16 / nodenext / node10 in both project types with no @types/node and skipLibCheck off, and loads the package with `import()` and `require()` under Node. `check` fails on the template's own placeholders (Example Studio, bare example.com). |
|
||||
| 68 | 2026-10 | A headless Chrome started by storebound's console automation (a test run's `--user-data-dir=$TMPDIR/storebound-profile-apple-…`, `--remote-debugging-port=0 --headless=new`) outlived its parent for ~20 hours as an orphan (parent PID 1). macOS LaunchServices took it for the running "Google Chrome", so the Dock's Chrome only activated the windowless instance and the owner's own Chrome would not open. A Chrome started with a DevTools port does not notice its parent dying. | `cli/console/chrome-guard.ts`: every Chrome storebound starts itself is ended on process exit, closed gracefully on SIGINT / SIGTERM / SIGHUP, and ended by a detached watchdog when the parent dies (SIGKILL included; a reused pid is never killed); every start first ends headless orphans on storebound's profiles and names orphaned windows; headless runs on macOS prefer Chromium / Chrome for Testing over Google Chrome.app; `console fill` closes the window on an error too (`tests/chrome-guard.test.ts`). |
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export const PRIVACY_ANSWERS = [
|
|||
' [ok] Data type: Location > Precise Location: No -> untick "Precise Location" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
];
|
||||
export const PRIVACY_STEPS = [
|
||||
' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).',
|
||||
' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar): https://appstoreconnect.apple.com/apps',
|
||||
' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]',
|
||||
' 5. "Email Address" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next. [click: "Next"]',
|
||||
' 6. "Email Address" step 2, linked to the user: pick the radio, then Next. [click: "Next"]',
|
||||
|
|
@ -264,7 +264,7 @@ export const CONSOLE_CMD = 'storebound console plan apple-app-privacy';
|
|||
export const CONSOLE_LINES = [
|
||||
{ at: 0.12, text: 'App Store Connect: App Privacy', head: true },
|
||||
{ at: 0.22, text: 'Steps:', head: true },
|
||||
{ at: 0.3, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).' },
|
||||
{ at: 0.3, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar): https://appstoreconnect.apple.com/apps' },
|
||||
{ at: 0.38, text: ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]' },
|
||||
{ at: 0.5, text: 'Answer sheet:', head: true },
|
||||
{ at: 0.58, text: ' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list' },
|
||||
|
|
|
|||
|
|
@ -822,3 +822,15 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
and `firstProductGaps` in --json), `release status` and the desktop checks (`appleFirstProductWarnings`), `validate`
|
||||
/ `release plan` (blocker), the `apple submit` plan (blocker); `iap-review` says "ready, not submitted yet";
|
||||
`tests/first-products.test.ts`.
|
||||
|
||||
## 71. The owner was sent to a console page with plain directions
|
||||
- **Symptom:** a message said "App Store Connect > App Information > Age Rating" or "go to Play Console" and the owner
|
||||
had to find the app, the page and the card by hand.
|
||||
- **Cause:** storebound's findings, fix hints and errors named console pages as text paths (40+ in cli/), without the
|
||||
app's URL.
|
||||
- **Do:** when you send the owner to a console page, give the absolute deep link (storebound prints it; `storebound
|
||||
console plan` has start URLs), never just "go to App Store Connect". Set apple.appId, play.developerId and
|
||||
play.appId so links open the app's own page instead of the app list.
|
||||
- **Guard:** `cli/lib/console-links.ts` (every page key, absolute URL with and without ids; `url` in validate / scan /
|
||||
declarations / release plan JSON, an Open button in the desktop) and `tests/console-links.test.ts`, which fails on a
|
||||
console path in cli/ or templates/ without an https URL.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Example Game · App Store "App Privacy" answers
|
||||
|
||||
App Store Connect > your app > App Privacy. **Web UI only, there is no API.** Fill it from this table, then press
|
||||
App Store Connect > your app > App Privacy (https://appstoreconnect.apple.com/apps, then the app; `storebound console plan apple-app-privacy` gives the deep link). **Web UI only, there is no API.** Fill it from this table, then press
|
||||
**Publish** at the top of the App Privacy page (saving alone does not publish it, and review checks the published one).
|
||||
Keep it consistent with the privacy policy and the Play Data safety answers (`dataSafety` in `storebound.config.ts`).
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Answers for a typical single-player free-to-play game with ads (mediation) and in-app purchases, no accounts, no chat,
|
||||
no user-generated content, general audience 13+. Change what does not match your game. Everything marked **UI** has no
|
||||
API: Play Console > your app > Policy and programs > **App content** (or Dashboard > "Set up your app"), and App Store
|
||||
API: Play Console > your app > Policy and programs > **App content** (https://play.google.com/console, then the app; `storebound console plan` gives deep links) (or Dashboard > "Set up your app"), and App Store
|
||||
Connect for Apple. Data safety (Play) has an API: `storebound play data-safety`.
|
||||
|
||||
## Google Play · App content (UI)
|
||||
|
|
@ -51,7 +51,7 @@ Expected: PEGI 3, ESRB Everyone, USK 0, IARC 3+. Apple age rating 4+ (all conten
|
|||
|
||||
## App Store (UI-only parts)
|
||||
|
||||
- App record: App Store Connect > Apps > + > New App (platform, name, primary language, bundle id, SKU). No API.
|
||||
- App record: App Store Connect > Apps > + > New App (https://appstoreconnect.apple.com/apps) (platform, name, primary language, bundle id, SKU). No API.
|
||||
- App Privacy: `app-privacy.md`, then **Publish**.
|
||||
- Export compliance (export-compliance skill): `ITSAppUsesNonExemptEncryption = NO` in Info.plist only if true (HTTPS / OS encryption only) so
|
||||
every build skips the question.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Example App · App Store "App Privacy" answers
|
||||
|
||||
App Store Connect > your app > App Privacy. **Web UI only, there is no API.** Fill it from this table, then press
|
||||
App Store Connect > your app > App Privacy (https://appstoreconnect.apple.com/apps, then the app; `storebound console plan apple-app-privacy` gives the deep link). **Web UI only, there is no API.** Fill it from this table, then press
|
||||
**Publish** (saving alone does not publish it). Keep it consistent with the privacy policy and the Play Data safety
|
||||
answers (`dataSafety` in `storebound.config.ts`). **Edit to match your app.**
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Starting answers for a typical **utility / productivity app**: an optional account (email + password, or sign in
|
||||
with a provider), an analytics SDK acting as your processor, data synced to your own backend, no ads, no chat with
|
||||
strangers, general audience. **Edit every line to match your app**: these are your statements to the stores. Everything
|
||||
marked **UI** has no API: Play Console > your app > Policy and programs > **App content**, and App Store Connect for
|
||||
marked **UI** has no API: Play Console > your app > Policy and programs > **App content** (https://play.google.com/console), and App Store Connect for
|
||||
Apple. Data safety (Play) has an API: `storebound play data-safety`.
|
||||
|
||||
## Google Play · App content (UI)
|
||||
|
|
@ -48,7 +48,7 @@ content publicly.
|
|||
|
||||
## App Store (UI-only parts)
|
||||
|
||||
- App record: App Store Connect > Apps > + > New App. No API.
|
||||
- App record: App Store Connect > Apps > + > New App (https://appstoreconnect.apple.com/apps). No API.
|
||||
- App Privacy: `app-privacy.md`, then **Publish**.
|
||||
- App Review information: `storebound apple review-details` sends the contact, the demo account (from
|
||||
`.storebound/credentials.json`, masked in the log) and the notes.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Example App · App Store "App Privacy" answers
|
||||
|
||||
App Store Connect > your app > App Privacy. **Web UI only, there is no API.** Fill it from this table, then press
|
||||
App Store Connect > your app > App Privacy (https://appstoreconnect.apple.com/apps, then the app; `storebound console plan apple-app-privacy` gives the deep link). **Web UI only, there is no API.** Fill it from this table, then press
|
||||
**Publish** (saving alone does not publish it). Keep it consistent with the privacy policy and the Play Data safety
|
||||
answers (`dataSafety` in `storebound.config.ts`). **Edit to match your app.**
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Starting answers for a typical **subscription app**: auto-renewable subscriptions (free trial on the yearly plan), an account (email + password, or sign in
|
||||
with a provider), an analytics SDK acting as your processor, data synced to your own backend, no ads, no chat with
|
||||
strangers, general audience. **Edit every line to match your app**: these are your statements to the stores. Everything
|
||||
marked **UI** has no API: Play Console > your app > Policy and programs > **App content**, and App Store Connect for
|
||||
marked **UI** has no API: Play Console > your app > Policy and programs > **App content** (https://play.google.com/console), and App Store Connect for
|
||||
Apple. Data safety (Play) has an API: `storebound play data-safety`.
|
||||
|
||||
## Google Play · App content (UI)
|
||||
|
|
@ -48,7 +48,7 @@ content publicly.
|
|||
|
||||
## App Store (UI-only parts)
|
||||
|
||||
- App record: App Store Connect > Apps > + > New App. No API.
|
||||
- App record: App Store Connect > Apps > + > New App (https://appstoreconnect.apple.com/apps). No API.
|
||||
- App Privacy: `app-privacy.md`, then **Publish**.
|
||||
- App Review information: `storebound apple review-details` sends the contact, the demo account (from
|
||||
`.storebound/credentials.json`, masked in the log) and the notes.
|
||||
|
|
|
|||
83
tests/console-links.test.ts
Normal file
83
tests/console-links.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Console deep links (pitfall 71): every page key builds an absolute https URL with and without ids, and no message in
|
||||
// cli/ sends the owner to "App Store Connect > ..." / "Play Console > ..." / "App content > ..." without a URL.
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { CONSOLE_PAGES, type ConsolePage, consoleLink, consoleUrl, consoleUrlExact, playTrackPage, setConsoleIds } from '../cli/lib/console-links';
|
||||
|
||||
const IDS = { appleAppId: '1000000001', playDeveloperId: '5550000000000000001', playAppId: '4970000000000000002' };
|
||||
const PAGES = Object.keys(CONSOLE_PAGES) as ConsolePage[];
|
||||
|
||||
afterEach(() => setConsoleIds({}));
|
||||
|
||||
describe('console-links', () => {
|
||||
test('every page: absolute https URL without ids', () => {
|
||||
for (const p of PAGES) {
|
||||
const u = consoleUrl(p, {});
|
||||
expect(u).toMatch(/^https:\/\/(appstoreconnect\.apple\.com|play\.google\.com\/console)/);
|
||||
expect(() => new URL(u)).not.toThrow();
|
||||
expect(consoleLink(p, '', {})).toContain(`: ${u}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every page: id-specific URL with ids', () => {
|
||||
for (const p of PAGES) {
|
||||
const u = consoleUrl(p, IDS);
|
||||
expect(u.startsWith('https://')).toBe(true);
|
||||
expect(consoleUrlExact(p, IDS)).toBe(true);
|
||||
const page = CONSOLE_PAGES[p];
|
||||
if (page.scope === 'app') expect(u).toContain(page.store === 'apple' ? `/apps/${IDS.appleAppId}/` : `/developers/${IDS.playDeveloperId}/app/${IDS.playAppId}/`);
|
||||
if (page.store === 'play' && page.scope === 'developer') expect(u).toContain(`/developers/${IDS.playDeveloperId}/`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fallbacks: app list / developer chooser; reused verified shapes', () => {
|
||||
expect(consoleUrl('asc.age-rating', {})).toBe('https://appstoreconnect.apple.com/apps');
|
||||
expect(consoleUrl('play.data-safety', {})).toBe('https://play.google.com/console');
|
||||
expect(consoleUrl('play.data-safety', { playDeveloperId: IDS.playDeveloperId })).toBe(`https://play.google.com/console/u/0/developers/${IDS.playDeveloperId}/app-list`);
|
||||
expect(consoleUrl('play.dashboard', IDS)).toBe(`https://play.google.com/console/u/0/developers/${IDS.playDeveloperId}/app/${IDS.playAppId}/app-dashboard`);
|
||||
expect(consoleUrl('play.content-rating', IDS)).toEndWith('/app-content/content-rating-overview');
|
||||
expect(consoleUrl('play.create-app', IDS)).toEndWith(`/developers/${IDS.playDeveloperId}/create-new-app`);
|
||||
expect(consoleUrl('asc.keys', {})).toBe('https://appstoreconnect.apple.com/access/integrations/api');
|
||||
expect(consoleUrlExact('asc.app-privacy', {})).toBe(false);
|
||||
expect(consoleUrl('asc.app-info', { appleAppId: 'not-a-number' })).toBe('https://appstoreconnect.apple.com/apps');
|
||||
});
|
||||
|
||||
test('ids remembered from the loaded config', () => {
|
||||
setConsoleIds(IDS);
|
||||
expect(consoleUrl('asc.app-privacy')).toBe(`https://appstoreconnect.apple.com/apps/${IDS.appleAppId}/distribution/privacy`);
|
||||
expect(consoleLink('play.app-access')).toContain(`/app/${IDS.playAppId}/app-content/testing-credentials`);
|
||||
expect(playTrackPage('production')).toBe('play.production');
|
||||
expect(playTrackPage('beta')).toBe('play.releases');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guard: no plain console directions in cli/', () => {
|
||||
const PLAIN = /App Store Connect >|Play Console >|App content >/;
|
||||
const LINKED = /https:\/\/|consoleLink\(|consoleUrl\(|\.url\b|\$\{\w*[Uu]rl\}/; // ${url}: a variable that holds the absolute link
|
||||
const SKIP = new Set(['console-links.ts', 'embedded.ts']); // embedded.ts is generated from templates/ (checked below)
|
||||
const files = (dir: string): string[] => readdirSync(dir).flatMap(n => {
|
||||
const p = join(dir, n);
|
||||
return statSync(p).isDirectory() ? files(p) : p.endsWith('.ts') && !p.endsWith('.test.ts') && !SKIP.has(n) ? [p] : [];
|
||||
});
|
||||
|
||||
test('every message line with a console path carries an https URL or goes through console-links', () => {
|
||||
const bad: string[] = [];
|
||||
for (const f of files('cli')) {
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
const t = line.trim();
|
||||
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; // comments are not messages
|
||||
if (PLAIN.test(line) && !LINKED.test(line)) bad.push(`${f}:${i + 1}: ${t.slice(0, 120)}`);
|
||||
});
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test('templates: console directions in the generated docs carry a URL', () => {
|
||||
const bad: string[] = [];
|
||||
const walk = (dir: string): string[] => readdirSync(dir).flatMap(n => { const p = join(dir, n); return statSync(p).isDirectory() ? walk(p) : p.endsWith('.md') ? [p] : []; });
|
||||
for (const f of walk('templates'))
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => { if (/App Store Connect >|Play Console >/.test(line) && !/https:\/\//.test(line)) bad.push(`${f}:${i + 1}`); });
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue