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

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

View file

@ -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': {

View file

@ -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: [] },

View file

@ -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',

View file

@ -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',

View file

@ -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[];

View file

@ -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>
))}

View file

@ -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>

View 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>
);
}