diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 71c31bb..dcf4efd 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -193,8 +193,8 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_ if matches!(kind, Some(Kind::Read | Kind::Write | Kind::Owner)) { let id = project_id.ok_or_else(|| Fail::host(format!("{method} needs a project")))?; if id.starts_with("store:") { - // a store-only app: store reads only, through the sidecar's generated config - if kind != Some(Kind::Read) { + // a store-only app: store reads, and the few writes that send only what the call carries (dry runs here) + if kind != Some(Kind::Read) && !policy::store_only_write(&method, kind) { return Err(Fail::host("store-only apps are read-only: link a folder to change anything")); } let target = StoreTarget::parse(&id).map_err(Fail::host)?; @@ -205,7 +205,7 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_ o.insert("credentials".into(), Value::Object(creds)); } let res = r.call_job(&method, p, job.as_deref()).await?; - if readable(&res) { + if kind == Some(Kind::Read) && readable(&res) { let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso()); } return Ok(res); @@ -400,6 +400,9 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St Value::Object(o) => Value::Object(o), _ => return Err(Fail::host("args must be an object")), }; + if project_id.starts_with("store:") { + return store_write_with_confirm(&window, &app, &state, &r, &method, kind, &d, &project_id, args, job.as_deref()).await; + } let (proj, creds) = project_params(&state, &project_id)?; let mut dry_params = json!({ "project": proj.path, "args": args, "dryRun": true }); if !creds.is_empty() { @@ -431,6 +434,42 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result })) } +/// `write_with_confirm` for a store-only app (no folder): only STORE_ONLY_WRITES, with the store ids (`virtual`) and the +/// accounts of the store target; the dry run, the same native dialog, then a token bound to `{ virtual, args }`. +#[allow(clippy::too_many_arguments)] +async fn store_write_with_confirm(window: &WebviewWindow, app: &AppHandle, state: &State<'_, AppState>, r: &std::sync::Arc, method: &str, kind: Kind, d: &crate::sidecar::Descriptor, project_id: &str, args: Value, job: Option<&str>) -> Res { + if !policy::store_only_write(method, Some(kind)) { + return Err(Fail::host("store-only apps are read-only for this: link a folder to change it")); + } + let target = StoreTarget::parse(project_id).map_err(Fail::host)?; + let creds = target_params(state, &target)?; + let virt = target.virtual_project(); + let name = virt["name"].as_str().unwrap_or("store app").to_string(); + let mut dry_params = json!({ "virtual": virt, "args": args, "dryRun": true }); + if !creds.is_empty() { + dry_params["credentials"] = Value::Object(creds.clone()); + } + let dry = r.call_job(method, dry_params, job).await?; + if dry["ok"] != Value::Bool(true) { + return Ok(json!({ "confirmed": false, "stage": "dry-run", "dryRun": dry })); + } + let summary = summary_for(method, kind, &d.title, &d.description, &name, d.network, d.destructive); + let text = format!("{summary}\n\nDry run ({}):\n{}", dry["command"].as_str().unwrap_or_default(), dialog_excerpt(dry["output"].as_str().unwrap_or_default())); + let (title, ok) = dialog_labels(kind, &d.title); + let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string()); + if !confirm_dialog_with(window, title, tilde(&text, home.as_deref()), ok).await? { + return Ok(json!({ "confirmed": false, "stage": "dialog", "dryRun": dry })); + } + let bound = json!({ "virtual": virt, "args": args }); + let token = r.mint(method, &bound).map_err(Fail::host)?; + let mut real = json!({ "virtual": virt, "args": args, "dryRun": false, "confirmToken": token }); + if !creds.is_empty() { + real["credentials"] = Value::Object(creds); + } + let result = r.call_job(method, real, job).await?; + Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result })) +} + /// An app folder as the window sees it: `missing` when the folder is gone (moved, renamed, deleted, or an old /// worktree), so the app shows "Folder not found" with Locate / Remove instead of reading it. #[derive(Debug, Clone, Serialize)] diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index 26db2e7..a5ee29b 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -122,6 +122,15 @@ pub const NEW_FOLDER_METHODS: &[&str] = &["project.init", "import.fastlane"]; pub const CONSOLE_FILL: &str = "console.fill"; pub const CONSOLE_FILL_NOTE: &str = "Console pages change. This is AI-assisted: review every field in the browser and press the final button (Publish, Submit, Save or Create) yourself. storebound moves through the pages for you and never submits or publishes; App Privacy answers stay a draft until you press Publish."; +/// Writes a store-only app (no folder) may run: they send only what the call carries (one language's texts), never +/// the sidecar's generated config. Dry run from the window like every write; the real run only through +/// `write_with_confirm`, its token bound to the store ids (`virtual`) instead of a folder. +pub const STORE_ONLY_WRITES: &[&str] = &["listing.set"]; + +pub fn store_only_write(method: &str, kind: Option) -> bool { + kind == Some(Kind::Write) && STORE_ONLY_WRITES.contains(&method) +} + /// Reads that are never kept in the offline cache (big: one rendered image as a data: URL). pub const NO_CACHE_METHODS: &[&str] = &["project.render"]; diff --git a/apps/desktop/src-tauri/tests/d3_writes.rs b/apps/desktop/src-tauri/tests/d3_writes.rs index 8eb9ee0..ffbd2c9 100644 --- a/apps/desktop/src-tauri/tests/d3_writes.rs +++ b/apps/desktop/src-tauri/tests/d3_writes.rs @@ -18,6 +18,27 @@ fn owner_actions_are_dry_runs_from_the_window() { assert_eq!(w["dryRun"], json!(true)); } +#[test] +fn store_only_apps_take_only_the_listing_text_write() { + use storebound_desktop::policy::{store_only_write, STORE_ONLY_WRITES}; + assert_eq!(STORE_ONLY_WRITES, &["listing.set"]); + assert!(store_only_write("listing.set", Some(Kind::Write))); + // the folder writes, owner actions and reads are not store-only writes + for m in ["play.listing", "apple.listing", "project.edit", "play.images"] { + assert!(!store_only_write(m, Some(Kind::Write)), "{m}"); + } + assert!(!store_only_write("listing.set", Some(Kind::Owner))); + assert!(!store_only_write("listing.set", Some(Kind::Read))); + // a dry run from the window, like every write + let p = sanitize_js_call("listing.set", Some(Kind::Write), &json!({ "args": { "store": "play", "lang": "en-US", "fields": "{\"short\":\"x\"}" } })).unwrap(); + assert_eq!(p["dryRun"], json!(true)); + // the token binds the store ids instead of a folder: another app's ids give another token + let secret = [9u8; 32]; + let a = json!({ "virtual": { "name": "com.example.a", "play": { "packageName": "com.example.a" } }, "args": { "lang": "en-US" } }); + let b = json!({ "virtual": { "name": "com.example.b", "play": { "packageName": "com.example.b" } }, "args": { "lang": "en-US" } }); + assert_ne!(mint(&secret, "listing.set", &a, 1_000, "n1").unwrap(), mint(&secret, "listing.set", &b, 1_000, "n1").unwrap()); +} + #[test] fn owner_dialogs_name_the_action() { let (title, ok) = dialog_labels(Kind::Owner, "Change the Play staged rollout"); diff --git a/apps/desktop/src/dev/editor-fixtures.ts b/apps/desktop/src/dev/editor-fixtures.ts new file mode 100644 index 0000000..ebaa69d --- /dev/null +++ b/apps/desktop/src/dev/editor-fixtures.ts @@ -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 = { en: 'en-US', tr: 'tr-TR', ja: 'ja-JP', ar: 'ar' }; +const APPLE_CODE: Record = { 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: {}, + }; +} diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 406b2f4..e737041 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -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 = {}): 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]); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7e0da5c..6a6d959 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1723,4 +1723,21 @@ export const en = { 'ed.moveDown': 'Move {id} down', 'ed.applyArtSub': 'Render, then upload: an upload replaces the store\'s screenshot set (dry run first).', 'ed.loadFailed': 'Could not read the app folder\'s files: {error}', + 'ed.storeOnlyTitle': 'Edit the listing on the stores', + 'ed.storeOnlySub': 'No app folder: the live texts, changed straight on each store per language.', + 'ed.storeOnlyNote': 'This app has no folder, so the texts go straight to the store: each Apply shows the store\'s dry run, then asks. URLs, contact and screenshots need an app folder (Link a folder).', + 'ed.storeOnlyApplyPlay': 'Google Play {lang}: preview', + 'ed.storeOnlyApplyApple': 'App Store {lang}: preview', + 'ed.liveEmpty': 'The live texts are not read yet', + 'ed.noEditableVersion': 'No App Store version is being prepared: name, subtitle and promotional text can change now; description, keywords and what\'s new change with a new version.', + 'ed.captionsTitle': 'Screenshot captions', + 'ed.captionsSub': 'Per language and scene ({file}). Render again after saving; long lines are checked by shots check.', + 'ed.eyebrow': 'Eyebrow', + 'ed.titleLine': 'Title line {n}', + 'ed.subline': 'Subline', + 'ed.contentRating': 'Content rating answers (Play IARC)', + 'ed.contentRatingHint': '{ "category": "game", "answers": { ... } }. No API: Fill in console types them; you press Submit.', + 'ed.targetAudience': 'Target audience and ads (Play)', + 'ed.appContent': 'App content declarations (Play)', + 'ed.appContentHint': 'Government, financial and health features, advertising ID. No API: the console plan step uses them.', } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 40b3d3d..3d7cc9e 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1725,4 +1725,21 @@ export const tr: Record = { 'ed.moveDown': '{id} sahnesini aşağı taşı', 'ed.applyArtSub': 'Oluştur, sonra yükle: yükleme mağazadaki ekran görüntüsü setinin yerini alır (önce deneme çalıştırması).', 'ed.loadFailed': 'Uygulama klasörünün dosyaları okunamadı: {error}', + 'ed.storeOnlyTitle': 'Listelemeyi mağazalarda düzenle', + 'ed.storeOnlySub': 'Uygulama klasörü yok: canlı metinler, her mağazada dil başına doğrudan değiştirilir.', + 'ed.storeOnlyNote': 'Bu uygulamanın klasörü yok, metinler doğrudan mağazaya gider: her Uygula önce mağazanın deneme çalıştırmasını gösterir, sonra sorar. URL\'ler, iletişim ve ekran görüntüleri için uygulama klasörü gerekir (Klasör bağla).', + 'ed.storeOnlyApplyPlay': 'Google Play {lang}: önizle', + 'ed.storeOnlyApplyApple': 'App Store {lang}: önizle', + 'ed.liveEmpty': 'Canlı metinler henüz okunmadı', + 'ed.noEditableVersion': 'Hazırlanan bir App Store sürümü yok: ad, alt başlık ve tanıtım metni şimdi değişebilir; açıklama, anahtar kelimeler ve yenilikler yeni sürümle değişir.', + 'ed.captionsTitle': 'Ekran görüntüsü başlıkları', + 'ed.captionsSub': 'Dil ve sahne başına ({file}). Kaydettikten sonra yeniden oluşturun; uzun satırları shots check denetler.', + 'ed.eyebrow': 'Üst satır', + 'ed.titleLine': 'Başlık satırı {n}', + 'ed.subline': 'Alt satır', + 'ed.contentRating': 'İçerik derecelendirme yanıtları (Play IARC)', + 'ed.contentRatingHint': '{ "category": "game", "answers": { ... } }. API yok: Konsolda doldur bunları yazar; Gönder\'e siz basarsınız.', + 'ed.targetAudience': 'Hedef kitle ve reklamlar (Play)', + 'ed.appContent': 'Uygulama içeriği beyanları (Play)', + 'ed.appContentHint': 'Devlet, finans ve sağlık özellikleri, reklam kimliği. API yok: konsol planı adımı bunları kullanır.', }; diff --git a/apps/desktop/src/lib/edits.ts b/apps/desktop/src/lib/edits.ts index 72ddb1d..1fc8248 100644 --- a/apps/desktop/src/lib/edits.ts +++ b/apps/desktop/src/lib/edits.ts @@ -7,7 +7,11 @@ export type EditOp = | { kind: 'config'; path: string; value?: unknown; remove?: boolean } | { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string } - | { kind: 'contact'; field: 'firstName' | 'lastName' | 'phone' | 'email'; value: string }; + | { kind: 'contact'; field: 'firstName' | 'lastName' | 'phone' | 'email'; value: string } + | { kind: 'shotsCopy'; lang: string; scene: string; field: 'eyebrow' | 'title' | 'sub'; line?: 0 | 1; value: string }; + +/** An edit without its value: what a field edits. */ +export type FieldOp = { kind: 'config'; path: string } | Omit, 'value'> | Omit, 'value'> | Omit, 'value'>; /** Which editor (tab) a pending edit belongs to, for the unsaved marks. */ export type EditArea = 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art'; @@ -15,7 +19,8 @@ export type EditArea = 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art' export interface Pending { key: string; op: EditOp; area: EditArea; /** The saved value, to show "was" and to drop an edit typed back to it. */ saved: unknown } export const keyOf = (op: EditOp): string => - op.kind === 'config' ? `config:${op.path}` : op.kind === 'listing' ? `listing:${op.store}:${op.lang}:${op.field}` : `contact:${op.field}`; + op.kind === 'config' ? `config:${op.path}` : op.kind === 'listing' ? `listing:${op.store}:${op.lang}:${op.field}` + : op.kind === 'shotsCopy' ? `shotsCopy:${op.lang}:${op.scene}:${op.field}${op.line === undefined ? '' : `:${op.line}`}` : `contact:${op.field}`; const valueOf = (op: EditOp): unknown => (op.kind === 'config' ? (op.remove ? undefined : op.value) : op.value); diff --git a/apps/desktop/src/lib/freshness.ts b/apps/desktop/src/lib/freshness.ts index 7f68546..9104ca9 100644 --- a/apps/desktop/src/lib/freshness.ts +++ b/apps/desktop/src/lib/freshness.ts @@ -16,6 +16,7 @@ export const TTL: Record = { 'prices.current': 30 * MIN, 'project.listings': 30 * MIN, 'project.editable': 30 * MIN, + 'listing.live': 30 * MIN, 'apple.ppo_status': 30 * MIN, validate: 30 * MIN, scan: 30 * MIN, @@ -56,11 +57,13 @@ export function readsFor(o: { folder: boolean; ios: boolean; android: boolean }) if (o.android) add('play.status'); if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); } if (o.folder) { - add('scan'); add('project.listings'); + add('scan'); add('project.listings'); add('project.editable'); if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); } add('analytics.compare'); add('aso.audit'); } if (o.ios) add('apple.ppo_status'); + // store-only apps: the listing editor's live texts + if (!o.folder && (o.ios || o.android)) add('listing.live'); return r; } diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index bb4d335..8a244cb 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -8,7 +8,7 @@ export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'he export type AppTab = (typeof APP_TABS)[number]; /** Tabs of a store-only app: store reads only (catalog, prices, listings, art, app info and checks need the app folder; * Growth shows the Product Page Optimization tests only). */ -export const STORE_TABS: readonly AppTab[] = ['releases', 'health', 'reviews', 'testflight', 'growth']; +export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'health', 'reviews', 'testflight', 'growth']; export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); export const TAB_KEY: Record = { releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews', diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 6c528c0..0d0eec9 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -24,6 +24,7 @@ import { ReleasesTab } from './tabs/Releases'; import { ReviewsTab } from './tabs/Reviews'; import { TestFlightTab } from './tabs/TestFlight'; import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor'; +import { StoreListingEditor } from './editors/StoreListingEditor'; import { useEdits } from '../ui/Editor'; export const ascUrl = (appId: string | null | undefined): string => (appId ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(appId)}/distribution` : 'https://appstoreconnect.apple.com/apps'); @@ -150,7 +151,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tabs.map((x, i) => ( ))} @@ -160,6 +161,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'catalog' && app.project && } {tab === 'prices' && app.project && } {tab === 'listings' && app.project && <>} + {tab === 'listings' && !app.project && } {tab === 'art' && app.project && } {tab === 'health' && } {tab === 'reviews' && } diff --git a/apps/desktop/src/screens/editors/AppInfoEditor.tsx b/apps/desktop/src/screens/editors/AppInfoEditor.tsx index cc2b320..053f4ea 100644 --- a/apps/desktop/src/screens/editors/AppInfoEditor.tsx +++ b/apps/desktop/src/screens/editors/AppInfoEditor.tsx @@ -98,6 +98,9 @@ function Declarations({ project, data }: { project: Project; data: EditableResul {data.play && } {data.apple && } + {data.play && } + {data.play && } + {data.play && } ); } diff --git a/apps/desktop/src/screens/editors/ArtEditor.tsx b/apps/desktop/src/screens/editors/ArtEditor.tsx index 4829e24..5becbf4 100644 --- a/apps/desktop/src/screens/editors/ArtEditor.tsx +++ b/apps/desktop/src/screens/editors/ArtEditor.tsx @@ -1,6 +1,7 @@ // Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the // raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config; // then render, and upload to each store with the dry run in the window (uploads replace the store's sets). +import { useState } from 'react'; import type { EditableResult, Project } from '../../api'; import { t } from '../../i18n'; import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor'; @@ -44,7 +45,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: ))} - + {data.shotsCopy && s.id)} />} +
{t('act.render')}
@@ -55,3 +57,35 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
); } + +/** Screenshot captions per language and scene (the shots copy file): eyebrow, the two title lines, the subline. */ +function Captions({ project, data, scenes }: { project: Project; data: EditableResult; scenes: string[] }) { + const copy = data.shotsCopy ?? {}; + const langs = data.languages.map(l => l.id); + const [lang, setLang] = useState(langs[0] ?? ''); + const L = langs.includes(lang) ? lang : langs[0] ?? ''; + return ( + +
+ {langs.map(x => )} +
+
+ {scenes.map(id => { + const c = copy[L]?.[id] ?? {}; + const title = Array.isArray(c.title) ? c.title : []; + return ( +
+
{id}
+
+ + + + +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/desktop/src/screens/editors/StoreListingEditor.tsx b/apps/desktop/src/screens/editors/StoreListingEditor.tsx new file mode 100644 index 0000000..a1640cc --- /dev/null +++ b/apps/desktop/src/screens/editors/StoreListingEditor.tsx @@ -0,0 +1,89 @@ +// The listing editor of a store-only app (no app folder, so no files to save into): the live texts per language +// (listing.live), edited in place with the same counters and checks, and applied straight to the store per language +// (listing.set: the store's dry run in the window, the native dialog, then the real run, which changes only the edited +// fields). URLs, contact and app details stay with an app folder (they come from its config). +import { useState } from 'react'; +import type { Project } from '../../api'; +import { type Key, t } from '../../i18n'; +import { appleLocaleIssues, playLangIssues, type FieldIssue } from '../../../../../cli/lib/listing-rules'; +import type { LiveListings } from '../../../../../cli/listing-store'; +import type { UnifiedApp } from '../../lib/identity'; +import { keyOf } from '../../lib/edits'; +import { useRead } from '../../store'; +import { ApplyPanel, type FieldIssueView, TextField, useEdits } from '../../ui/Editor'; +import { Icon, StoreGlyph } from '../../ui/icons'; +import { Card, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; + +const PLAY: { f: 'title' | 'short' | 'full'; label: Key; limit: number; rows?: number }[] = [ + { f: 'title', label: 'lst.title', limit: 30 }, { f: 'short', label: 'lst.short', limit: 80 }, { f: 'full', label: 'lst.full', limit: 4000, rows: 10 }, +]; +const APPLE: { f: 'name' | 'subtitle' | 'promotionalText' | 'keywords' | 'description' | 'whatsNew'; label: Key; limit: number; rows?: number }[] = [ + { f: 'name', label: 'lst.name', limit: 30 }, { f: 'subtitle', label: 'lst.subtitle', limit: 30 }, { f: 'promotionalText', label: 'lst.promo', limit: 170, rows: 3 }, + { f: 'keywords', label: 'lst.keywords', limit: 100 }, { f: 'description', label: 'lst.description', limit: 4000, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', limit: 4000, rows: 4 }, +]; +const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg })); + +function Column({ project, store, lang, saved, appName, editable }: { project: Project; store: 'play' | 'apple'; lang: string; saved: Record; appName: string; editable: boolean }) { + const e = useEdits(); + const defs = store === 'play' ? PLAY : APPLE; + const vals: Record = {}; + const changed: Record = {}; + for (const d of defs) { + const p = e.get(project.id, keyOf({ kind: 'listing', store, lang, field: d.f, value: '' })); + vals[d.f] = p && p.op.kind === 'listing' ? p.op.value : saved[d.f] ?? ''; + if (p && p.op.kind === 'listing') changed[d.f] = p.op.value; + } + const issues = store === 'play' ? playLangIssues(lang, { title: vals.title || undefined, short: vals.short ?? '', full: vals.full ?? '' }, appName) + : appleLocaleIssues(lang, vals, appName); + const errors = issues.filter(i => i.level === 'error' && changed[i.field] !== undefined).length; + const fields = JSON.stringify(changed); + const n = Object.keys(changed).length; + return ( +
+
{store === 'play' ? 'Google Play' : 'App Store'}{lang}
+ {store === 'apple' && !editable && {t('ed.noEditableVersion')}} + {defs.map(d => ( + + ))} + 0} hint={errors ? t('ed.fixFirst') : undefined} blocked={null} + onApplied={() => { for (const f of Object.keys(changed)) e.set(project.id, { kind: 'listing', store, lang, field: f, value: changed[f]! }, 'listings', changed[f]); }} /> +
+ ); +} + +export function StoreListingEditor({ app }: { app: UnifiedApp }) { + const r = useRead(app.target, 'listing.live', { network: true, title: t('tab.listings') }); + const [lang, setLang] = useState(''); + // the window's edit store is keyed by app: a store-only app is keyed by its store target + const project = { id: app.target, path: '', name: app.name, playAccount: null, appleAccount: null, added: '' } as Project; + if (!r.data) { + if (r.loading && !r.error) return ; + return {t('tf.read')}}>{r.error ?? t('ed.storeOnlyNote')}; + } + const L = r.data; + const langs = [...new Set([...(L.play ?? []).map(x => x.language), ...(L.apple?.locales ?? []).map(x => x.locale)])].sort(); + const cur = langs.includes(lang) ? lang : langs[0] ?? ''; + const pl = L.play?.find(x => x.language === cur); + const al = L.apple?.locales.find(x => x.locale === cur); + return ( + <> + {Object.entries(L.errors).map(([s, e]) => )} + } id="store-listing-editor"> +
+ {t('ed.storeOnlyNote')} +
+ {langs.map(x => )} +
+
+ {pl && } + {al && } appName={app.name} editable={L.apple!.editable} />} + {!pl && !al && {t('ed.noStoreForLang', { lang: cur })}} +
+
+
+ + ); +} diff --git a/apps/desktop/src/ui/Editor.tsx b/apps/desktop/src/ui/Editor.tsx index 7479ebe..0308342 100644 --- a/apps/desktop/src/ui/Editor.tsx +++ b/apps/desktop/src/ui/Editor.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'; import { api, type EditableResult, type EditResult, type Project, type ToolResult } from '../api'; import { t } from '../i18n'; -import { type EditArea, type EditOp, edits, keyOf } from '../lib/edits'; +import { type EditArea, type EditOp, edits, type FieldOp, keyOf } from '../lib/edits'; import { failureText } from '../lib/results'; import { useRead, useWrite } from '../store'; import { Icon, type IconName } from './icons'; @@ -61,7 +61,7 @@ interface FieldBase { project: Project; area: EditArea; label: string; hint?: Re /** A text field: one line or several, a live character counter against the store limit. */ export function TextField({ project, area, op, saved, label, limit, multiline, rows = 3, placeholder, hint, issues = [], mono, id, disabled, kind }: FieldBase & { /** The edit without its value: { kind: 'listing', store, lang, field } or { kind: 'config', path } or contact. */ - op: Omit, 'value'> | { kind: 'config'; path: string } | { kind: 'contact'; field: Extract['field'] }; + op: FieldOp; saved: string; limit?: number; multiline?: boolean; rows?: number; placeholder?: string; mono?: boolean; kind?: 'url' | 'email' | 'text'; }) { const e = useEdits(); @@ -263,9 +263,10 @@ export function EditBar({ project, area, data, files, onSaved }: { project: Proj } /** A store write with its dry run in the window first: the plan (old -> new per store and language), then Apply. */ -export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked }: { +export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked, onApplied }: { project: Project; method: string; args?: Record; label: string; icon?: IconName; done: string; disabled?: boolean; hint?: string; /** Unsaved edits that the store write would not include. */ blocked?: string | null; + /** After the real run went through. */ onApplied?: () => void; }) { const w = useWrite(project.id); const [st, setSt] = useState<{ loading: boolean; text: string; ok: boolean } | null>(null); @@ -289,7 +290,7 @@ export function ApplyPanel({ project, method, args = {}, label, icon = 'upload',
{st.text}
- {st.ok && setSt(null) }}>{t('ed.applyNow')}} + {st.ok && { setSt(null); onApplied?.(); } }}>{t('ed.applyNow')}}
)} diff --git a/apps/desktop/tests/scheduler.test.ts b/apps/desktop/tests/scheduler.test.ts index 8124e94..48044f7 100644 --- a/apps/desktop/tests/scheduler.test.ts +++ b/apps/desktop/tests/scheduler.test.ts @@ -225,8 +225,8 @@ describe('freshness', () => { expect(lanesOf('health', { ios: false, android: true })).toEqual(['play']); expect(lanesOf('scan', { ios: true, android: true })).toEqual(['local']); const all = readsFor({ folder: true, ios: true, android: true }).map(r => r.method); - expect(all).toEqual(expect.arrayContaining(['apple.status', 'play.status', 'apple.testflight_status', 'reviews.list', 'health', 'catalog.diff', 'prices.current', 'project.listings', 'validate', 'scan', 'analytics.compare', 'aso.audit', 'apple.ppo_status'])); - expect(readsFor({ folder: false, ios: false, android: true }).map(r => r.method)).toEqual(['play.status', 'reviews.list', 'health']); + expect(all).toEqual(expect.arrayContaining(['apple.status', 'play.status', 'apple.testflight_status', 'reviews.list', 'health', 'catalog.diff', 'prices.current', 'project.listings', 'project.editable', 'validate', 'scan', 'analytics.compare', 'aso.audit', 'apple.ppo_status'])); + expect(readsFor({ folder: false, ios: false, android: true }).map(r => r.method)).toEqual(['play.status', 'reviews.list', 'health', 'listing.live']); expect(readKey('p1', 'reviews.list', { days: 30 })).toBe(readKey('p1', 'reviews.list', { days: 30 })); expect(readKey('p1', 'reviews.list', { days: 30 })).not.toBe(readKey('p1', 'reviews.list', {})); expect(looksThrottled('GET /v1/apps -> 429 Too Many Requests')).toBe(true); diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 195a688..647dd64 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -28,8 +28,10 @@ export const WRAPPED: { apple: Record; play: Record; play: Record; play: Record; play: Record\"] [--force] [--dry] [--json]", "storebound listing check [--json] limits, keyword rules, untranslated and draft sections; exit 1 on a problem", - "storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker" + "storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker", + "storebound listing live [--json] | set --store play|apple --lang --fields '' [--dry] the texts straight on" ], "onboard": [ "storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]" diff --git a/cli/edit.ts b/cli/edit.ts index 1cf37d5..75dd944 100644 --- a/cli/edit.ts +++ b/cli/edit.ts @@ -9,6 +9,7 @@ // { "kind": "listing", "store": "apple", "lang": "en-US", "field": "subtitle", "value": "Lists that stay tidy" } // { "kind": "listing", "store": "play", "lang": "tr-TR", "field": "short", "value": "..." } // { "kind": "contact", "field": "email", "value": "review@example.com" } (.storebound/credentials.json, masked) +// { "kind": "shotsCopy", "lang": "en", "scene": "lists", "field": "title", "line": 0, "value": "Every list," } // Every run plans first: old -> new per edit, the diff per file and the checks (store limits, required fields, keyword // and metadata rules of `check --aso` / `scan`, URLs, and the whole config validated as `check` loads it). --dry stops // there. An error stops the write; the files are written only when every check passes (comments, order and @@ -35,6 +36,7 @@ export function sources(ctx: Ctx): EditSources { play: config.play ? file(resolve(config.play.listing ?? 'store/listing.md')) : null, apple: config.apple ? file(resolve(config.apple.listing ?? 'store/appstore.md')) : null, contact: file(contact), + shotsCopy: config.shots?.copy ? file(resolve(config.shots.copy)) : null, appName: config.app.name, appleName: appleName(config), }; diff --git a/cli/lib/config-path.ts b/cli/lib/config-path.ts index 650004c..b889c83 100644 --- a/cli/lib/config-path.ts +++ b/cli/lib/config-path.ts @@ -152,8 +152,21 @@ function step(t: string): string { return m ? (m[1]!.startsWith('\t') ? '\t' : m[1]!.length >= 4 && m[1]!.length % 4 === 0 && !/\n {2}\S/.test(t) ? ' ' : ' ') : ' '; } +/** JSON files are edited as text too (their own formatting, e.g. one scene per line in the shots copy file, stays): + * values as JSON, keys in double quotes, no trailing commas. Set for the duration of one setByPath call. */ +let JSON_MODE = false; + +/** A JSON literal: one line when short, else indented like the line it sits on. */ +function jsonLit(v: unknown, ind: string): string { + const one = JSON.stringify(v); + if (one.length <= 100 || typeof v !== 'object' || v === null) return one; + return JSON.stringify(v, null, 2).split('\n').map((l, i) => (i ? ind + l : l)).join('\n'); +} +const lit = (v: unknown, ind: string): string => (JSON_MODE ? jsonLit(v, ind) : toTs(v, ind)); + /** A literal for `v` in the style of the value it replaces (the quote character of an old string). */ function literal(v: unknown, ind: string, old?: string): string { + if (JSON_MODE) return jsonLit(v, ind); if (typeof v === 'string' && old && (old[0] === '"' || old[0] === '`') && !v.includes('\n')) { if (old[0] === '"') return JSON.stringify(v); if (!/[`$\\]/.test(v)) return `\`${v}\``; @@ -167,8 +180,13 @@ const plain = (src: string): boolean => /^(['"`]|-?\d|true\b|false\b|null\b|unde /** The config text with the value at `path` set (undefined: removed). */ export function setByPath(text: string, file: string, path: string | PathPart[], value: unknown): string { const parts = typeof path === 'string' ? parsePath(path) : path; - if (file.endsWith('.json')) return setJson(text, parts, value); - let open = rootObject(text); + JSON_MODE = file.endsWith('.json'); + try { return setInText(text, parts, value); } finally { JSON_MODE = false; } +} + +function setInText(text: string, parts: PathPart[], value: unknown): string { + let open = JSON_MODE ? text.search(/\S/) : rootObject(text); + if (JSON_MODE && text[open] !== '{') throw new Error('the JSON file is not an object'); for (let n = 0; n < parts.length; n++) { const part = parts[n]!; const last = n === parts.length - 1; @@ -235,26 +253,27 @@ function evalString(s: string): string { } function insertMember(t: string, open: number, close: number, list: Member[], key: string, value: unknown): string { - const k = /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`; + const k = JSON_MODE ? JSON.stringify(key) : /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`; + const end = JSON_MODE ? '' : ','; const body = t.slice(open + 1, close); if (!body.includes('\n')) { - const lit = toTs(value, lineIndent(t, open)); + const l1 = lit(value, lineIndent(t, open)); const inner = body.trim().replace(/,$/, ''); - return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${lit} ${t.slice(close)}`; + return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${l1} ${t.slice(close)}`; } const lastM = list.at(-1); const ind = lastM ? lineIndent(t, lastM.start) : lineIndent(t, open) + step(t); - const lit = toTs(value, ind); + const l2 = lit(value, ind); if (lastM) { const needComma = t.slice(lastM.valueEnd, lastM.end).indexOf(',') < 0; // after the last member's comma and any comment on the same line - let at = lastM.end; + let at = needComma ? lastM.valueEnd : lastM.end; const nl = t.indexOf('\n', at); if (nl >= 0 && nl < close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(at, nl))) at = nl; const pre = needComma ? t.slice(0, lastM.valueEnd) + ',' + t.slice(lastM.valueEnd, at) : t.slice(0, at); - return `${pre}\n${ind}${k}: ${lit},${t.slice(at)}`; + return `${pre}\n${ind}${k}: ${l2}${end}${t.slice(at)}`; } - return `${t.slice(0, open + 1)}\n${ind}${k}: ${lit},${t.slice(open + 1)}`; + return `${t.slice(0, open + 1)}\n${ind}${k}: ${l2}${end}${t.slice(open + 1)}`; } function removeMember(t: string, m: Member, list: Member[], open: number, close: number): string { @@ -266,32 +285,14 @@ function removeMember(t: string, m: Member, list: Member[], open: number, close: const start = ownLine ? ls : m.start; let out = t.slice(0, start) + t.slice(end); if (list.length === 1 && !t.slice(open, close).includes('\n')) out = t.slice(0, open + 1) + t.slice(close); + else if (JSON_MODE && m === list.at(-1) && list.length > 1) { + // JSON has no trailing comma: drop the one after the member that is now the last + const prev = list[list.length - 2]!; + const comma = t.indexOf(',', prev.valueEnd); + if (comma >= 0 && comma < prev.end) out = out.slice(0, comma) + out.slice(comma + 1); + } else if (m === list.at(-1) && t.slice(m.valueEnd, m.end).indexOf(',') < 0 && list.length > 1) { // the previous member keeps a trailing comma: valid in TS and JS } return out; } - -function setJson(text: string, parts: PathPart[], value: unknown): string { - const root = JSON.parse(text) as Record; - let cur: unknown = root; - for (let n = 0; n < parts.length; n++) { - const p = parts[n]!; - const last = n === parts.length - 1; - if ('key' in p) { - if (typeof cur !== 'object' || cur === null || Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not an object`); - const o = cur as Record; - if (last) { if (value === undefined) delete o[p.key]; else o[p.key] = value; break; } - if (o[p.key] === undefined) { if (value === undefined) return text; o[p.key] = {}; } - cur = o[p.key]; - } else { - if (!Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not a list`); - const i = 'index' in p ? p.index : cur.findIndex(x => x && typeof x === 'object' && (x as Record)[p.match] === p.eq); - if (i < 0 || i >= cur.length) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`); - if (last) { if (value === undefined) cur.splice(i, 1); else cur[i] = value; break; } - cur = cur[i]; - } - } - const ind = /\n([ \t]+)"/.exec(text)?.[1] ?? ' '; - return JSON.stringify(root, null, ind) + (text.endsWith('\n') ? '\n' : ''); -} diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 3e099f7..12909da 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -420,7 +420,9 @@ export interface EditableListing { lang: string; language: string; status: strin /** project.editable (`edit --show --json`): what the desktop's editors show and change. */ export interface EditableResult { root: string; - files: { config: string; play: string | null; apple: string | null; contact: string }; + files: { config: string; play: string | null; apple: string | null; contact: string; shotsCopy?: string | null }; + /** The screenshot captions (shots copy file): language -> scene -> eyebrow, title (two lines), sub. */ + shotsCopy?: Record>> | null; format: 'ts' | 'js' | 'json'; /** The app folder is a git work tree; which of the editable files have uncommitted changes. storebound never commits. */ git: { repo: boolean; uncommitted: string[] }; @@ -435,6 +437,8 @@ export interface EditableResult { products: Record[]; subscriptions: Record[]; dataSafety: Record | null; appPrivacy: Record | null; reviewAccess: Record | null; accounts: Record | null; social: Record | null; + /** Console form answers (Play content rating, target audience, App content declarations; the App Store record). */ + console?: Record | null; /** Screenshot scenes in store order and the feature graphic (shots). */ shots: { scenes: { id: string; frames?: { src: string }[] }[]; feature: Record | null } | null; }; diff --git a/cli/lib/edit-values.ts b/cli/lib/edit-values.ts index dc984db..a42f459 100644 --- a/cli/lib/edit-values.ts +++ b/cli/lib/edit-values.ts @@ -58,12 +58,16 @@ export function editableValues(l: Loaded): EditableResult { apple.push({ lang: lang.apple, language: lang.id, status: s?.status ?? null, exists: !!s, values: s?.values ?? {} }); } } - const files = [path, ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])]; + let shotsCopy: EditableResult['shotsCopy'] = null; + const copyFile = config.shots?.copy ? resolve(config.shots.copy) : null; + try { if (copyFile && existsSync(copyFile)) shotsCopy = JSON.parse(readFileSync(copyFile, 'utf8')) as EditableResult['shotsCopy']; } catch { /* broken JSON: shots check names it */ } + const files = [path, ...(copyFile ? [copyFile] : []), ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])]; const { signing: _s, build: _b, adhoc: _a, ...appleRest } = config.apple ?? ({} as NonNullable); const { build: _pb, ...playRest } = config.play ?? ({} as NonNullable); return { root, - files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile) }, + files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile), shotsCopy: copyFile ? rel(copyFile) : null }, + shotsCopy, format: path.endsWith('.json') ? 'json' : path.endsWith('.js') ? 'js' : 'ts', git: gitState(root, files), appName: config.app.name, @@ -74,6 +78,7 @@ export function editableValues(l: Loaded): EditableResult { config: JSON.parse(JSON.stringify({ app: config.app, apple: config.apple ? appleRest : null, play: config.play ? playRest : null, products: config.products ?? [], subscriptions: config.subscriptions ?? [], dataSafety: config.dataSafety ?? null, appPrivacy: config.appPrivacy ?? null, reviewAccess: config.reviewAccess ?? null, accounts: config.accounts ?? null, social: config.social ?? null, + console: config.console ?? null, shots: config.shots ? { scenes: config.shots.scenes, feature: config.shots.feature ?? null } : null, })) as EditableResult['config'], contact, diff --git a/cli/lib/edit.ts b/cli/lib/edit.ts index ce7990f..27a17e2 100644 --- a/cli/lib/edit.ts +++ b/cli/lib/edit.ts @@ -3,6 +3,7 @@ // pass. Sources: // - storebound.config.ts / .js / .json: one value by path (config-path.ts), the rest of the file unchanged // - store/listing.md (Google Play) and store/appstore.md (App Store): one field block (listing-edit.ts) +// - the shots copy file (shots.copy, JSON): screenshot captions per language and scene, as a text edit too // - .storebound/credentials.json reviewContact (App Review contact: personal data, never in the config or a log line) // The plan itself is pure (texts in, texts out); cli/edit.ts reads and writes the files. import { compactDiff } from './diff'; @@ -14,7 +15,9 @@ import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssu export type EditOp = | { kind: 'config'; path: string; value?: unknown; remove?: boolean } | { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string } - | { kind: 'contact'; field: ContactField; value: string }; + | { kind: 'contact'; field: ContactField; value: string } + /** A screenshot caption in the shots copy file (shots.copy): eyebrow, title line 1 / 2 (title[0], title[1]) or sub. */ + | { kind: 'shotsCopy'; lang: string; scene: string; field: 'eyebrow' | 'title' | 'sub'; line?: 0 | 1; value: string }; export type ContactField = 'firstName' | 'lastName' | 'phone' | 'email'; export const CONTACT_FIELDS: readonly ContactField[] = ['firstName', 'lastName', 'phone', 'email']; @@ -29,6 +32,7 @@ export interface EditSources { play: { file: string; rel: string; text: string | null } | null; apple: { file: string; rel: string; text: string | null } | null; contact: { file: string; rel: string; text: string | null }; + shotsCopy?: { file: string; rel: string; text: string | null } | null; appName: string; appleName: string; } @@ -58,12 +62,20 @@ export function checkOp(o: unknown, i: number): EditOp { if (e.store === 'play' ? !isPlayField(field) : !isAppleField(field)) throw new Error(`edit ${i + 1}: ${String(e.store)} has no field ${field}`); return { kind: 'listing', store: e.store, lang, field, value: str('value', 20_000) }; } + if (e.kind === 'shotsCopy') { + const lang = str('lang', 40), scene = str('scene', 60); + if (!/^[A-Za-z0-9_-]+$/.test(lang) || !/^[A-Za-z0-9_-]+$/.test(scene)) throw new Error(`edit ${i + 1}: lang and scene are ids`); + if (e.field !== 'eyebrow' && e.field !== 'title' && e.field !== 'sub') throw new Error(`edit ${i + 1}: caption field eyebrow, title or sub`); + if (e.line !== undefined && e.line !== 0 && e.line !== 1) throw new Error(`edit ${i + 1}: line 0 or 1`); + if (e.field === 'title' && e.line === undefined) throw new Error(`edit ${i + 1}: the title needs line 0 or 1`); + return { kind: 'shotsCopy', lang, scene, field: e.field, ...(e.field === 'title' ? { line: e.line as 0 | 1 } : {}), value: str('value', 400) }; + } if (e.kind === 'contact') { const field = str('field', 20) as ContactField; if (!CONTACT_FIELDS.includes(field)) throw new Error(`edit ${i + 1}: contact field ${field}`); return { kind: 'contact', field, value: str('value', 200) }; } - throw new Error(`edit ${i + 1}: kind config, listing or contact`); + throw new Error(`edit ${i + 1}: kind config, listing, shotsCopy or contact`); } /** Plain JSON data only (no functions), not too deep or big. */ @@ -127,6 +139,23 @@ export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: strin changes.push({ what: `${op.store === 'play' ? 'Google Play' : 'App Store'} ${op.lang} ${op.field}`, file: f.rel, from: r.from, to: op.value.trim() || null, ...(r.codes.length > 1 ? { also: r.codes.filter(c => c !== op.lang) } : {}) }); touched[op.store].add(op.lang); if (op.store === 'apple') onlyWhatToTest.set(op.lang, (onlyWhatToTest.get(op.lang) ?? true) && op.field === 'whatToTest'); + } else if (op.kind === 'shotsCopy') { + if (!src.shotsCopy) throw new Error('this app has no shots copy file (shots.copy in the config)'); + const t = read(src.shotsCopy) || '{}\n'; + let cur: unknown; + try { cur = JSON.parse(t); } catch (e) { throw new Error(`${src.shotsCopy.rel}: not JSON (${(e as Error).message})`); } + const scene = ((cur as Record>>)[op.lang] ?? {})[op.scene] ?? {}; + const title = Array.isArray(scene.title) ? [...(scene.title as string[])] : []; + const from = op.field === 'title' ? title[op.line!] ?? null : (scene[op.field] as string | undefined) ?? null; + let next: string; + if (op.field === 'title') { + // the title is two lines: set one, keep the other + while (title.length < 2) title.push(''); + title[op.line!] = op.value; + next = setByPath(t, src.shotsCopy.file, [{ key: op.lang }, { key: op.scene }, { key: 'title' }], title); + } else next = setByPath(t, src.shotsCopy.file, [{ key: op.lang }, { key: op.scene }, { key: op.field }], op.value); + texts.set(src.shotsCopy.file, next); + changes.push({ what: `screenshot ${op.lang} ${op.scene} ${op.field}${op.field === 'title' ? ` line ${op.line! + 1}` : ''}`, file: src.shotsCopy.rel, from, to: op.value || null }); } else { read(src.contact); contact ??= src.contact.text ? JSON.parse(src.contact.text) as Record : {}; diff --git a/cli/listing-store.ts b/cli/listing-store.ts new file mode 100644 index 0000000..fd2f754 --- /dev/null +++ b/cli/listing-store.ts @@ -0,0 +1,194 @@ +// Listing text straight on the stores, without listing files: for store-only apps in the desktop (no app folder, so +// nothing to save into) and for a quick fix of one language. +// storebound listing live [--json] +// the live texts per language: Google Play title / short / full (one throwaway edit, deleted), App Store name / +// subtitle (the app info being prepared, else the live one) and description / keywords / promotional text / what's +// new (the editable version, else the newest) +// storebound listing set --store play|apple --lang --fields '{"short":"..."}' [--dry] [--json] +// changes only the given fields of one language. Every run reads the store first and prints old -> new per field; +// --dry stops there; the real run asks in a terminal. Google Play: one edit, PATCH of that listing (other fields, +// the promo video and the other languages stay), committed. App Store: PATCH of the localization (name, subtitle: +// the app info being prepared; promotional text: the editable version, else the live one, which App Store +// Connect allows; description, keywords, what's new: the editable version only). A missing language is created. +// Checks before anything is sent: the store limits and the rules of `check` / `scan` (lib/listing-rules.ts); an error +// stops the run. The URLs, contact and app details are not touched here (`play listing` / `apple listing` send them +// from the config of an app folder). +import { appleLocaleIssues, type FieldIssue, playLangIssues } from './lib/listing-rules'; +import type { AppleCopy } from './lib/copy'; +import type { Ctx } from './lib/ctx'; +import { confirm, need } from './lib/ctx'; +import { done, log, printJson, warn } from './lib/log'; +import { ascClient, EDITABLE, type J } from './apple/asc'; +import { playClient } from './play/client'; + +const TAG = 'listing'; + +export const PLAY_API_FIELD = { title: 'title', short: 'shortDescription', full: 'fullDescription' } as const; +export type PlayTextField = keyof typeof PLAY_API_FIELD; +export const APPLE_INFO_FIELDS = ['name', 'subtitle'] as const; +export const APPLE_VERSION_FIELDS = ['description', 'keywords', 'promotionalText', 'whatsNew'] as const; + +export interface LiveListings { + play: { language: string; title: string; short: string; full: string }[] | null; + apple: { version: string | null; state: string | null; editable: boolean; locales: { locale: string; values: AppleCopy }[] } | null; + errors: { play?: string; apple?: string }; +} + +export interface FieldPlan { field: string; from: string | null; to: string } +export interface SetPlan { store: 'play' | 'apple'; lang: string; create: boolean; changes: FieldPlan[]; same: string[]; issues: FieldIssue[]; refused: string | null; target?: string } + +/** Old -> new for the given fields of one language (an empty and a missing value are the same). Pure. */ +export function setPlan(store: 'play' | 'apple', lang: string, current: Record | null, want: Record, o: { appName: string; editable?: boolean }): SetPlan { + const changes: FieldPlan[] = []; + const same: string[] = []; + for (const [f, v] of Object.entries(want)) { + const from = current?.[f] ?? null; + if ((from ?? '') === v.trim()) same.push(f); else changes.push({ field: f, from, to: v.trim() }); + } + const merged = { ...(current ?? {}), ...Object.fromEntries(Object.entries(want).map(([k, v]) => [k, v.trim()])) }; + // only the fields this run sends are judged (a store value it leaves alone is not this run's problem) + const sent = new Set(Object.keys(want)); + const all = store === 'play' + ? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName) + : appleLocaleIssues(lang, merged as AppleCopy, o.appName); + const issues = all.filter(i => sent.has(i.field) && !(i.level === 'error' && /is required$/.test(i.msg) && !(want[i.field] !== undefined && !want[i.field]!.trim()))); + let refused: string | null = null; + if (store === 'apple' && o.editable === false && changes.some(c => c.field !== 'promotionalText' && (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field))) + refused = 'no editable App Store version: description, keywords and what\'s new change with a new version (Releases, or apple listing --version)'; + return { store, lang, create: !current, changes, same, issues, refused }; +} + +async function playLive(ctx: Ctx): Promise { + const { config, root } = ctx.loaded; + const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); + const now = await play.read(async id => (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []); + return now.map(l => ({ language: l.language, title: l.title ?? '', short: l.shortDescription ?? '', full: l.fullDescription ?? '' })).sort((a, b) => a.language.localeCompare(b.language)); +} + +interface AppleRefs { c: Awaited>; version: J | null; editable: boolean; info: J } + +async function appleRefs(ctx: Ctx): Promise { + const { config, root } = ctx.loaded; + const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); + const appId = await c.app(); + const ev = await c.editableVersion(); + const versions = ev ? [ev] : await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`); + const version = ev ?? [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')))[0] ?? null; + const info = await c.editableAppInfo(); + return { c, version, editable: !!ev && EDITABLE.has(ev.attributes.appVersionState ?? ev.attributes.appStoreState), info }; +} + +async function appleLive(ctx: Ctx): Promise> { + const r = await appleRefs(ctx); + const by: Record = {}; + for (const l of await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`)) by[l.attributes.locale] = { name: l.attributes.name ?? '', subtitle: l.attributes.subtitle ?? '' }; + if (r.version) for (const l of await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`)) { + const at = l.attributes; + by[at.locale] = { ...by[at.locale], description: at.description ?? '', keywords: at.keywords ?? '', promotionalText: at.promotionalText ?? '', whatsNew: at.whatsNew ?? '' }; + } + return { + version: r.version?.attributes.versionString ?? null, state: r.version ? (r.version.attributes.appVersionState ?? r.version.attributes.appStoreState) : null, editable: r.editable, + locales: Object.entries(by).sort(([a], [b]) => a.localeCompare(b)).map(([locale, values]) => ({ locale, values })), + }; +} + +async function live(ctx: Ctx): Promise { + const { config } = ctx.loaded; + const out: LiveListings = { play: null, apple: null, errors: {} }; + await Promise.all([ + config.play ? playLive(ctx).then(v => { out.play = v; }, e => { out.errors.play = (e as Error).message.split('\n')[0]!; }) : null, + config.apple ? appleLive(ctx).then(v => { out.apple = v; }, e => { out.errors.apple = (e as Error).message.split('\n')[0]!; }) : null, + ]); + if (ctx.args.has('json')) { printJson(out); return; } + for (const l of out.play ?? []) log(TAG, `Google Play ${l.language}: title ${l.title.length}, short ${l.short.length}, full ${l.full.length} characters`); + for (const l of out.apple?.locales ?? []) log(TAG, `App Store ${l.locale}: ${Object.entries(l.values).filter(([, v]) => v).map(([k]) => k).join(', ')}`); + for (const [s, e] of Object.entries(out.errors)) warn(TAG, `${s}: ${e}`); +} + +const cut = (s: string | null): string => (s === null ? '(none)' : `"${s.length > 60 ? `${s.slice(0, 57)}...` : s}"`.replace(/\n/g, ' ')); + +function show(ctx: Ctx, p: SetPlan): void { + const where = `${p.store === 'play' ? 'Google Play' : 'App Store'} ${p.lang}${p.target ? ` (${p.target})` : ''}`; + if (ctx.args.has('json') && process.env.STOREBOUND_MACHINE !== '1') return; + log(TAG, `${where}: ${p.create ? 'new language' : 'update'}`); + for (const c of p.changes) console.log(` ${c.field}: ${cut(c.from)} -> ${cut(c.to)}`); + for (const s of p.same) console.log(` ${s}: same`); + for (const i of p.issues) console.log(` ${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} ${i.field}: ${i.msg}`); + if (p.refused) console.log(` refused: ${p.refused}`); +} + +async function set(ctx: Ctx): Promise { + const a = ctx.args; + const { config } = ctx.loaded; + const store = a.get('store'); + if (store !== 'play' && store !== 'apple') throw new Error('--store play or apple'); + const lang = a.get('lang') ?? ''; + if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error('--lang: a store language code (en-US, tr-TR, zh-Hans)'); + let want: Record; + try { want = JSON.parse(a.get('fields') ?? '') as Record; } catch { throw new Error('--fields: JSON, e.g. {"short":"..."}'); } + const allowed: readonly string[] = store === 'play' ? Object.keys(PLAY_API_FIELD) : [...APPLE_INFO_FIELDS, ...APPLE_VERSION_FIELDS]; + if (!want || typeof want !== 'object' || Array.isArray(want) || !Object.keys(want).length) throw new Error('--fields: at least one field'); + for (const [k, v] of Object.entries(want)) { + if (!allowed.includes(k)) throw new Error(`--fields: ${store} has no text field ${k} here (${allowed.join(', ')})`); + if (typeof v !== 'string' || v.length > 20_000) throw new Error(`--fields.${k}: text`); + } + const dry = a.has('dry'); + if (store === 'play') { + const playName = config.play?.name ?? config.app.name; + const now = await playLive(ctx); + const cur = now?.find(l => l.language === lang) ?? null; + const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName }); + show(ctx, p); + const errors = p.issues.filter(i => i.level === 'error').length; + if (a.has('json')) printJson({ ...p, dry, written: false }); + if (errors) throw new Error(`nothing sent: ${errors} error(s)`); + if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on Google Play'); return; } + if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; } + const { root } = ctx.loaded; + const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); + const body: Record = { language: lang }; + for (const c of p.changes) body[PLAY_API_FIELD[c.field as PlayTextField]] = c.to; + if (p.create && !body.title) body.title = playName; + await play.edit(async id => { + if (p.create) await play.call('PUT', `${play.API}/edits/${id}/listings/${lang}`, { title: playName, shortDescription: '', fullDescription: '', ...body }); + else await play.call('PATCH', `${play.API}/edits/${id}/listings/${lang}`, body); + }); + done(TAG, `Google Play ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (not public until Google reviews the change)`); + return; + } + const r = await appleRefs(ctx); + const infoLocs = await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`); + const verLocs = r.version ? await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`) : []; + const il = infoLocs.find((x: J) => x.attributes.locale === lang) ?? null; + const vl = verLocs.find((x: J) => x.attributes.locale === lang) ?? null; + const cur: Record | null = il || vl ? { + name: il?.attributes.name ?? '', subtitle: il?.attributes.subtitle ?? '', description: vl?.attributes.description ?? '', keywords: vl?.attributes.keywords ?? '', + promotionalText: vl?.attributes.promotionalText ?? '', whatsNew: vl?.attributes.whatsNew ?? '', + } : null; + const appName = config.apple?.name ?? config.app.name; + const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' }; + show(ctx, p); + const errors = p.issues.filter(i => i.level === 'error').length; + if (a.has('json')) printJson({ ...p, dry, written: false }); + if (p.refused) throw new Error(p.refused); + if (errors) throw new Error(`nothing sent: ${errors} error(s)`); + if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on the App Store'); return; } + if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; } + const info = Object.fromEntries(p.changes.filter(c => (APPLE_INFO_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); + const ver = Object.fromEntries(p.changes.filter(c => (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); + if (Object.keys(info).length) { + if (il) await r.c.asc('PATCH', `/v1/appInfoLocalizations/${il.id}`, { data: { type: 'appInfoLocalizations', id: il.id, attributes: info } }); + else await r.c.asc('POST', '/v1/appInfoLocalizations', { data: { type: 'appInfoLocalizations', attributes: { locale: lang, name: info.name ?? appName, ...info }, relationships: { appInfo: { data: { type: 'appInfos', id: r.info.id } } } } }); + } + if (Object.keys(ver).length) { + if (!r.version) throw new Error('no App Store version to put the texts on'); + if (vl) await r.c.asc('PATCH', `/v1/appStoreVersionLocalizations/${vl.id}`, { data: { type: 'appStoreVersionLocalizations', id: vl.id, attributes: ver } }); + else await r.c.asc('POST', '/v1/appStoreVersionLocalizations', { data: { type: 'appStoreVersionLocalizations', attributes: { locale: lang, ...ver }, relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: r.version.id } } } } }); + } + done(TAG, `App Store ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (${p.target}; customers see it with the next approved version, promotional text at once)`); +} + +export async function run(ctx: Ctx, sub: string): Promise { + if (sub === 'live') return live(ctx); + if (sub === 'set') return set(ctx); +} diff --git a/cli/listing.ts b/cli/listing.ts index 331cc30..ad94aeb 100644 --- a/cli/listing.ts +++ b/cli/listing.ts @@ -9,6 +9,8 @@ // Reviewed sections (no marker) are kept unless --force; a section's What's new is kept. // storebound listing check [--json] limits, keyword rules, untranslated and draft sections; exit 1 on a problem // storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker +// storebound listing live [--json] | set --store play|apple --lang --fields '' [--dry] the texts straight on +// the stores, without listing files (store-only apps in the desktop): cli/listing-store.ts // --to / --from / --lang take config language ids, App Store locales or Play codes. Local files only: nothing is sent // to a store (apple listing / play listing push them after `storebound check`). import { existsSync, readFileSync, writeFileSync } from 'node:fs'; @@ -211,5 +213,6 @@ export async function run(ctx: Ctx): Promise { if (sub === 'translate') return translate(ctx); if (sub === 'check') return check(ctx); if (sub === 'reviewed') return reviewed(ctx); - throw new Error('usage: storebound listing translate --to [--from ] [--translate ""] [--dry] | check | reviewed --lang '); + if (sub === 'live' || sub === 'set') return (await import('./listing-store')).run(ctx, sub); + throw new Error('usage: storebound listing translate --to [--from ] [--translate ""] [--dry] | check | reviewed --lang | live [--json] | set --store play|apple --lang --fields \'\' [--dry]'); } diff --git a/cli/serve.ts b/cli/serve.ts index 32fc7a0..5f37b33 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -51,12 +51,12 @@ export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|com /** Read tools whose command prints a structured result with --json. */ export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status', 'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff', - 'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable']); + 'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable', 'listing_live']); /** Console forms `storebound console fill` knows (cli/console/fill.ts). */ export const CONSOLE_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience']; /** Write tools that print a structured result with --json in the dry run and the real run (console.fill: the answers). */ -export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit']); +export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit', 'listing_set']); /** Write tools whose command may ask its own question (confirm()): a verified host token answers it, as for owner actions. */ const TOKEN_ANSWERS = new Set(['console_fill']); @@ -104,6 +104,13 @@ export const DESKTOP_ONLY: ToolDef[] = [ description: 'Writes edits of the app folder\'s sources of truth: config values by path (the rest of the file, comments and formatting kept), listing fields per store and language (store/listing.md, store/appstore.md), the App Review contact (.storebound/credentials.json). The dry run shows old -> new, the diff per file and the checks (store limits, required fields, keyword and metadata rules, the whole config validated); an error stops the write. Nothing goes to a store and nothing is committed.', props: { ops: { type: 'string', pattern: '^[\\[{][\\s\\S]{1,400000}$', description: 'The edits as JSON (one edit or a list; see `storebound edit`)' } }, required: ['ops'], args: i => ['--ops', String(i.ops)] }, + { name: 'listing_live', title: 'Live listing texts', command: ['listing', 'live'], write: false, network: true, + description: 'Read-only: the live listing texts per language on both stores (Google Play title, short and full description; App Store name, subtitle, description, keywords, promotional text, what\'s new of the editable version, else the newest).' }, + { name: 'listing_set', title: 'Change listing texts on a store', command: ['listing', 'set'], write: true, network: true, + description: 'Changes the given text fields of one language straight on one store (no listing files: store-only apps): Google Play one edit with a PATCH of that listing, committed; App Store the localization of the app info being prepared and of the editable version (promotional text also on the live one). The dry run reads the store and shows old -> new per field with the checks (store limits, keyword and metadata rules); an error stops it. Not public until the store reviews it.', + props: { store: { type: 'string', enum: ['play', 'apple'] }, lang: { type: 'string', pattern: '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$', description: 'Store language code (en-US, tr-TR, zh-Hans)' }, + fields: { type: 'string', pattern: '^\\{[\\s\\S]{1,60000}$', description: 'The fields as JSON, e.g. {"short":"..."}' } }, + required: ['store', 'lang', 'fields'], args: i => ['--store', String(i.store), '--lang', String(i.lang), '--fields', String(i.fields)] }, { name: 'console_fill', title: 'Fill in a console form', command: ['console', 'fill'], write: true, network: true, description: 'Fills one store console form that has no API (App Store app record, age rating, App Privacy; Play content rating, target audience) from the config in a visible Chromium the owner signs in to; uncertain fields are highlighted. It moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save in its dialogs; content rating: Save draft) and never submits or publishes: the owner reviews every field and presses the final button (Publish, Submit, Save or Create). The dry run lists the answers; the real run lasts until the owner closes the browser.', props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' }, @@ -239,7 +246,10 @@ const SYSTEM: MethodDescriptor[] = [ ]; /** Read methods a store-only app (params.virtual, no app folder) may use: store reads that need only the store ids. */ -export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status']); +export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status', 'listing_live']); +/** Writes a store-only app may run (no app folder: they send only what the call carries, never the generated config's + * placeholder links). The confirm token binds them to the store ids (`virtual`) instead of a folder. */ +export const VIRTUAL_WRITES = new Set(['listing_set']); const PKG_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/; const BUNDLE_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,154}$/; @@ -413,7 +423,7 @@ export class Session { let project: string; if (params.virtual !== undefined) { if (params.project !== undefined) throw new RpcError(ERR.invalidParams, 'project or virtual, not both'); - if (t.write || !VIRTUAL_READS.has(t.name)) throw new RpcError(ERR.invalidParams, `${method}: needs an app folder (store-only apps have store reads only)`); + if (t.write ? !VIRTUAL_WRITES.has(t.name) : !VIRTUAL_READS.has(t.name)) throw new RpcError(ERR.invalidParams, `${method}: needs an app folder (store-only apps have store reads only)`); project = virtualDir(checkVirtual(params.virtual)); } else project = NEW_PROJECT.has(t.name) ? checkNewProject(params.project) : checkProject(params.project); const args = (params.args ?? {}) as Json; @@ -428,7 +438,7 @@ export class Session { else { if (params.confirmToken === undefined) throw new RpcError(ERR.confirmRequired, `${method} writes: dryRun false needs a confirmToken from the desktop host`); // Bound to the project string exactly as sent (the host mints over what it sends) and the args object. - const v = this.guard!.verify(params.confirmToken, method, { project: params.project, args }); + const v = this.guard!.verify(params.confirmToken, method, params.virtual !== undefined ? { virtual: params.virtual, args } : { project: params.project, args }); if (!v.ok) throw new RpcError(ERR.confirmInvalid, `confirm token rejected: ${v.error}`, { reason: v.error }); dryRun = false; } diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 6628d9c..b0282d5 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -12,8 +12,8 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | API | operations | GET | other | wrapped by a command | owner only | |---|---:|---:|---:|---:|---:| -| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 200 | 166 | -| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 33 | 49 | +| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 204 | 166 | +| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 34 | 49 | | Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 | ## App Store Connect API 4.5.1 @@ -231,14 +231,14 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appEvents_localizations_getToManyRelationship` | GET | `/v1/appEvents/{id}/relationships/localizations` | read | | | `appEvents_updateInstance` | PATCH | `/v1/appEvents/{id}` | write (dry by default) | | | **appInfoLocalizations** (4) | | | | | -| `appInfoLocalizations_createInstance` | POST | `/v1/appInfoLocalizations` | write (dry by default) | | +| `appInfoLocalizations_createInstance` | POST | `/v1/appInfoLocalizations` | write (dry by default) | listing-store | | `appInfoLocalizations_deleteInstance` | DELETE | `/v1/appInfoLocalizations/{id}` | owner only (typed confirm) | | | `appInfoLocalizations_getInstance` | GET | `/v1/appInfoLocalizations/{id}` | read | | -| `appInfoLocalizations_updateInstance` | PATCH | `/v1/appInfoLocalizations/{id}` | write (dry by default) | | +| `appInfoLocalizations_updateInstance` | PATCH | `/v1/appInfoLocalizations/{id}` | write (dry by default) | listing-store | | **appInfos** (20) | | | | | | `appInfos_ageRatingDeclaration_getToOneRelated` | GET | `/v1/appInfos/{id}/ageRatingDeclaration` | read | apple/appinfo, console/verify, validate | | `appInfos_ageRatingDeclaration_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/ageRatingDeclaration` | read | | -| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, onboard, validate | +| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, listing-store, onboard, validate | | `appInfos_appInfoLocalizations_getToManyRelationship` | GET | `/v1/appInfos/{id}/relationships/appInfoLocalizations` | read | | | `appInfos_getInstance` | GET | `/v1/appInfos/{id}` | read | apple/appinfo | | `appInfos_primaryCategory_getToOneRelated` | GET | `/v1/appInfos/{id}/primaryCategory` | read | | @@ -339,7 +339,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appStoreVersionLocalizations_appPreviewSets_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/appPreviewSets` | read | | | `appStoreVersionLocalizations_appScreenshotSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appScreenshotSets` | read | apple/screenshots, validate | | `appStoreVersionLocalizations_appScreenshotSets_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/appScreenshotSets` | read | | -| `appStoreVersionLocalizations_createInstance` | POST | `/v1/appStoreVersionLocalizations` | write (dry by default) | | +| `appStoreVersionLocalizations_createInstance` | POST | `/v1/appStoreVersionLocalizations` | write (dry by default) | listing-store | | `appStoreVersionLocalizations_deleteInstance` | DELETE | `/v1/appStoreVersionLocalizations/{id}` | owner only (typed confirm) | | | `appStoreVersionLocalizations_getInstance` | GET | `/v1/appStoreVersionLocalizations/{id}` | read | | | `appStoreVersionLocalizations_placements_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/placements` | read | | @@ -348,7 +348,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appStoreVersionLocalizations_searchKeywords_deleteToManyRelationship` | DELETE | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | owner only (typed confirm) | | | `appStoreVersionLocalizations_searchKeywords_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/searchKeywords` | read | | | `appStoreVersionLocalizations_searchKeywords_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | read | | -| `appStoreVersionLocalizations_updateInstance` | PATCH | `/v1/appStoreVersionLocalizations/{id}` | write (dry by default) | | +| `appStoreVersionLocalizations_updateInstance` | PATCH | `/v1/appStoreVersionLocalizations/{id}` | write (dry by default) | listing-store | | **appStoreVersionPhasedReleases** (3) | | | | | | `appStoreVersionPhasedReleases_createInstance` | POST | `/v1/appStoreVersionPhasedReleases` | write (dry by default) | apple/release | | `appStoreVersionPhasedReleases_deleteInstance` | DELETE | `/v1/appStoreVersionPhasedReleases/{id}` | owner only (typed confirm) | | @@ -371,7 +371,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appStoreVersions_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperimentsV2` | read | | | `appStoreVersions_appStoreVersionExperiments_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionExperiments` | read | | | `appStoreVersions_appStoreVersionExperiments_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperiments` | read | | -| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, onboard, validate | +| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, listing-store, onboard, validate | | `appStoreVersions_appStoreVersionLocalizations_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionLocalizations` | read | | | `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionPhasedRelease` | read | apple/release, apple/status | | `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | | @@ -421,7 +421,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `apps_appPriceSchedule_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/appPriceSchedule` | read | | | `apps_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersionExperimentsV2` | read | apple/ppo | | `apps_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersionExperimentsV2` | read | | -| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, lib/store-apps, onboard, validate | +| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, lib/store-apps, listing-store, onboard, validate | | `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | | | `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | | | `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | | @@ -1598,9 +1598,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `androidpublisher.edits.listings.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | owner only (typed confirm) | | | `androidpublisher.edits.listings.deleteall` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | owner only (typed confirm) | | | `androidpublisher.edits.listings.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | read | | -| `androidpublisher.edits.listings.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | read | play/listing, play/status, validate | -| `androidpublisher.edits.listings.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | | -| `androidpublisher.edits.listings.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | play/listing | +| `androidpublisher.edits.listings.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | read | listing-store, play/listing, play/status, validate | +| `androidpublisher.edits.listings.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store | +| `androidpublisher.edits.listings.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store, play/listing | | **androidpublisher.edits.testers** (3) | | | | | | `androidpublisher.edits.testers.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}` | read | | | `androidpublisher.edits.testers.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}` | owner only (typed confirm) | | diff --git a/docs/console-parity.md b/docs/console-parity.md index 7460355..fe3d857 100644 --- a/docs/console-parity.md +++ b/docs/console-parity.md @@ -1,66 +1,71 @@ # Console parity: App Store Connect and Play Console in the desktop app -The desktop app (apps/desktop, [desktop.md](desktop.md)) is meant to replace App Store Connect and Play Console for -everything storebound can read or write. This page lists every owner-editable thing in the two consoles that the CLI -covers, and what the desktop does with it. +The desktop app (apps/desktop, [desktop.md](desktop.md)) replaces App Store Connect and Play Console for everything +storebound can read or write. This page lists every owner-editable thing in the two consoles that the CLI covers, and +what the desktop does with it. `tests/console-parity.test.ts` keeps it honest: every row is edited in the window or +names the reason it is not. Columns: - **storebound**: the command that reads or writes it, and where the value lives (the source of truth in the app - folder: `storebound.config.ts`, `store/listing.md`, `store/appstore.md`, `.storebound/`). + folder: `storebound.config.ts`, `store/listing.md`, `store/appstore.md`, the shots copy file, `.storebound/`). - **View**: the desktop shows the current value. -- **Edit**: the desktop edits it in place (validation, unsaved-changes guard, undo) and saves it into the source file. -- **Store write**: the desktop writes it to the store (dry run in the window, native confirm dialog, then the real - run; owner-only steps through the owner-action dialog). -- **Gap**: what is missing, or why a cell stays "no" on purpose. +- **Edit**: the desktop edits it in place (live counter against the store limit where there is one, the checks of + `check` / `scan` while typing, unsaved-changes mark, undo / redo) and saves it into the source file through + `project.edit` (`storebound edit`: dry run with the diff and the checks in the window, then the native dialog). +- **Store write**: the desktop writes it to the store (the dry run in the window, the native confirm dialog, then the + real run; owner-only steps through the owner-action dialog). +- **Gap**: "none", or why a cell stays "no" on purpose. -Status: **audit before the editors (2026-10-14)**. Every row whose CLI can write the value but the desktop cannot edit -it is a gap. Phase 2 closes them; this table is updated with the result. +Status: **after the editors (2026-10-14)**. The audit before them (Phase 1) had 70 rows, 62 of them with a gap. Now: 73 +rows, 55 with nothing missing, 18 with the reason a cell stays "no" (no store API, an owner-only public step, a +secret, or personal data). ## App Store Connect | Console screen | storebound | View | Edit | Store write | Gap | | --- | --- | --- | --- | --- | --- | -| App Information > Name (per locale) | `apple listing` (`### name`, apple.name, app.name) | counter only | no | yes (`apple.listing`) | no text editor | -| App Information > Subtitle | `apple listing` (`### subtitle`) | counter only | no | yes | no text editor | -| Version > Promotional Text | `apple listing` (`### promotionalText`) | counter only | no | yes | no text editor | -| Version > Description | `apple listing` (`### description`) | counter only | no | yes | no text editor | -| Version > Keywords | `apple listing` (`### keywords`), `check --aso` | counter only | no | yes | no editor, no keyword checks while typing | -| Version > What's New | `apple listing`, `whats-new apply` (`### whatsNew`) | no | no | yes | no editor | -| Version > Support URL | `apple listing` (app.supportUrl) | no | no | yes | not shown, no editor | -| Version > Marketing URL | `apple listing` (app.marketingUrl) | no | no | yes | not shown, no editor | -| App Privacy > Privacy Policy URL | `apple listing` (app.privacyPolicyUrl) | no | no | yes | not shown, no editor | -| Version > Copyright | `apple listing` (app.copyright) | no | no | yes | not shown, no editor | -| Version > version string, build | `apple listing --version --build` | yes | version field | yes | none | -| Version > Screenshots (per device, locale) | `shots render`, `apple screenshots` | renders | no | yes (`apple.screenshots`) | no ordering or replacement of scenes | -| Version > App Previews | `shots video`, `apple previews` | renders | no | yes | no replacement | -| App Information > Category, subcategories | `apple appinfo` (apple.info.categories) | yes | no | yes (`apple.appinfo`) | no editor | -| App Information > Age Rating | `apple appinfo` (apple.info.ageRating), console plan | yes | no | yes | no editor of the answers | -| App Information > Content Rights | `apple appinfo` (apple.info.contentRights) | yes | no | yes | no editor | -| Pricing and Availability > Free, territories | `apple appinfo` (apple.info.free) | yes | no | yes | no editor | -| Pricing and Availability > Pre-orders | `apple preorder` | no | no | terminal (owner) | public step; terminal only | -| App Privacy (data types, purposes) | appPrivacy.types, `console fill apple-app-privacy` | yes | no | no API (console fill) | no editor of the answers | -| App Review Information > Contact | `apple review-details` (.storebound/credentials.json reviewContact) | masked | no | yes (`apple.review_details`) | no editor | -| App Review Information > Notes | `apple review-details` (apple.info.reviewNotes, reviewAccess.instructions) | yes | no | yes | no editor | -| App Review Information > Sign-in required, demo account | reviewAccess.signInRequired, credentials.json reviewDemoAccount | masked | no | yes | no editor of sign-in required | -| Export compliance (encryption) | `apple encryption` (apple.usesNonExemptEncryption) | yes | no | yes | no editor | -| In-App Purchases > reference name, display name, description per locale | `apple iap` (products[]) | parity only | no | yes (`apple.iap`) | no editor | -| In-App Purchases > price | products[].usd, overrides, `prices import` | yes (Prices) | no | yes, owner gate | no editor of the base price | -| In-App Purchases > review note, screenshot | products[].reviewNote / reviewScreenshot, `apple iap-review` | no | no | yes (`apple.iap_review`) | not shown, no editor | -| Subscriptions > group name per locale | `apple subscriptions` (subscriptions[].listings) | no | no | yes | no editor | -| Subscriptions > name, description per locale | `apple subscriptions` (subscriptions[].subscriptions[].listings) | parity only | no | yes | no editor | -| Subscriptions > level, period, price | `apple subscriptions` | parity only | no | yes, owner gate for live prices | no editor | -| Subscriptions > introductory and promotional offers | subscriptions[].subscriptions[].intro / offers | no | no | yes (sync) | no editor | -| Subscriptions > offer codes | `apple subscriptions` (offerCodes), `apple offer-codes` | no | no | terminal (owner) | no editor | -| Subscriptions > win-back offers | `apple win-back` (winBack) | no | no | terminal (owner) | no editor | -| In-App Purchases > promoted purchases | `apple promoted` (apple.promoted) | no | no | terminal (owner) | no editor | -| Custom product pages | `apple cpp list / create / upload` (apple.customProductPages) | no | no | CLI only | not in the window | -| In-app events | `apple events list / create / upload` (apple.events) | no | no | CLI only | not in the window | -| Product Page Optimization | `apple ppo` | yes | create / start / stop | yes (owner start / stop) | none | -| TestFlight > What to Test (per build) | `apple testflight what-to-test` | yes | yes (text) | yes | per-locale text not editable | -| TestFlight > groups | `apple testflight groups sync` (apple.testflight.groups) | yes | no | yes (create missing) | no group editor | -| TestFlight > Test Information (beta description, notes) | `apple testflight info` (apple.testflight.description / reviewNotes) | no | no | CLI only | not in the window | -| TestFlight > testers | `owner.testflight_testers` (.storebound/testers.csv) | count | no | yes (owner) | CSV not editable | +| App Information > Name (per locale) | `apple listing` (`### name`, apple.name, app.name) | yes | yes | yes (`apple.listing`) | none | +| App Information > Subtitle | `apple listing` (`### subtitle`) | yes | yes | yes | none | +| Version > Promotional Text | `apple listing` (`### promotionalText`) | yes | yes | yes | none | +| Version > Description | `apple listing` (`### description`) | yes | yes | yes | none | +| Version > Keywords | `apple listing` (`### keywords`), `check --aso` | yes | yes (keyword rules while typing) | yes | none | +| Version > What's New | `apple listing`, `whats-new apply` (`### whatsNew`) | yes | yes | yes | none | +| Version > Support URL | `apple listing` (app.supportUrl) | yes | yes | yes | none | +| Version > Marketing URL | `apple listing` (app.marketingUrl) | yes | yes | yes | none | +| App Privacy > Privacy Policy URL | `apple listing` (app.privacyPolicyUrl) | yes | yes | yes | none | +| Version > Copyright | `apple listing` (app.copyright) | yes | yes | yes | none | +| Version > version string, build | `apple listing --version --build` | yes | yes | yes | none | +| Version > Screenshots (per device, locale) | `shots render`, `apple screenshots` (shots.scenes, the shots copy file) | yes | yes (scene order, the frame of each scene, the captions per language) | yes (`apple.screenshots`) | none | +| Version > App Previews | `shots record`, `shots video`, `apple previews` | yes | n/a | yes (`apple.previews`) | reason: a preview is cut from recorded clips, not a field; replacing one is a new recording (`shots.video` render job in the window) | +| App Information > Category, subcategories | `apple appinfo` (apple.info.categories) | yes | yes | yes (`apple.appinfo`) | none | +| App Information > Age Rating | `apple appinfo` (apple.info.ageRating), `console fill apple-age-rating` | yes | yes (every question) | yes | none | +| App Information > Content Rights | `apple appinfo` (apple.info.contentRights) | yes | yes | yes | none | +| Pricing and Availability > Free, territories | `apple appinfo` (apple.info.free) | yes | yes | yes | none | +| Pricing and Availability > Pre-orders | `apple preorder status / enable / end` | no | no | no | reason: putting an app on pre-order or ending it is public at once; it stays the owner's terminal step (`apple preorder enable --release-date`, asks first), there is no config value to edit | +| App Privacy (data types, purposes) | appPrivacy.types, `console fill apple-app-privacy` | yes | yes (checked JSON, validated with the config) | no | reason: App Privacy has no API; Fill in console types the saved answers, the owner presses Publish | +| App Review Information > Contact | `apple review-details` (.storebound/credentials.json reviewContact) | masked | yes (write-only, masked) | yes (`apple.review_details`) | none | +| App Review Information > Notes | `apple review-details` (apple.info.reviewNotes, reviewAccess.instructions) | yes | yes | yes | none | +| App Review Information > Sign-in required | reviewAccess.signInRequired | yes | yes | yes | none | +| App Review Information > Demo account | credentials.json reviewDemoAccount | masked | no | yes (sent by `apple.review_details`) | reason: a password; the window never reads or takes it (AGENTS.md rule 4), the owner puts it in `.storebound/credentials.json` | +| Export compliance (encryption) | `apple encryption` (apple.usesNonExemptEncryption) | yes | yes | yes (`apple.encryption`) | none | +| In-App Purchases > reference name, display name, description per locale | `apple iap` (products[]) | yes | yes | yes (`apple.iap`) | none | +| In-App Purchases > price | products[].usd, overrides, `prices import` | yes | yes | yes, owner gate for live products | none | +| In-App Purchases > review note, screenshot | products[].reviewNote / reviewScreenshot, `apple iap-review` | yes | yes | yes (`apple.iap_review`) | none | +| Subscriptions > group name per locale | `apple subscriptions` (subscriptions[].listings) | yes | yes | yes (`apple.subscriptions`) | none | +| Subscriptions > name, description per locale | `apple subscriptions` | yes | yes | yes | none | +| Subscriptions > level, period, price | `apple subscriptions` | yes | yes | yes, owner gate for live prices | none | +| Subscriptions > introductory and promotional offers | subscriptions[].subscriptions[].intro / offers | yes | yes (checked JSON) | yes (`apple.subscriptions`) | none | +| Subscriptions > offer codes | offerCodes, `apple subscriptions`, `apple offer-codes --codes` | yes | yes (checked JSON) | yes for the offers; the codes: no | reason: generating codes makes vouchers anyone can redeem; it stays the owner's terminal step (`apple offer-codes --codes`, asks first, codes saved to .storebound/) | +| Subscriptions > win-back offers | winBack, `apple win-back` | yes | yes (checked JSON) | no | reason: a win-back offer goes live by itself on its start date; creating one is the owner's terminal step (`apple win-back`, asks first) | +| In-App Purchases > promoted purchases | apple.promoted, `apple promoted` | yes | yes (checked JSON) | no | reason: promoting shows the purchase on the product page; `apple promoted [--enable]` asks first and stays the owner's terminal step | +| Custom product pages | `apple cpp create / upload` (apple.customProductPages) | yes | yes (checked JSON) | yes (`apple.cpp_create`, `apple.cpp_upload`) | none | +| In-app events | `apple events create / upload` (apple.events) | yes | yes (checked JSON) | yes (`apple.events_create`, `apple.events_upload`) | none | +| Product Page Optimization | `apple ppo` | yes | yes | yes (owner start / stop) | none | +| TestFlight > What to Test (per build, per locale) | `apple testflight what-to-test` (`### whatToTest`, else `### whatsNew`) | yes | yes (per locale) | yes (`apple.testflight_what_to_test`) | none | +| TestFlight > groups | `apple testflight groups sync` (apple.testflight.groups) | yes | yes (checked JSON) | yes (creates the missing groups) | none | +| TestFlight > Test Information (beta description, notes) | `apple testflight info` (apple.testflight.description / reviewNotes) | yes | yes | yes (`apple.testflight_info`) | none | +| TestFlight > testers | `owner.testflight_testers` (.storebound/testers.csv) | count | no | yes (owner) | reason: tester emails are personal data kept in the gitignored CSV; adding them is the owner action, removing them stays a terminal step | | Ratings and Reviews > reply | `reviews draft / reply / approve / send` | yes | yes | yes (owner send) | none | | Version > phased release | `owner.ios_phased` | yes | n/a | yes (owner) | none | | Version > Submit for review | `owner.apple_submit` / `owner.apple_resubmit` | yes | n/a | yes (owner) | none | @@ -69,36 +74,38 @@ it is a gap. Phase 2 closes them; this table is updated with the result. | Console screen | storebound | View | Edit | Store write | Gap | | --- | --- | --- | --- | --- | --- | -| Main store listing > App name (per language) | `play listing` (`### title`, app.name) | counter only | no | yes (`play.listing`) | no text editor | -| Main store listing > Short description | `play listing` (`### short`) | counter only | no | yes | no text editor | -| Main store listing > Full description | `play listing` (`### full`) | counter only | no | yes | no text editor, no stuffing check while typing | -| Release > Release notes (per language) | `play release`, `whats-new apply` (`### What's new`) | counter only | no | with the release (owner) | no editor | -| Main store listing > Graphics (icon, feature graphic, screenshots) | `icons`, `shots render`, `play images` | renders | no | yes (`play.images`) | no ordering or replacement of scenes | -| Main store listing > Video (YouTube URL) | `play listing` (play.video) | no | no | yes | not shown, no editor | -| Store settings > Contact email | `play listing` app details (app.contactEmail) | no | no | yes | not shown, no editor | -| Store settings > Website | `play listing` app details (app.website) | no | no | yes | not shown, no editor | -| Store settings > App category | play.category, console plan (no API) | yes | no | no API | no editor | -| App content > Privacy policy | app.privacyPolicyUrl, console plan | no | no | no API | not shown, no editor | -| App content > Data safety | `play data-safety` (dataSafety) | yes | no | yes (`play.data_safety`) | no editor of the answers | -| App content > Content rating (IARC) | social, products[].randomRewards, `console fill play-content-rating` | yes | no | no API (console fill) | no editor of the answers | -| App content > Target audience | play.targetAges, `console fill play-target-audience` | plan | no | no API (console fill) | no editor | -| Monetize > In-app products: title, description per language | `play products` (products[].listings) | parity only | no | yes (`play.products`) | no editor | -| Monetize > In-app products: price | products[].usd / overrides, `prices import` | yes (Prices) | no | yes, owner gate | no editor of the base price | -| Monetize > Subscriptions: title, benefits, description per language | `play subscriptions` | parity only | no | yes | no editor | -| Monetize > Subscriptions: base plans, offers, price | `play subscriptions` | parity only | no | yes, owner gate | no editor | -| Pricing > regional prices (price sheet) | `prices export / import` | yes | no | yes (owner) | sheet not editable in place | -| Production > Countries / regions | play.countries, console plan | count | no | no API | no editor | -| Publishing overview > Managed publishing | play.managedPublishing | yes | no | no API | no editor | -| Testing tracks (internal, closed, open) | `play release --track` | status | n/a | terminal (owner) | sends builds to testers: not in the window by design | +| Main store listing > App name (per language) | `play listing` (`### title`, app.name) | yes | yes | yes (`play.listing`) | none | +| Main store listing > Short description | `play listing` (`### short`) | yes | yes | yes | none | +| Main store listing > Full description | `play listing` (`### full`) | yes | yes (stuffing and claims checks while typing) | yes | none | +| Release > Release notes (per language) | `play release`, `whats-new apply` (`### What's new`) | yes | yes | yes (with the release: `owner.play_production`) | none | +| Main store listing > Graphics (icon, feature graphic, screenshots) | `icons`, `shots render`, `play images` | yes | yes (scene order, frames, captions) | yes (`play.images`) | none | +| Main store listing > Video (YouTube URL) | `play listing` (play.video) | yes | yes | yes | none | +| Store settings > Contact email | `play listing` app details (app.contactEmail) | yes | yes | yes | none | +| Store settings > Website | `play listing` app details (app.website) | yes | yes | yes | none | +| Store settings > App category | play.category, console plan | yes | yes | no | reason: no API; the console plan step (App info > Console steps) uses the saved value | +| App content > Privacy policy | app.privacyPolicyUrl, console plan | yes | yes | no | reason: no API; the console plan step uses the saved value | +| App content > Data safety | `play data-safety` (dataSafety) | yes | yes | yes (`play.data_safety`) | none | +| App content > Content rating (IARC) | console.contentRating, `console fill play-content-rating` | yes | yes (checked JSON) | no | reason: no API; Fill in console types the saved answers, the owner presses Submit | +| App content > Target audience | play.targetAges, console.targetAudience, `console fill play-target-audience` | yes | yes | no | reason: no API; Fill in console types the saved answers, the owner saves | +| App content > other declarations (ads ID, government, finance, health) | console.appContent, console plan | yes | yes (checked JSON) | no | reason: no API; the console plan step uses the saved answers | +| Monetize > In-app products: title, description per language | `play products` (products[].listings) | yes | yes | yes (`play.products`) | none | +| Monetize > In-app products: price | products[].usd / overrides, `prices import` | yes | yes | yes, owner gate for live products | none | +| Monetize > Subscriptions: title, benefits, description per language | `play subscriptions` | yes | yes | yes (`play.subscriptions`) | none | +| Monetize > Subscriptions: base plans, offers, price | `play subscriptions` | yes | yes | yes, owner gate for live prices | none | +| Pricing > regional prices (price sheet) | `prices export / import`, overrides | yes | yes (per-product overrides in the Catalog editor) | yes (owner) | none | +| Production > Countries / regions | play.countries, console plan | yes | yes | no | reason: no API for the production countries; the console plan step uses the saved list | +| Publishing overview > Managed publishing | play.managedPublishing | yes | yes | no | reason: no API; only Play Console sets it (the console plan names the switch) | +| Testing tracks (internal, closed, open) | `play release --track` | yes | n/a | no | reason: sends a build to testers; the owner's terminal step by design (desktop.md safety model) | | Production > staged rollout | `owner.play_rollout` | yes | n/a | yes (owner) | none | | Production > Send to production | `owner.play_production` | yes | n/a | yes (owner) | none | | Ratings and reviews > reply | `reviews draft / reply / approve / send` | yes | yes | yes (owner send) | none | -| Internal app sharing | `play share` | no | n/a | CLI only | not in the window | +| Internal app sharing | `play share` | no | n/a | no | reason: uploads a build file for testers, not a console field; a terminal or agent step (`play share`) | ## Both stores | What | storebound | View | Edit | Store write | Gap | | --- | --- | --- | --- | --- | --- | -| Pull the live texts into the files | `apple listing pull`, `play listing pull`, `catalog pull` | no | n/a | n/a (writes local files) | not in the window | -| Store-only app (no folder): listing text | (none) | yes (status) | no | no | store-only apps are read-only in the window | -| Uncommitted changes in the app folder | git | no | n/a | n/a | not shown | +| Pull the live texts into the files | `apple listing pull`, `play listing pull` | yes | n/a | n/a (writes the listing files after the diff) | none | +| Store-only app (no folder): listing text per language | `listing live`, `listing set` | yes | yes | yes (`listing.set`, one language per apply) | none | +| Store-only app (no folder): URLs, contact, catalog, art | (the config of an app folder) | yes (reads) | no | no | reason: those writes send what an app folder's config holds; "Link a folder" or "Start an app folder" turns the app into a full one | +| Uncommitted changes in the app folder | git status of the edited files | yes | n/a | n/a | none (storebound never commits) | diff --git a/docs/desktop.md b/docs/desktop.md index 0ae5627..be2c4ed 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -170,8 +170,9 @@ host state file (`overrides_set`, every key checked in `workspace.rs`). `store:ios:::;android::`. - The host checks the target and the account kinds, and passes `virtual: { name, apple, play }`. The sidecar writes a minimal `storebound.config.json` (store ids, placeholder links) into its temp folder. -- Only `apple.status`, `play.status`, `health`, `reviews.list`, `apple.testflight_status` and `apple.ppo_status` work - this way (tabs Releases, Health, Reviews, TestFlight, Growth), and `portfolio.status` takes store-only entries. +- Only `apple.status`, `play.status`, `health`, `reviews.list`, `apple.testflight_status`, `apple.ppo_status` and + `listing.live` work this way (tabs Releases, Listings, Health, Reviews, TestFlight, Growth), plus the one write + `listing.set` (listing text per language, see "Editors"), and `portfolio.status` takes store-only entries. - "Link a folder", "Start an app folder here" or "Import from fastlane" turn it into a full app. ### Settings @@ -218,8 +219,14 @@ is [console-parity.md](console-parity.md). first, then the native dialog, then the real run. Price changes of live products stay the owner's price decision. - **Pull from the store** (`play.listing_pull`, `apple.listing_pull`): the live texts into the files, the diff in the dialog first; unsaved edits stay pending on top. -- **Store-only apps** (no folder) stay read-only in the window: a store write sends what the files hold (see - console-parity.md). +- **Store-only apps** (no folder) get a Listings tab too: the live texts per language (`listing.live` = `storebound + listing live`), edited with the same counters and checks and applied straight to the store per language + (`listing.set` = `storebound listing set`: the store's dry run in the window, the native dialog, a PATCH of only the + edited fields). It is the one write a store-only app may run (`VIRTUAL_WRITES` in serve.ts, `STORE_ONLY_WRITES` in + policy.rs); its token is bound to the store ids instead of a folder. URLs, contact, catalog and art need an app + folder (they send what its config holds). +- **Captions:** the screenshot captions per language and scene (the shots copy file) are edited in Store art as a + text edit of the JSON (its own layout, one scene per line, stays). - `tests/edit.test.ts` (round trips, checks, dry run, CLI), `apps/desktop/tests/edits.test.ts` (unsaved edits, undo), and the boot check's editor case (edit a field, counter and limit check, review, save: the fixture file changes by one line, the config keeps its comments, Apply shows the dry run per language). diff --git a/tests/console-parity.test.ts b/tests/console-parity.test.ts new file mode 100644 index 0000000..098bcc4 --- /dev/null +++ b/tests/console-parity.test.ts @@ -0,0 +1,33 @@ +// docs/console-parity.md is the parity checklist of the desktop against App Store Connect and Play Console: every row +// is edited in the window ("Edit" yes) or names its reason, no row is left as an open gap, and every desktop method a +// row names exists in the sidecar (so a renamed or removed method cannot leave the table claiming a write). +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describeMethods } from '../cli/serve'; + +const DOC = readFileSync(join(import.meta.dir, '..', 'docs', 'console-parity.md'), 'utf8'); +const rows = DOC.split('\n').filter(l => /^\| /.test(l) && !/^\| (Console screen|What) \|/.test(l) && !/^\| ---/.test(l)) + .map(l => l.slice(2, -2).split(' | ').map(c => c.trim())); + +describe('console parity checklist', () => { + test('every row: edited in the window, or the reason why not', () => { + expect(rows.length).toBeGreaterThan(60); + for (const r of rows) { + expect(r, r[0]).toHaveLength(6); + const [screen, , , edit, write, gap] = r as [string, string, string, string, string, string]; + if (gap === 'none' || gap.startsWith('none ')) { + // nothing missing: edited (or nothing to edit) and written where the store has an API + expect(edit, screen).not.toBe('no'); + expect(write, screen).not.toMatch(/^no\b/); + } else expect(gap, screen).toMatch(/^reason: \S/); + expect(gap, screen).not.toMatch(/no editor|not in the window$|not shown/); + } + }); + test('the desktop methods the table names exist', () => { + const methods = new Set(describeMethods().map(m => m.method)); + const named = [...DOC.matchAll(/`((?:apple|play|owner|project|listing|shots|reviews|prices|console)\.[a-z_]+)`/g)].map(m => m[1]!); + expect(named.length).toBeGreaterThan(20); + for (const m of new Set(named)) expect(methods.has(m), m).toBe(true); + }); +}); diff --git a/tests/edit.test.ts b/tests/edit.test.ts index da3505c..d2d9f67 100644 --- a/tests/edit.test.ts +++ b/tests/edit.test.ts @@ -80,6 +80,8 @@ describe('config-path: one value set by path, the rest of the file byte for byte expect(out).toContain("apple: { bundleId: 'com.example.app', info: { reviewNotes: 'Line one\\nIt\\'s line two' } },"); const app = setByPath(CFG, 'storebound.config.ts', 'app.marketingUrl', 'https://example.com/m'); expect(app).toContain(" contactEmail: 'support@example.com',\n marketingUrl: 'https://example.com/m',\n },"); + // after a last member without a trailing comma (and its comment) + expect(setByPath('export default {\n a: 1 // one\n};\n', 'c.ts', 'b', 2)).toBe('export default {\n a: 1, // one\n b: 2,\n};\n'); }); test('removes a key; the edited file still loads to the same object otherwise', async () => { const out = setByPath(CFG, 'storebound.config.ts', 'app.website', undefined); @@ -301,3 +303,30 @@ describe('CLI: storebound edit on a copy of the demo app', () => { expect(statSync(f).mode & 0o777).toBe(0o600); }); }); + +describe('JSON files edited as text (the shots copy file keeps one scene per line)', () => { + test('a caption line changes, the other lines and the layout stay', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-copy-')); + cpSync(join(ROOT, 'examples', 'demo-app'), dir, { recursive: true }); + const file = join(dir, 'store', 'shots', 'copy.json'); + const before = readFileSync(file, 'utf8'); + const ops = JSON.stringify([{ kind: 'shotsCopy', lang: 'en', scene: 'lists', field: 'title', line: 1, value: 'in one tidy place' }, { kind: 'shotsCopy', lang: 'tr', scene: 'share', field: 'sub', value: 'Birlikte düzenleyin.' }]); + const r = await Bun.$`bun ${CLI} edit --ops ${ops}`.cwd(dir).quiet().nothrow(); + expect(r.exitCode).toBe(0); + const after = readFileSync(file, 'utf8'); + const changed = after.split('\n').filter((l, i) => l !== before.split('\n')[i]); + expect(changed).toHaveLength(2); + expect(changed[0]).toContain('"title": ["Every list,","in one tidy place"]'); + expect(JSON.parse(after).tr.share.sub).toBe('Birlikte düzenleyin.'); + expect(after.split('\n').length).toBe(before.split('\n').length); + }); + test('setByPath on JSON: insert and remove without trailing commas', () => { + const j = '{\n "a": { "x": 1 },\n "b": 2\n}\n'; + const ins = setByPath(j, 'f.json', 'c', 'new'); + expect(JSON.parse(ins)).toEqual({ a: { x: 1 }, b: 2, c: 'new' }); + expect(ins).toContain(' "b": 2,\n "c": "new"\n}'); + const rem = setByPath(j, 'f.json', 'b', undefined); + expect(JSON.parse(rem)).toEqual({ a: { x: 1 } }); + expect(JSON.parse(setByPath(j, 'f.json', 'a.y', [1, 2]))).toEqual({ a: { x: 1, y: [1, 2] }, b: 2 }); + }); +}); diff --git a/tests/listing-store.test.ts b/tests/listing-store.test.ts new file mode 100644 index 0000000..56d815d --- /dev/null +++ b/tests/listing-store.test.ts @@ -0,0 +1,72 @@ +// Store-only apps edit their listing straight on the store (`storebound listing set`, serve method listing.set): the +// plan (old -> new per field, only the fields sent are judged, the App Store version rule), and the serve rules: the +// only write a store-only app (virtual) may run, a dry run unless the host's token for exactly those store ids comes. +import { describe, expect, test } from 'bun:test'; +import { mint } from '../cli/lib/confirm-token'; +import { ERR } from '../cli/lib/desktop-protocol'; +import { setPlan } from '../cli/listing-store'; +import type { RunOptions, Running } from '../cli/lib/runner'; +import { Session } from '../cli/serve'; + +describe('listing set: the plan', () => { + test('old -> new for the fields sent, the rest left alone', () => { + const p = setPlan('play', 'en-US', { title: 'Tidy', short: 'Old short', full: 'Full text.' }, { short: 'New short' }, { appName: 'Tidy' }); + expect(p.changes).toEqual([{ field: 'short', from: 'Old short', to: 'New short' }]); + expect(p.issues.filter(i => i.level === 'error')).toEqual([]); + expect(setPlan('play', 'en-US', { short: 'Same' }, { short: 'Same ' }, { appName: 'A' }).changes).toEqual([]); + }); + test('limits and rules of the sent fields; a new language', () => { + const p = setPlan('apple', 'en-US', null, { subtitle: 'x'.repeat(31) }, { appName: 'Tidy', editable: true }); + expect(p.create).toBe(true); + expect(p.issues.map(i => [i.field, i.level])).toContainEqual(['subtitle', 'error']); + // required fields the run does not send are not this run's problem + expect(p.issues.some(i => i.field === 'keywords')).toBe(false); + }); + test('App Store: without an editable version only name, subtitle and promotional text', () => { + expect(setPlan('apple', 'en-US', { promotionalText: 'a' }, { promotionalText: 'b' }, { appName: 'A', editable: false }).refused).toBeNull(); + expect(setPlan('apple', 'en-US', { description: 'a' }, { description: 'b' }, { appName: 'A', editable: false }).refused).toMatch(/no editable App Store version/); + }); +}); + +const SECRET_HEX = Array.from({ length: 32 }, (_, i) => (i + 3).toString(16).padStart(2, '0')).join(''); +const SECRET = Buffer.from(SECRET_HEX, 'hex'); +type J = Record; + +async function session(now = () => Date.now()) { + const calls: { argv: string[]; o: RunOptions }[] = []; + const run = (argv: string[], o: RunOptions): Running => { calls.push({ argv, o }); return { result: Promise.resolve({ code: 0, out: 'done', cancelled: false }), cancel: () => {} }; }; + const s = new Session(() => {}, run, now); + let id = 0; + const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as J; + await call('session.hello', { secret: SECRET_HEX }); + return { calls, call }; +} +const err = (r: J) => r.error as { code: number; message: string } | undefined; + +describe('serve: listing.set is the one write of a store-only app', () => { + const virt = { name: 'com.example.tidy', play: { packageName: 'com.example.tidy' } }; + const args = { store: 'play', lang: 'en-US', fields: JSON.stringify({ short: 'Lists that stay tidy' }) }; + test('dry run with the store ids, the real run only with a token bound to them', async () => { + const now = 5_000_000; + const s = await session(() => now); + const dry = await s.call('listing.set', { virtual: virt, args }); + expect(err(dry)).toBeUndefined(); + expect(s.calls.at(-1)!.argv).toEqual(['listing', 'set', '--store', 'play', '--lang', 'en-US', '--fields', args.fields, '--dry', '--json']); + expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false }))!.code).toBe(ERR.confirmRequired); + // a token for a folder, or for another app's store ids, does not fit + expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { project: '/x', args }, { now }) }))!.code).toBe(ERR.confirmInvalid); + expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { virtual: { ...virt, name: 'other' }, args }, { now }) }))!.code).toBe(ERR.confirmInvalid); + const real = await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { virtual: virt, args }, { now }) }); + expect(err(real)).toBeUndefined(); + expect(s.calls.at(-1)!.argv).not.toContain('--dry'); + }); + test('folder writes and other store writes stay refused for store-only apps; listing.live reads', async () => { + const s = await session(); + for (const m of ['play.listing', 'apple.listing', 'project.edit', 'play.images']) { + const r = await s.call(m, { virtual: virt, args: m === 'project.edit' ? { ops: '[]' } : {} }); + expect(err(r)?.message, m).toMatch(/needs an app folder/); + } + expect(err(await s.call('listing.live', { virtual: virt }))).toBeUndefined(); + expect(s.calls.at(-1)!.argv).toEqual(['listing', 'live', '--json']); + }); +}); diff --git a/tests/store-apps.test.ts b/tests/store-apps.test.ts index 9ac2d77..b87eb5d 100644 --- a/tests/store-apps.test.ts +++ b/tests/store-apps.test.ts @@ -238,7 +238,7 @@ describe('serve: store-only apps (virtual)', () => { const r = await s.call('reviews.list', { virtual: V, args: { days: 30 }, credentials: APPLE }); expect(r.error).toBeUndefined(); expect(existsSync(join(s.calls[0]!.o.cwd, 'storebound.config.json'))).toBe(true); - expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'health_issue', 'play_status', 'reviews_list']); + expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'health_issue', 'listing_live', 'play_status', 'reviews_list']); for (const m of ['play.listing', 'catalog.diff', 'project.listings', 'check', 'reviews.approve']) expect((await s.call(m, { virtual: V, args: m === 'reviews.approve' ? { file: 'apple-1.md' } : {} })).error).toMatchObject({ code: ERR.invalidParams }); expect((await s.call('health', { virtual: V, project: '/tmp' })).error).toMatchObject({ code: ERR.invalidParams });