store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest

This commit is contained in:
CRTkafa 2026-10-14 00:53:38 +08:00
parent 3cea2af4e1
commit 7ac028421a
33 changed files with 852 additions and 157 deletions

View file

@ -0,0 +1,48 @@
// Dev fixtures of the editors (made up): project.editable of the fixture folders and listing.live of store-only apps.
import type { EditableResult, ProjectSummary } from '../api';
import type { LiveListings } from '../../../../cli/listing-store';
const PLAY_LIMITS = { title: 30, short: 80, full: 4000, whatsNew: 500 };
const APPLE_LIMITS = { name: 30, subtitle: 30, promotionalText: 170, keywords: 100, description: 4000, whatsNew: 4000 };
const PLAY_CODE: Record<string, string> = { en: 'en-US', tr: 'tr-TR', ja: 'ja-JP', ar: 'ar' };
const APPLE_CODE: Record<string, string> = { en: 'en-US', tr: 'tr', ja: 'ja', ar: 'ar-SA' };
export function editableFixture(s: ProjectSummary | undefined): EditableResult | undefined {
if (!s) return undefined;
const langs = s.languages.map(id => ({ id, play: s.play ? [PLAY_CODE[id] ?? id] : [], apple: s.apple ? APPLE_CODE[id] ?? id : null, rtl: id === 'ar' }));
const text = (lang: string) => ({
title: s.name, short: `${s.name}: a calm, short promise (${lang})`, full: `${s.name} in ${lang}. What it does and why it feels good.\n\nFEATURES\n• One-thumb controls\n• Works offline`,
whatsNew: 'Smoother sync and a darker dark mode.',
});
return {
root: s.dir, files: { config: 'storebound.config.ts', play: s.play ? 'store/listing.md' : null, apple: s.apple ? 'store/appstore.md' : null, contact: '.storebound/' },
format: 'ts', git: { repo: true, uncommitted: ['store/appstore.md'] }, appName: s.name, appleName: s.name,
languages: langs,
play: s.play ? langs.map(l => ({ lang: l.play[0]!, language: l.id, codes: l.play, status: null, exists: l.id !== 'ar', values: l.id === 'ar' ? {} : text(l.play[0]!) })) : null,
apple: s.apple ? langs.map(l => ({ lang: l.apple!, language: l.id, status: l.id === 'ja' ? 'draft' : null, exists: true, values: {
subtitle: 'Tidy lists, calm days', promotionalText: 'New: shared lists.', keywords: 'lists,todo,planner,groceries,checklist,tasks,organize,shopping,reminders,family',
description: `${s.name} keeps what matters in order.`, whatsNew: 'Smoother sync.', whatToTest: 'Shared lists on two devices.',
} })) : null,
config: {
app: { name: s.name, privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com', copyright: '2026 Example Studio' },
apple: s.apple ? { bundleId: s.apple.bundleId, info: { categories: { primary: s.profile === 'game' ? 'GAMES' : 'PRODUCTIVITY', ...(s.profile === 'game' ? { primarySubOne: 'GAMES_PUZZLE' } : {}) }, ageRating: {}, contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT', free: true, reviewNotes: 'Sign in with the demo account.' }, testflight: { groups: [{ name: 'Studio team', internal: true }], description: { en: 'Thanks for testing!' } } } : null,
play: s.play ? { packageName: s.play.packageName, category: s.profile === 'game' ? 'Puzzle' : 'Productivity' } : null,
products: s.productIds.map(id => ({ id, type: 'consumable', usd: 0.99, listings: Object.fromEntries(s.languages.map(l => [l, { title: id.replace(/_/g, ' '), description: 'A handful of coins.' }])) })),
subscriptions: s.subscriptionIds.length ? [{ name: `${s.name} VIP`, listings: { en: { name: 'VIP' } }, subscriptions: s.subscriptionIds.map(id => ({ id, period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'VIP monthly', description: 'Every level, no ads.' } } })) }] : [],
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] } } },
appPrivacy: null, reviewAccess: { signInRequired: false }, accounts: null, social: null,
shots: s.shots ? { scenes: [{ id: 'hero', frames: [{ src: 'hero.png' }] }, { id: 'levels', frames: [{ src: 'levels.png' }] }, { id: 'shop', frames: [{ src: 'shop.png' }] }], feature: null } : null,
},
contact: { firstName: 'A***', lastName: 'L***', phone: '+15***', email: 'a***@example.com' }, demoAccount: null,
limits: { play: PLAY_LIMITS, apple: APPLE_LIMITS, iap: { name: 30, description: 45 }, appleSub: { name: 30, description: 45 }, playSub: { description: 200, benefits: 4, benefit: 40 }, reviewNotesBytes: 4000 },
};
}
export function liveFixture(target: string): LiveListings {
const play = /android:/.test(target), ios = /ios:/.test(target);
return {
play: play ? [{ language: 'en-US', title: 'Store Only App', short: 'A store-only app read live', full: 'Read straight from Google Play.' }, { language: 'tr-TR', title: 'Mağaza Uygulaması', short: 'Canlı okunan bir uygulama', full: 'Doğrudan Google Play\'den okundu.' }] : null,
apple: ios ? { version: '1.4.0', state: 'READY_FOR_SALE', editable: false, locales: [{ locale: 'en-US', values: { name: 'Store Only App', subtitle: 'Read live', promotionalText: 'Fresh from App Store Connect.', keywords: 'store,live', description: 'Read straight from App Store Connect.', whatsNew: 'Fixes.' } }] } : null,
errors: {},
};
}

View file

@ -7,6 +7,7 @@ import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleFo
type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api';
import { buildApps, type Overrides } from '../lib/identity';
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
import { editableFixture, liveFixture } from './editor-fixtures';
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
@ -436,12 +437,14 @@ function read(method: string, id: string, args: Record<string, unknown> = {}): T
case 'reviews.list': return ok(method, STORE_REVIEWS.filter(r => (play || r.store === 'apple') && (/ios:/.test(id) || r.store === 'play')));
case 'apple.testflight_status': return ok(method, TF['p-tidy']);
case 'apple.ppo_status': return ok(method, /ios:/.test(id) ? PPO_STORE : { tests: [] });
case 'listing.live': return ok(method, liveFixture(id));
default: return { method, ok: false, exitCode: 1, dryRun: false, command: `storebound ${method}`, output: `fixture: ${method} needs an app folder` };
}
}
switch (method) {
case 'project.icon': return ok(method, { path: SUMMARY[id]?.icon ?? null, dataUrl: ICONS[id] ?? null });
case 'project.listings': return ok(method, LISTINGS[id]);
case 'project.editable': return ok(method, editableFixture(SUMMARY[id]));
case 'reviews.drafts': return ok(method, DRAFTS[id]);
case 'health': return ok(method, HEALTH[id]);
case 'reviews.list': return ok(method, REVIEWS[id]);