Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52)
This commit is contained in:
parent
d2ce185441
commit
e5f148e534
55 changed files with 1648 additions and 158 deletions
34
CHANGELOG.md
34
CHANGELOG.md
|
|
@ -38,6 +38,40 @@ machine in `~/.storebound`, shared by the CLI and the app.
|
|||
`storebound://open` link, so a folder added in the terminal is selected without a question.
|
||||
- **Desktop:** a folder whose `package.json` pins another storebound version shows "This project uses storebound X,
|
||||
the app runs Y". `storebound-desktop --version`. `ws desktop import / export` carry the accounts' profile names.
|
||||
|
||||
Onboarding apps that are already live, without owner questions for what the stores and the code prove (lessons
|
||||
43-52, from setting storebound up on 6 live apps):
|
||||
|
||||
- **`storebound onboard [--write]`** (MCP `onboard`, dry run by default). Read-only on both stores, never changes a
|
||||
store price. Imports the live one-time products and subscriptions of both stores into `products` / `subscriptions`:
|
||||
ids, type, listings per language, usd = the US price (else Play's own USD price for new regions), Apple's price
|
||||
schedule base territory as an override when it is not the USA, an item on one store only kept with `platforms`;
|
||||
`catalog diff` comes out clean. An item with no USD price anywhere is named with its base price, not left out
|
||||
silently. Fills app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app details and
|
||||
App Store Connect where the config holds a template value (real values that differ are reported and kept); the App
|
||||
Review contact goes into `.storebound/credentials.json` `reviewContact` (masked in output, the phone never printed).
|
||||
Sign-in for review is decided from Apple's review detail (`demoAccountRequired`; a demo account set there is imported
|
||||
into `reviewDemoAccount`, never printed) and the code's auth signals, never a default yes. With accounts and no
|
||||
`accounts.deletionUrl` it looks for the deletion page on the app's own site (/delete-account, /account/delete,
|
||||
/delete, /account-deletion, links in the privacy policy that mention deletion); not found stays a finding. Then the
|
||||
owner questions that are left, in one batch.
|
||||
- **App Store keywords count characters, not bytes.** Live READY_FOR_SALE fields hold Thai 95 characters / 265
|
||||
bytes, Turkish 100 / 105, Polish 99 / 105: `check`, `check --aso`, `listing check`, `listing translate` and
|
||||
`aso suggest` count characters again (lesson 4 was wrong; lesson 43).
|
||||
- **`validate`:** a live app with only the 6.5" iPhone set (APP_IPHONE_65) passes, App Store Connect accepts it for
|
||||
the 6.9" requirement; a 6.9" set is a recommendation (also the two 13" iPad types). Play: a non-default language
|
||||
without its own icon / feature graphic uses the default language's (a note), only the default language's missing
|
||||
graphics block.
|
||||
- **Play Reporting API off:** the finding names the Cloud project and the exact link to enable
|
||||
`playdeveloperreporting.googleapis.com` for it (`apps check`, `apps list`, `health`, `validate`); `apps list` no
|
||||
longer fails but lists the Play apps of this machine's app folders and store-only entries.
|
||||
- **`storebound accounts default <profile>`** (`--clear`): the machine's default credential profile, a pointer file in
|
||||
the credentials folder, shown in `accounts list`. Order: env > the profile a project names > the project's own keys
|
||||
> this default > the only profile (or the one named `default`).
|
||||
- **Store-only apps:** `storebound apps add --store apple|play --app-id <id> --account <profile>` adds an app that is
|
||||
live but has no project on this machine to `~/.storebound/projects.json` (`storeApps`); `apps folders` shows it as
|
||||
"no local project", `apps remove apple:<id>` takes it out. The desktop shows it read-only ("Store only": status,
|
||||
reviews, analytics) under the account with that profile, and keeps the entries when it writes the folder list.
|
||||
- `ws` children get the app's own account choice when the workspace names no account (before: no profile at all).
|
||||
|
||||
Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons 35 to 37):
|
||||
|
|
|
|||
|
|
@ -1091,9 +1091,21 @@ pub async fn store_apps_refresh(window: WebviewWindow, app: AppHandle, state: St
|
|||
let value = r.call_job("apps.list", json!({ "credentials": creds, "args": { "icons": icons } }), job.as_deref()).await?;
|
||||
let at = store::now_iso();
|
||||
state.cache.put(&format!("acct:{id}"), "apps.list", &json!({}), &value, &at).map_err(Fail::host)?;
|
||||
let st = saved(&state)?;
|
||||
let value = match st.accounts.iter().find(|a| a.id == id) {
|
||||
Some(a) => store_only_into(&st.accounts, a, Some(value.clone()), ®istry::read_store_apps(&state.registry), &at).unwrap_or(value),
|
||||
None => value,
|
||||
};
|
||||
Ok(Entry { value, at })
|
||||
}
|
||||
|
||||
/// One account's app list with the machine's store-only apps of its credential profile added (registry.rs).
|
||||
fn store_only_into(all: &[Account], a: &Account, list: Option<Value>, apps: &[registry::StoreOnly], at: &str) -> Option<Value> {
|
||||
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
|
||||
let only = all.iter().filter(|x| x.kind == a.kind).count() == 1;
|
||||
registry::with_store_only(list, store, a.profile.as_deref(), only, apps, at)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountApps {
|
||||
account: String,
|
||||
|
|
@ -1104,11 +1116,19 @@ pub struct AccountApps {
|
|||
#[tauri::command]
|
||||
pub fn store_apps_cached(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<AccountApps>> {
|
||||
guard(&window)?;
|
||||
let ids: Vec<String> = saved(&state)?.accounts.iter().map(|a| a.id.clone()).collect();
|
||||
ids.into_iter()
|
||||
.map(|account| {
|
||||
let entry = state.cache.get(&format!("acct:{account}"), "apps.list", &json!({})).map_err(Fail::host)?;
|
||||
Ok(AccountApps { account, entry })
|
||||
let accounts = saved(&state)?.accounts.clone();
|
||||
let store_only = registry::read_store_apps(&state.registry);
|
||||
accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let entry = state.cache.get(&format!("acct:{}", a.id), "apps.list", &json!({})).map_err(Fail::host)?;
|
||||
// apps live on a store with no project on this machine (`storebound apps add --store … --app-id …`)
|
||||
let at = entry.as_ref().map(|e| e.at.clone()).unwrap_or_else(store::now_iso);
|
||||
let entry = match store_only_into(&accounts, a, entry.as_ref().map(|e| e.value.clone()), &store_only, &at) {
|
||||
Some(value) => Some(Entry { value, at }),
|
||||
None => entry,
|
||||
};
|
||||
Ok(AccountApps { account: a.id.clone(), entry })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
//! in state.json under the same id. `sync` runs at start, when the list is shown and before a deep link is handled;
|
||||
//! adding, removing and locating a folder in the app write the registry too. The first start with a registry missing
|
||||
//! writes the app's own list into it.
|
||||
//! { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }] }
|
||||
//! { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }],
|
||||
//! "storeApps": [{ "id": "<16 hex>", "store": "apple", "appId": "6400000000", "account": "<profile>", "name": "…" }] }
|
||||
//! `storeApps` (written by `storebound apps add --store … --app-id …`) are apps live on a store with no project on this
|
||||
//! machine: they join the app list of the account with that credential profile (`store_apps_cached`), so the
|
||||
//! portfolio shows them read-only ("store only"). Writing the folder list keeps them.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -69,12 +73,87 @@ pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> {
|
|||
if let Some(d) = file.parent() {
|
||||
fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&serde_json::json!({ "version": 1, "projects": entries })).map_err(|e| e.to_string())? + "\n";
|
||||
// the store-only entries the CLI wrote stay as they are
|
||||
let keep = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v.get("storeApps").cloned()).filter(|s| s.as_array().is_some_and(|a| !a.is_empty()));
|
||||
let mut doc = serde_json::json!({ "version": 1, "projects": entries });
|
||||
if let Some(s) = keep {
|
||||
doc["storeApps"] = s;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n";
|
||||
let tmp = file.with_extension(format!("tmp-{}", std::process::id()));
|
||||
fs::write(&tmp, text).map_err(|e| e.to_string())?;
|
||||
fs::rename(&tmp, file).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// An app that is live on a store with no project on this machine (cli/lib/registry.ts StoreOnlyApp).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StoreOnly {
|
||||
pub id: String,
|
||||
/// "apple" | "play"
|
||||
pub store: String,
|
||||
/// Apple: the numeric app id; Play: the package name.
|
||||
pub app_id: String,
|
||||
/// The credential profile that reads it; None = the account of that store when there is only one.
|
||||
pub account: Option<String>,
|
||||
pub name: String,
|
||||
pub bundle_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The store-only entries of the file's text (pure): malformed ones are dropped.
|
||||
pub fn parse_store_apps(text: &str) -> Vec<StoreOnly> {
|
||||
let Ok(v) = serde_json::from_str::<Value>(text) else { return vec![] };
|
||||
let mut out: Vec<StoreOnly> = vec![];
|
||||
for x in v.get("storeApps").and_then(Value::as_array).into_iter().flatten() {
|
||||
let s = |k: &str| x.get(k).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let (Some(store), Some(app_id)) = (s("store"), s("appId")) else { continue };
|
||||
let ok = match store.as_str() {
|
||||
"apple" => app_id.len() >= 6 && app_id.len() <= 12 && app_id.chars().all(|c| c.is_ascii_digit()),
|
||||
"play" => app_id.contains('.') && app_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_'),
|
||||
_ => false,
|
||||
};
|
||||
if !ok || out.iter().any(|o| o.store == store && o.app_id == app_id) {
|
||||
continue;
|
||||
}
|
||||
let id: String = Sha256::digest(format!("store:{store}:{app_id}").as_bytes()).iter().take(8).map(|b| format!("{b:02x}")).collect();
|
||||
let bundle_id = s("bundleId").or_else(|| (store == "play").then(|| app_id.clone()));
|
||||
out.push(StoreOnly { id, name: s("name").unwrap_or_else(|| app_id.clone()), account: s("account"), bundle_id, store, app_id });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn read_store_apps(file: &Path) -> Vec<StoreOnly> {
|
||||
fs::read_to_string(file).map(|t| parse_store_apps(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One account's app list (`apps.list` JSON, or None when never read) with the store-only entries of its profile
|
||||
/// added (pure). `store` is "apple" | "play"; `only` = this is the only account of that store (entries that name no
|
||||
/// profile go to it). The entries already in the list (same id) are left as the store reported them.
|
||||
pub fn with_store_only(list: Option<Value>, store: &str, profile: Option<&str>, only: bool, apps: &[StoreOnly], at: &str) -> Option<Value> {
|
||||
let mine: Vec<&StoreOnly> = apps.iter().filter(|a| a.store == store && (a.account.as_deref().is_some_and(|p| Some(p) == profile) || (a.account.is_none() && only))).collect();
|
||||
if mine.is_empty() {
|
||||
return list;
|
||||
}
|
||||
let ios = store == "apple";
|
||||
let mut v = list.unwrap_or_else(|| serde_json::json!({ "store": if ios { "ios" } else { "android" }, "apps": [], "warnings": [], "at": at }));
|
||||
if !v.get("apps").is_some_and(Value::is_array) {
|
||||
v["apps"] = serde_json::json!([]);
|
||||
}
|
||||
let have: Vec<String> = v["apps"].as_array().map(|a| a.iter().filter_map(|x| x.get("id").and_then(Value::as_str).map(str::to_string)).collect()).unwrap_or_default();
|
||||
for a in mine {
|
||||
if have.contains(&a.app_id) {
|
||||
continue;
|
||||
}
|
||||
let app = serde_json::json!({
|
||||
"store": if ios { "ios" } else { "android" }, "id": a.app_id, "bundleId": a.bundle_id.clone().unwrap_or_default(), "name": a.name,
|
||||
"platforms": [if ios { "IOS" } else { "ANDROID" }], "sku": null, "primaryLocale": null, "latest": null, "icon": null, "registered": true,
|
||||
});
|
||||
if let Some(list) = v["apps"].as_array_mut() {
|
||||
list.push(app);
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn entry_of(p: &Project) -> Entry {
|
||||
Entry { id: p.id.clone(), path: p.path.clone(), name: p.name.clone(), added: p.added.clone() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,40 @@ fn the_folder_list_is_the_machines() {
|
|||
assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::<Vec<_>>(), vec![registry::folder_id("/a")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_only_apps_join_their_profiles_account_and_survive_folder_writes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("projects.json");
|
||||
// as `storebound apps add --store apple --app-id 6400000999 --account teamA` and a Play one without a profile wrote it
|
||||
std::fs::write(&file, r#"{"version":1,"projects":[],"storeApps":[
|
||||
{"id":"x","store":"apple","appId":"6400000999","account":"teamA","name":"Example Aquarium","bundleId":"com.example.aquarium","added":"t"},
|
||||
{"store":"play","appId":"com.example.legacy","name":"Legacy"},
|
||||
{"store":"apple","appId":"not-a-number"}]}"#).unwrap();
|
||||
let apps = registry::read_store_apps(&file);
|
||||
assert_eq!(apps.iter().map(|a| a.app_id.as_str()).collect::<Vec<_>>(), vec!["6400000999", "com.example.legacy"]);
|
||||
assert_eq!(apps[1].bundle_id.as_deref(), Some("com.example.legacy"));
|
||||
// the app writes its folder list: the store-only entries stay
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", None)], ..State::default() };
|
||||
registry::publish(&st, &file, &[]).unwrap();
|
||||
assert_eq!(registry::read_store_apps(&file).len(), 2);
|
||||
assert!(registry::sync(&mut st, &file).is_ok());
|
||||
assert_eq!(registry::read_store_apps(&file).len(), 2);
|
||||
// an account of profile teamA that never listed its apps: the entry shows as its app, read-only, no folder
|
||||
let v = registry::with_store_only(None, "apple", Some("teamA"), false, &apps, "t").unwrap();
|
||||
assert_eq!(v["store"], "ios");
|
||||
assert_eq!(v["apps"][0]["id"], "6400000999");
|
||||
assert_eq!(v["apps"][0]["bundleId"], "com.example.aquarium");
|
||||
assert_eq!(v["apps"][0]["registered"], true);
|
||||
// an account of another profile gets nothing; the list the store returned is kept, the entry not doubled
|
||||
assert_eq!(registry::with_store_only(None, "apple", Some("teamB"), false, &apps, "t"), None);
|
||||
let listed = serde_json::json!({ "store": "ios", "apps": [{ "id": "6400000999", "name": "From the store" }], "warnings": [], "at": "t" });
|
||||
assert_eq!(registry::with_store_only(Some(listed.clone()), "apple", Some("teamA"), false, &apps, "t"), Some(listed));
|
||||
// no profile named: the only Play account takes it
|
||||
let p = registry::with_store_only(None, "play", Some("any"), true, &apps, "t").unwrap();
|
||||
assert_eq!(p["apps"][0]["name"], "Legacy");
|
||||
assert_eq!(registry::with_store_only(None, "play", Some("any"), false, &apps, "t"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_pins_another_storebound_says_so() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -208,7 +208,9 @@ fn log_entries_are_one_block_and_rotate() {
|
|||
/// next to it as `<name>.host.json`, after the start-up sync with the machine's shared stores: D1 keeps its accounts in a
|
||||
/// keychain that has no entries (the keys were never added: the accounts stay without a profile), D3's keychain has
|
||||
/// them (they move into credential profiles), and shared-profiles is the 0.8 format whose apple account comes from a
|
||||
/// profile the CLI wrote, with a folder that pins storebound 0.6.0. tools/desktop-boot-check.ts boots the built window on exactly these answers.
|
||||
/// profile the CLI wrote, with a folder that pins storebound 0.6.0 and a store-only app of that profile (`storebound
|
||||
/// apps add --store apple --app-id … --account default`: store_apps_cached lists it under the apple account).
|
||||
/// tools/desktop-boot-check.ts boots the built window on exactly these answers.
|
||||
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
|
||||
#[test]
|
||||
fn host_answers_for_the_fixtures_are_current() {
|
||||
|
|
@ -240,9 +242,22 @@ fn host_answers_for_the_fixtures_are_current() {
|
|||
let pin = project_view(&p).storebound_pin.expect("0.6.0 is not this app");
|
||||
projects[0]["storeboundPin"] = json!(pin);
|
||||
}
|
||||
// store_apps_cached before any store read: only the machine's store-only entries (registry.rs)
|
||||
let store_only = if name == "shared-profiles" {
|
||||
registry::parse_store_apps(r#"{"storeApps":[{"store":"apple","appId":"6400000999","account":"default","name":"Example Aquarium","bundleId":"com.example.aquarium"}]}"#)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let cached: Vec<serde_json::Value> = st.accounts.iter().map(|a| {
|
||||
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
|
||||
let only = st.accounts.iter().filter(|x| x.kind == a.kind).count() == 1;
|
||||
let entry = registry::with_store_only(None, store, a.profile.as_deref(), only, &store_only, "2026-10-13T00:00:00Z").map(|value| json!({ "value": value, "at": "2026-10-13T00:00:00Z" }));
|
||||
json!({ "account": a.id, "entry": entry })
|
||||
}).collect();
|
||||
let host = json!({
|
||||
"projects_list": projects,
|
||||
"accounts_list": st.accounts,
|
||||
"store_apps_cached": cached,
|
||||
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
|
||||
});
|
||||
let want = format!("{}\n", serde_json::to_string_pretty(&host).unwrap());
|
||||
|
|
|
|||
|
|
@ -582,7 +582,7 @@ export const en = {
|
|||
'action.importApps': 'Import apps',
|
||||
'action.importAppsHint': 'Read-only: every app each store account can see, from App Store Connect and Google Play',
|
||||
'apps.storeOnly': 'Store only',
|
||||
'apps.storeOnlyHint': 'On a store account, no app folder yet: store data only (read-only)',
|
||||
'apps.storeOnlyHint': 'On a store account, no local project (no app folder on this machine): store data only (read-only)',
|
||||
'common.save': 'Save',
|
||||
'studio.studio': 'Studio',
|
||||
'studio.none': 'No studio',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"playAccount": null
|
||||
}
|
||||
],
|
||||
"store_apps_cached": [],
|
||||
"workspace_get": {
|
||||
"overrides": {
|
||||
"appStudio": [],
|
||||
|
|
|
|||
|
|
@ -43,6 +43,20 @@
|
|||
"playAccount": "0123456789abcdef"
|
||||
}
|
||||
],
|
||||
"store_apps_cached": [
|
||||
{
|
||||
"account": "0123456789abcdef",
|
||||
"entry": null
|
||||
},
|
||||
{
|
||||
"account": "fedcba9876543210",
|
||||
"entry": null
|
||||
},
|
||||
{
|
||||
"account": "00112233445566ff",
|
||||
"entry": null
|
||||
}
|
||||
],
|
||||
"workspace_get": {
|
||||
"overrides": {
|
||||
"appStudio": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"accounts_list": [],
|
||||
"projects_list": [],
|
||||
"store_apps_cached": [],
|
||||
"workspace_get": {
|
||||
"overrides": {
|
||||
"appStudio": [],
|
||||
|
|
|
|||
|
|
@ -28,6 +28,39 @@
|
|||
"storeboundPin": "0.6.0"
|
||||
}
|
||||
],
|
||||
"store_apps_cached": [
|
||||
{
|
||||
"account": "82a8c2561d950999",
|
||||
"entry": null
|
||||
},
|
||||
{
|
||||
"account": "8e4050ce9325b4da",
|
||||
"entry": {
|
||||
"at": "2026-10-13T00:00:00Z",
|
||||
"value": {
|
||||
"apps": [
|
||||
{
|
||||
"bundleId": "com.example.aquarium",
|
||||
"icon": null,
|
||||
"id": "6400000999",
|
||||
"latest": null,
|
||||
"name": "Example Aquarium",
|
||||
"platforms": [
|
||||
"IOS"
|
||||
],
|
||||
"primaryLocale": null,
|
||||
"registered": true,
|
||||
"sku": null,
|
||||
"store": "ios"
|
||||
}
|
||||
],
|
||||
"at": "2026-10-13T00:00:00Z",
|
||||
"store": "ios",
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"workspace_get": {
|
||||
"overrides": {
|
||||
"appStudio": [],
|
||||
|
|
|
|||
|
|
@ -10,10 +10,14 @@
|
|||
// contact and demo account stay in the project
|
||||
// storebound accounts use <profile> [--dir <project>] this project uses that profile ("account" in its
|
||||
// .storebound/credentials.json)
|
||||
// storebound accounts default <profile> | --clear the machine's default profile (a pointer file in the
|
||||
// credentials folder). Order: env > a profile the project
|
||||
// names > the project's own keys > this default > the only
|
||||
// profile (or the one named "default")
|
||||
import { resolve } from 'node:path';
|
||||
import type { Args } from './lib/args';
|
||||
import { applyImport, describeProfiles, planImport } from './lib/accounts';
|
||||
import { activeProfile, profileDir } from './lib/credentials';
|
||||
import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials';
|
||||
import { confirm, type Ctx } from './lib/ctx';
|
||||
import { textTable } from './lib/table';
|
||||
|
||||
|
|
@ -26,14 +30,17 @@ export async function run(args: Args): Promise<void> {
|
|||
const rows = describeProfiles();
|
||||
let active = null;
|
||||
try { active = activeProfile(dir); } catch { /* unreadable project file */ }
|
||||
if (args.has('json')) { console.log(JSON.stringify({ folder: tilde(profileDir()), profiles: rows, thisFolder: active }, null, 2)); return; }
|
||||
const def = defaultProfile();
|
||||
const pointer = defaultPointer();
|
||||
if (args.has('json')) { console.log(JSON.stringify({ folder: tilde(profileDir()), profiles: rows.map(r => ({ ...r, default: r.profile === def })), default: def, defaultFrom: def ? (def === pointer ? 'pointer' : rows.length === 1 ? 'only' : 'named-default') : null, thisFolder: active }, null, 2)); return; }
|
||||
if (!rows.length) {
|
||||
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
|
||||
return;
|
||||
}
|
||||
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-'])));
|
||||
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
|
||||
if (pointer && pointer !== def) console.log(`note: the default pointer names "${pointer}", which is not a profile here (storebound accounts default <profile>)`);
|
||||
console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`);
|
||||
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and none named "default": storebound accounts use <profile>)');
|
||||
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and no default: storebound accounts default <profile>, or accounts use <profile> in the project)');
|
||||
return;
|
||||
}
|
||||
if (sub === 'import') {
|
||||
|
|
@ -61,5 +68,13 @@ export async function run(args: Args): Promise<void> {
|
|||
console.log(`${tilde(f)}: "account": "${name}"`);
|
||||
return;
|
||||
}
|
||||
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile>]');
|
||||
if (sub === 'default') {
|
||||
if (args.has('clear')) { setDefaultPointer(null); console.log('default profile pointer removed (default: the only profile, or the one named "default")'); return; }
|
||||
const name = args._[1];
|
||||
if (!name) { const d = defaultProfile(); console.log(d ? `default profile: ${d}${d === defaultPointer() ? ' (accounts default)' : ''}` : 'no default profile (storebound accounts default <profile>)'); return; }
|
||||
const f = setDefaultPointer(name);
|
||||
console.log(`${tilde(f)}: default profile "${name}" (used by every folder that names no profile and has no keys of its own)`);
|
||||
return;
|
||||
}
|
||||
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile> | default <profile> | default --clear]');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appEvents_getInstance': 'apple/events',
|
||||
'appEvents_localizations_getToManyRelated': 'apple/events',
|
||||
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo console/verify validate',
|
||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
|
||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing onboard validate',
|
||||
'appInfos_getInstance': 'apple/appinfo',
|
||||
'appInfos_updateInstance': 'apple/appinfo',
|
||||
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
|
||||
|
|
@ -54,8 +54,8 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
|
||||
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
|
||||
'appStoreVersionReleaseRequests_createInstance': 'apple/release',
|
||||
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots validate',
|
||||
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details onboard validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots onboard validate',
|
||||
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
|
||||
'appStoreVersions_build_getToOneRelated': 'apple/listing apple/resubmit validate',
|
||||
'appStoreVersions_build_updateToOneRelationship': 'apple/listing apple/resubmit',
|
||||
|
|
@ -70,7 +70,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
|
||||
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
|
||||
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
|
||||
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps validate',
|
||||
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps onboard validate',
|
||||
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
|
||||
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
|
||||
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
|
||||
|
|
@ -78,7 +78,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_buildUploads_getToManyRelated': 'apple/upload',
|
||||
'apps_customerReviews_getToManyRelated': 'reviews',
|
||||
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id console/verify lib/store-apps',
|
||||
'apps_getInstance': 'apple/appinfo apple/status',
|
||||
'apps_getInstance': 'apple/appinfo apple/status apps onboard',
|
||||
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
|
||||
'apps_perfPowerMetrics_getToManyRelated': 'health',
|
||||
'apps_promotedPurchases_getToManyRelated': 'apple/promoted',
|
||||
|
|
@ -209,13 +209,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'androidpublisher.edits.bundles.upload': 'play/release play/upload',
|
||||
'androidpublisher.edits.commit': 'play/client',
|
||||
'androidpublisher.edits.countryavailability.get': 'validate',
|
||||
'androidpublisher.edits.delete': 'apps play/client validate',
|
||||
'androidpublisher.edits.details.get': 'apps play/listing play/status validate',
|
||||
'androidpublisher.edits.delete': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.details.get': 'apps onboard play/listing play/status validate',
|
||||
'androidpublisher.edits.details.update': 'play/listing',
|
||||
'androidpublisher.edits.images.deleteall': 'play/images',
|
||||
'androidpublisher.edits.images.list': 'apps play/images validate',
|
||||
'androidpublisher.edits.images.upload': 'play/images',
|
||||
'androidpublisher.edits.insert': 'apps play/client validate',
|
||||
'androidpublisher.edits.insert': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.listings.list': 'play/listing play/status validate',
|
||||
'androidpublisher.edits.listings.update': 'play/listing',
|
||||
'androidpublisher.edits.tracks.get': 'play/release play/rollout play/upload',
|
||||
|
|
|
|||
81
cli/apps.ts
81
cli/apps.ts
|
|
@ -8,21 +8,25 @@
|
|||
// storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,
|
||||
// the same list as the desktop app's App folders)
|
||||
// storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)
|
||||
// storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]
|
||||
// a store app with no project on this machine: the
|
||||
// desktop shows it read-only ("no local project");
|
||||
// apps remove apple:<id> | <entry id> takes it out
|
||||
import type { Args } from './lib/args';
|
||||
import { ascKey, playServiceAccount } from './lib/credentials';
|
||||
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
|
||||
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
|
||||
import { errorBody } from './lib/http';
|
||||
import { log, printJson, warn } from './lib/log';
|
||||
import { appleCheck, type Bytes, dataUrl, type Get, listAppleApps, listPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
|
||||
import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
|
||||
import { textTable } from './lib/table';
|
||||
import { ascClient } from './apple/asc';
|
||||
import { REPORTING_API } from './play/google';
|
||||
import * as rt from './lib/rt';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { CONFIG_NAMES } from './lib/config';
|
||||
import { addFolder, readRegistry, registryFile, removeFolder } from './lib/registry';
|
||||
import { addFolder, addStoreApp, readRegistry, registryFile, removeFolder, removeStoreApp } from './lib/registry';
|
||||
|
||||
type Store = 'apple' | 'play';
|
||||
|
||||
|
|
@ -99,8 +103,42 @@ export async function listApps(store: Store, root: string, icons: boolean): Prom
|
|||
return { store: 'ios', ...r, at };
|
||||
}
|
||||
const sa = playServiceAccount(root);
|
||||
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {});
|
||||
return { store: 'android', ...r, at };
|
||||
try {
|
||||
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {});
|
||||
return { store: 'android', ...r, at };
|
||||
} catch (e) {
|
||||
// the Reporting API is off in the service account's Cloud project: a clear finding with the exact link, and the
|
||||
// Play apps this machine knows (app folders' configs, store-only entries) instead of failing the whole list
|
||||
if (!isServiceDisabled(e)) throw e;
|
||||
const c = playCheck(sa.client_email, { error: e });
|
||||
const known = knownPlayPackages(root);
|
||||
return { store: 'android', apps: localPlayApps(known), at,
|
||||
warnings: [`${c.message} Fix: ${c.fix}`, known.length ? `listed ${known.length} Play app(s) from this machine's app folders and store-only entries instead (not read from Google Play)` : 'no Play app folders on this machine to list instead (storebound apps add)'] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Play package names this machine knows without the Reporting API: the config of `root`, every app folder of
|
||||
* ~/.storebound/projects.json (read as text, never run) and the store-only Play entries. */
|
||||
export function knownPlayPackages(root: string): { packageName: string; name: string; source: string }[] {
|
||||
const out: { packageName: string; name: string; source: string }[] = [];
|
||||
const reg = readRegistry();
|
||||
const fromFolder = (dir: string, name: string) => {
|
||||
for (const n of CONFIG_NAMES) {
|
||||
const f = join(dir, n);
|
||||
if (!existsSync(f)) continue;
|
||||
try {
|
||||
const text = readFileSync(f, 'utf8');
|
||||
const pkg = /\bpackageName\s*["']?\s*:\s*["']([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["']/.exec(text)?.[1];
|
||||
const appName = /\bapp\s*["']?\s*:\s*\{[^}]*?\bname\s*["']?\s*:\s*["']([^"'\n]+)["']/.exec(text)?.[1];
|
||||
if (pkg && !out.some(o => o.packageName === pkg)) out.push({ packageName: pkg, name: appName ?? name, source: dir });
|
||||
} catch { /* unreadable: skipped */ }
|
||||
return;
|
||||
}
|
||||
};
|
||||
fromFolder(root, '');
|
||||
for (const p of reg.projects) fromFolder(p.path, p.name);
|
||||
for (const a of reg.storeApps ?? []) if (a.store === 'play' && !out.some(o => o.packageName === a.appId)) out.push({ packageName: a.appId, name: a.name, source: 'store-only entry' });
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function checkAccount(store: Store, root: string): Promise<AccountCheck> {
|
||||
|
|
@ -123,16 +161,43 @@ export async function checkAccount(store: Store, root: string): Promise<AccountC
|
|||
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
|
||||
|
||||
/** apps folders / add / remove: the machine's app folder list (cli/lib/registry.ts). */
|
||||
function folders(sub: string, args: Args): void {
|
||||
async function folders(sub: string, args: Args): Promise<void> {
|
||||
if (sub === 'folders') {
|
||||
const r = readRegistry();
|
||||
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })) }); return; }
|
||||
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })), storeApps: r.storeApps ?? [] }); return; }
|
||||
if (r.storeApps?.length) {
|
||||
console.log(textTable(['store app', 'store', 'app id', 'profile', ''], r.storeApps.map(a => [a.name, a.store, a.appId, a.account ?? '(default)', 'no local project'])));
|
||||
console.log('');
|
||||
}
|
||||
if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; }
|
||||
console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found'])));
|
||||
console.log(`
|
||||
${tilde(registryFile())}: the same list as the desktop app's App folders`);
|
||||
return;
|
||||
}
|
||||
const store = args.get('store');
|
||||
if (sub === 'add' && store !== undefined) {
|
||||
if (store !== 'apple' && store !== 'play') throw new Error('--store: apple or play');
|
||||
const appId = args.get('app-id');
|
||||
if (!appId) throw new Error('storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <name>]');
|
||||
let name = args.get('name'), bundleId = args.get('bundle-id') ?? null;
|
||||
// Apple: the name and bundle id from App Store Connect when the profile's key can read them (read-only; offline is fine)
|
||||
if (store === 'apple' && (!name || !bundleId) && /^\d{6,12}$/.test(appId)) {
|
||||
try {
|
||||
const a = (await (await appleGet(process.cwd()))(`/v1/apps/${appId}?fields[apps]=name,bundleId`)).data;
|
||||
name ||= a?.attributes?.name; bundleId ||= a?.attributes?.bundleId ?? null;
|
||||
} catch { /* no key or no access: the entry keeps what was given */ }
|
||||
}
|
||||
const r = addStoreApp({ store, appId, account: args.get('account') ?? (process.env.STOREBOUND_PROFILE || null), name, bundleId });
|
||||
log('apps', r.added ? `added ${store} app ${r.app.appId} (${r.app.name}) with no local project${r.app.account ? `, profile ${r.app.account}` : ''}: the desktop shows it read-only (status, reviews, analytics)`
|
||||
: r.changed ? `updated ${store} app ${r.app.appId}` : `${store} app ${r.app.appId} is already in the list`);
|
||||
return;
|
||||
}
|
||||
if (sub === 'remove' && args._[1] && (/^(apple|play):/.test(args._[1]) || (readRegistry().storeApps ?? []).some(a => a.id === args._[1]))) {
|
||||
const removed = removeStoreApp(args._[1]);
|
||||
log('apps', removed ? `removed ${removed.store} app ${removed.appId} from the list` : `${args._[1]} is not in the list`);
|
||||
return;
|
||||
}
|
||||
const dir = resolve(args._[1] ?? '.');
|
||||
if (sub === 'add') {
|
||||
if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new Error(`${dir}: no storebound config here (storebound init starts one)`);
|
||||
|
|
@ -181,6 +246,6 @@ export async function run(args: Args): Promise<void> {
|
|||
if (out.some(c => c.status !== 'ok')) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps remove <folder>');
|
||||
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps add --store apple|play --app-id <id> [--account <profile>] | apps remove <folder> | apps remove apple:<id>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,8 +220,8 @@ async function suggest(ctx: Ctx): Promise<void> {
|
|||
const skipped = r.candidates.filter(c => c.status !== 'candidate' && c.kind === 'word').slice(0, 12);
|
||||
if (skipped.length) log(TAG, `left out: ${skipped.map(c => `${c.term} (${c.why})`).join(', ')}`);
|
||||
const p = r.proposal;
|
||||
if (p.after === p.before) { log(TAG, `${r.locale}: keyword field unchanged (${p.bytes}/100 bytes)`); continue; }
|
||||
console.log(`--- ${r.locale} keywords (store/appstore.md)\n- ${p.before || '(empty)'}\n+ ${p.after}\n ${p.bytes}/100 bytes · kept ${p.kept.length} · added ${p.added.join(', ') || 'none'}${p.dropped.length ? ` · dropped ${p.dropped.map(d => `${d.term} (${d.why})`).join(', ')}` : ''}`);
|
||||
if (p.after === p.before) { log(TAG, `${r.locale}: keyword field unchanged (${p.chars}/100 characters)`); continue; }
|
||||
console.log(`--- ${r.locale} keywords (store/appstore.md)\n- ${p.before || '(empty)'}\n+ ${p.after}\n ${p.chars}/100 characters · kept ${p.kept.length} · added ${p.added.join(', ') || 'none'}${p.dropped.length ? ` · dropped ${p.dropped.map(d => `${d.term} (${d.why})`).join(', ')}` : ''}`);
|
||||
}
|
||||
log(TAG, 'relevance: in your own text (+1) and in competitors\' names (+1); "in names": top apps that already have the word in their name; popularity only from Apple Ads, never estimated');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { ascClient, type J } from './apple/asc';
|
|||
import { type CatalogRow, catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from './lib/catalog';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { done, log, printJson } from './lib/log';
|
||||
import { fromMoney } from './lib/money';
|
||||
import { fromMoney, type Money } from './lib/money';
|
||||
import { listOneTimeProducts } from './play/products';
|
||||
import { playClient } from './play/client';
|
||||
import { appleSubPrices, basePlanId } from './lib/subscriptions';
|
||||
|
|
@ -25,7 +25,7 @@ import { textTable } from './lib/table';
|
|||
|
||||
const TAG = 'catalog';
|
||||
|
||||
async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
export async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
|
|
@ -37,11 +37,15 @@ async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
const hasSchedule = !!(await c.maybe(`/v2/inAppPurchases/${p.id}/iapPriceSchedule`))?.data;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
// today's price per territory: entries that started (or have no start), the latest start wins; manual over automatic
|
||||
const rows = hasSchedule ? (await appleCurrent(c, p.id, p.attributes.productId, true)).rows.filter(r => (r.startDate ?? '') <= today)
|
||||
.sort((x, y) => (x.kind === 'auto' ? 0 : 1) - (y.kind === 'auto' ? 0 : 1) || (x.startDate ?? '').localeCompare(y.startDate ?? '')) : [];
|
||||
const cur = hasSchedule ? await appleCurrent(c, p.id, p.attributes.productId, true) : null;
|
||||
const rows = (cur?.rows ?? []).filter(r => (r.startDate ?? '') <= today)
|
||||
.sort((x, y) => (x.kind === 'auto' ? 0 : 1) - (y.kind === 'auto' ? 0 : 1) || (x.startDate ?? '').localeCompare(y.startDate ?? ''));
|
||||
const byRegion = new Map(rows.map(r => [r.region, r] as const));
|
||||
// the price schedule's base territory (alpha-3) and its price today
|
||||
const base = cur?.base ? byRegion.get(regionOf(cur.base)) : undefined;
|
||||
out.push({ id: p.attributes.productId, kind: fromAppleType(p.attributes.inAppPurchaseType), state: p.attributes.state, locales,
|
||||
prices: [...byRegion.values()].map(r => ({ region: r.region, currency: r.currency, price: r.price })) });
|
||||
prices: [...byRegion.values()].map(r => ({ region: r.region, currency: r.currency, price: r.price })),
|
||||
...(base ? { base: { region: base.region, currency: base.currency, price: base.price } } : {}) });
|
||||
}
|
||||
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
|
||||
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
|
||||
|
|
@ -52,14 +56,14 @@ async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
const promos = (await c.all(`/v1/subscriptions/${s.id}/promotionalOffers?limit=200`)).map((o: J) => o.attributes.offerCode as string);
|
||||
const codes = (await c.all(`/v1/subscriptions/${s.id}/offerCodes?limit=200`)).map((o: J) => `code:${o.attributes.name}`);
|
||||
out.push({ id: s.attributes.productId, kind: 'subscription', state: s.attributes.state, period: fromApplePeriod(s.attributes.subscriptionPeriod),
|
||||
level: s.attributes.groupLevel, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })),
|
||||
level: s.attributes.groupLevel, group: g.attributes.referenceName, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })),
|
||||
offers: [...intro, ...promos, ...codes] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
export async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const play = await playClient(root, config.play!.packageName);
|
||||
const out: StoreItem[] = [];
|
||||
|
|
@ -69,7 +73,8 @@ async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
if (!p) continue;
|
||||
const opt = p.purchaseOptions?.find(o => o.purchaseOptionId === 'buy') ?? p.purchaseOptions?.[0];
|
||||
out.push({ id: productId, kind: 'one_time', state: opt?.state, locales: ((p.listings as { languageCode: string }[] | undefined) ?? []).map(l => l.languageCode),
|
||||
prices: (opt?.regionalPricingAndAvailabilityConfigs ?? []).filter(r => r.price && r.availability !== 'NO_LONGER_AVAILABLE').map(r => ({ region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) })) });
|
||||
prices: (opt?.regionalPricingAndAvailabilityConfigs ?? []).filter(r => r.price && r.availability !== 'NO_LONGER_AVAILABLE').map(r => ({ region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) })),
|
||||
...((opt?.newRegionsConfig as { usdPrice?: Money } | undefined)?.usdPrice ? { usd: fromMoney((opt!.newRegionsConfig as { usdPrice: Money }).usdPrice) } : {}) });
|
||||
}
|
||||
let page = '';
|
||||
do {
|
||||
|
|
@ -83,14 +88,15 @@ async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
|||
const offers = bp ? ((await play.call<{ subscriptionOffers?: { offerId: string }[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.productId)}/basePlans/${bp.basePlanId}/offers`)).subscriptionOffers ?? []).map(o => o.offerId) : [];
|
||||
out.push({ id: s.productId, kind: 'subscription', state: bp?.state, period: bp?.autoRenewingBasePlanType?.billingPeriodDuration ?? bp?.prepaidBasePlanType?.billingPeriodDuration,
|
||||
locales: (s.listings ?? []).map((l: J) => l.languageCode),
|
||||
prices: (bp?.regionalConfigs ?? []).filter((x: J) => x.price).map((x: J) => ({ region: x.regionCode, currency: x.price.currencyCode, price: fromMoney(x.price) })), offers });
|
||||
prices: (bp?.regionalConfigs ?? []).filter((x: J) => x.price).map((x: J) => ({ region: x.regionCode, currency: x.price.currencyCode, price: fromMoney(x.price) })), offers,
|
||||
...(bp?.otherRegionsConfig?.usdPrice ? { usd: fromMoney(bp.otherRegionsConfig.usdPrice) } : {}) });
|
||||
}
|
||||
page = r.nextPageToken ?? '';
|
||||
} while (page);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
export async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
|
|
@ -106,7 +112,7 @@ async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
|||
return out;
|
||||
}
|
||||
|
||||
async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
export async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const play = await playClient(root, config.play!.packageName);
|
||||
const out: RemoteCatalog = {};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
|||
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
||||
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app'],
|
||||
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],
|
||||
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile>'],
|
||||
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile> | default <profile> (the machine default; --clear)'],
|
||||
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
|
||||
'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'],
|
||||
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],
|
||||
|
|
@ -30,6 +30,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
|
||||
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
|
||||
'onboard': [() => import('./onboard'), 'an app that is already live: the live products + subscriptions with their USD / base prices, contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page, from the stores, the code and the app\'s site -> config and .storebound/credentials.json (--write; read-only on the stores, never changes store prices); then the owner questions nothing proves'],
|
||||
'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'],
|
||||
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'],
|
||||
'triage': [() => import('./triage'), 'TestFlight crash / screenshot feedback and Play error reports grouped by signature, device, OS version, build; read-only, --json'],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
],
|
||||
"commands": {
|
||||
"accounts": [
|
||||
"clear",
|
||||
"dir",
|
||||
"name"
|
||||
],
|
||||
|
|
@ -261,7 +262,10 @@
|
|||
"with-automatic"
|
||||
],
|
||||
"apps": [
|
||||
"app-id",
|
||||
"bundle-id",
|
||||
"icons",
|
||||
"name",
|
||||
"store"
|
||||
],
|
||||
"aso": [
|
||||
|
|
@ -436,6 +440,36 @@
|
|||
"translate"
|
||||
],
|
||||
"mcp": [],
|
||||
"onboard": [
|
||||
"all",
|
||||
"allow-price-changes",
|
||||
"base",
|
||||
"capacitor",
|
||||
"ceiling",
|
||||
"cordova",
|
||||
"cordova-android",
|
||||
"cordova-ios",
|
||||
"csv",
|
||||
"expo",
|
||||
"file",
|
||||
"floor",
|
||||
"flutter",
|
||||
"godot",
|
||||
"no-catalog",
|
||||
"no-subscriptions",
|
||||
"no-web",
|
||||
"offline",
|
||||
"out",
|
||||
"react-native",
|
||||
"reconvert",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
"unity",
|
||||
"update-bigmac",
|
||||
"with-automatic",
|
||||
"write"
|
||||
],
|
||||
"originality": [
|
||||
"against",
|
||||
"threshold"
|
||||
|
|
@ -755,6 +789,7 @@
|
|||
"alpha",
|
||||
"apk",
|
||||
"app",
|
||||
"app-id",
|
||||
"apple",
|
||||
"apply",
|
||||
"apps",
|
||||
|
|
@ -767,6 +802,7 @@
|
|||
"body",
|
||||
"build",
|
||||
"build-number",
|
||||
"bundle-id",
|
||||
"by",
|
||||
"campaign",
|
||||
"cancel-submission",
|
||||
|
|
@ -775,6 +811,7 @@
|
|||
"ceiling",
|
||||
"channel",
|
||||
"chrome-profile",
|
||||
"clear",
|
||||
"codes",
|
||||
"command",
|
||||
"comments",
|
||||
|
|
@ -850,6 +887,7 @@
|
|||
"name",
|
||||
"new-version",
|
||||
"no-captions",
|
||||
"no-catalog",
|
||||
"no-declarations",
|
||||
"no-folder",
|
||||
"no-health",
|
||||
|
|
@ -866,6 +904,7 @@
|
|||
"no-validate",
|
||||
"no-variants",
|
||||
"no-wait",
|
||||
"no-web",
|
||||
"off",
|
||||
"offline",
|
||||
"older-than",
|
||||
|
|
@ -960,7 +999,8 @@
|
|||
"accounts": [
|
||||
"storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which",
|
||||
"storebound accounts import [--dir <project>] [--name <profile>] [--dry] [--yes]",
|
||||
"storebound accounts use <profile> [--dir <project>] this project uses that profile (\"account\" in its"
|
||||
"storebound accounts use <profile> [--dir <project>] this project uses that profile (\"account\" in its",
|
||||
"storebound accounts default <profile> | --clear the machine's default profile (a pointer file in the"
|
||||
],
|
||||
"ads": [
|
||||
"storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json]"
|
||||
|
|
@ -1101,7 +1141,8 @@
|
|||
"storebound apps list [--store apple|play] [--icons] [--json] every app the key can see (App Store Connect team /",
|
||||
"storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix",
|
||||
"storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,",
|
||||
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)"
|
||||
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)",
|
||||
"storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]"
|
||||
],
|
||||
"aso": [
|
||||
"storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]",
|
||||
|
|
@ -1173,6 +1214,9 @@
|
|||
"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"
|
||||
],
|
||||
"onboard": [
|
||||
"storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]"
|
||||
],
|
||||
"originality": [
|
||||
"storebound originality [--against <folder>[,<folder>]] [--threshold 0.7] [--json]"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -169,10 +169,10 @@ export function nextActions(a: AsoAudit, i: AuditInput): Action[] {
|
|||
const err = f.find(x => x.level === 'error');
|
||||
if (err) out.push({ what: `fix ${err.where}: ${err.msg}`, why: 'App Store Connect refuses the keyword field as it is', command: 'storebound check --aso' });
|
||||
if (a.drafts.length) out.push({ what: `review the draft keywords of ${a.drafts.join(', ')} in store/appstore.md and delete the draft line`, why: 'they were proposed by aso suggest, not reviewed yet' });
|
||||
const room = f.find(x => /bytes used; fill/.test(x.msg));
|
||||
if (room) out.push({ what: `fill the keyword field of ${room.where.replace('apple ', '')} (${room.msg.replace(/^keywords: /, '')})`, why: 'unused bytes are unused search reach', command: `storebound aso suggest --locale ${room.where.replace('apple ', '')}` });
|
||||
const room = f.find(x => /characters used; fill/.test(x.msg));
|
||||
if (room) out.push({ what: `fill the keyword field of ${room.where.replace('apple ', '')} (${room.msg.replace(/^keywords: /, '')})`, why: 'unused characters are unused search reach', command: `storebound aso suggest --locale ${room.where.replace('apple ', '')}` });
|
||||
const rep = f.find(x => /repeat words from the name/.test(x.msg));
|
||||
if (rep) out.push({ what: `${rep.where}: replace the words the name / subtitle already index (${rep.msg.split(': ').at(-1)})`, why: 'they take bytes without adding reach', command: `storebound aso suggest --locale ${rep.where.replace('apple ', '')}` });
|
||||
if (rep) out.push({ what: `${rep.where}: replace the words the name / subtitle already index (${rep.msg.split(': ').at(-1)})`, why: 'they take characters without adding reach', command: `storebound aso suggest --locale ${rep.where.replace('apple ', '')}` });
|
||||
const gap = a.competitors.gaps.find(g => g.gaps.length);
|
||||
if (gap) out.push({ what: `consider "${gap.gaps.slice(0, 3).map(g => g.word).join('", "')}" for ${gap.locale} (in the names of ${gap.gaps[0]!.developers} developers' top apps, not in your name / subtitle / keywords)`, why: 'words the top apps lead with; only if they describe your app', command: `storebound aso suggest --locale ${gap.locale}` });
|
||||
const lost = a.ranks.rows.filter(r => r.arrow === '↓' || r.arrow === 'lost');
|
||||
|
|
|
|||
|
|
@ -276,12 +276,12 @@ export function candidates(i: CandidateInput): Candidate[] {
|
|||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// The 100-byte keyword field
|
||||
|
||||
export interface FieldProposal { locale: string; before: string; after: string; bytes: number; kept: string[]; added: string[]; dropped: { term: string; why: string }[] }
|
||||
export interface FieldProposal { locale: string; before: string; after: string; chars: number; kept: string[]; added: string[]; dropped: { term: string; why: string }[] }
|
||||
|
||||
const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
|
||||
const chars = (s: string) => [...s].length;
|
||||
|
||||
/**
|
||||
* A keyword field for one locale that fits 100 bytes (UTF-8) with the existing rules: commas without spaces, no
|
||||
* A keyword field for one locale that fits 100 characters (App Store Connect counts characters, not bytes) with the existing rules: commas without spaces, no
|
||||
* duplicates (plural-insensitive), no words of the name / subtitle, no filler or category words, single words. Current
|
||||
* terms that are still valid are kept first (change a few terms per release, not all), unless `fresh`; then the best
|
||||
* candidates fill the room. Pure.
|
||||
|
|
@ -292,7 +292,7 @@ export function fitKeywordField(o: { locale: string; current: string; name: stri
|
|||
const blocked = new Map(o.ranked.filter(c => c.status !== 'candidate' && c.kind === 'word').map(c => [stem(c.term, L), c.why ?? c.status] as const));
|
||||
const out: string[] = [], kept: string[] = [], added: string[] = [], dropped: { term: string; why: string }[] = [];
|
||||
const seen: string[] = [];
|
||||
const fits = (t: string) => bytes([...out, t].join(',')) <= limit;
|
||||
const fits = (t: string) => chars([...out, t].join(',')) <= limit;
|
||||
const tryAdd = (t: string, into: string[]): string | null => {
|
||||
const ws = words(t, L);
|
||||
if (!ws.length) return 'empty';
|
||||
|
|
@ -301,12 +301,12 @@ export function fitKeywordField(o: { locale: string; current: string; name: stri
|
|||
if (ws.length === 1 ? coveredBy(ws[0]!, seen, L) : seen.includes(ws.join(' '))) return 'duplicate';
|
||||
const b = ws.length === 1 ? blocked.get(stem(ws[0]!, L)) : undefined;
|
||||
if (b) return b;
|
||||
if (!fits(t)) return 'no room in 100 bytes';
|
||||
if (!fits(t)) return 'no room in 100 characters';
|
||||
seen.push(ws.length === 1 ? ws[0]! : ws.join(' ')); out.push(t); into.push(t);
|
||||
return null;
|
||||
};
|
||||
if (!o.fresh) for (const t of o.current.split(',').map(s => s.trim().toLocaleLowerCase(L)).filter(Boolean)) {
|
||||
// keep a current phrase as the words it is made of: single words combine on their own and save bytes
|
||||
// keep a current phrase as the words it is made of: single words combine on their own and save characters
|
||||
const parts = /\s/.test(t) ? tokens(t, L) : [t];
|
||||
for (const p of parts) { const why = tryAdd(p, kept); if (why && why !== 'duplicate') dropped.push({ term: p, why }); }
|
||||
}
|
||||
|
|
@ -315,7 +315,7 @@ export function fitKeywordField(o: { locale: string; current: string; name: stri
|
|||
tryAdd(c.term, added);
|
||||
}
|
||||
const after = out.join(',');
|
||||
return { locale: L, before: o.current, after, bytes: bytes(after), kept, added, dropped };
|
||||
return { locale: L, before: o.current, after, chars: chars(after), kept, added, dropped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -27,12 +27,13 @@ export function auditAppleKeywords(locale: string, c: AppleCopy, appName: string
|
|||
const at = `apple ${locale}`;
|
||||
const kw = c.keywords ?? '';
|
||||
if (!kw) return [{ level: 'error', where: at, msg: 'keywords: empty' }];
|
||||
// App Store Connect limits the keyword field to 100 bytes (UTF-8), not characters: Cyrillic, Arabic, Hindi, Thai,
|
||||
// Japanese, Korean and Chinese words take 2-3 bytes per character, so both the limit and the fill hint count bytes.
|
||||
const bytes = Buffer.byteLength(kw, 'utf8');
|
||||
if (bytes > 100) out.push({ level: 'error', where: at, msg: `keywords: ${bytes} bytes > 100` });
|
||||
// App Store Connect limits the keyword field to 100 characters, not UTF-8 bytes: live versions (READY_FOR_SALE,
|
||||
// read 2026-10-13) hold Thai 95 characters / 265 bytes, Russian 91 / 169, Turkish 100 / 105, Polish 99 / 105
|
||||
// (store-pitfalls 43). Both the limit and the fill hint count characters.
|
||||
const chars = len(kw);
|
||||
if (chars > 100) out.push({ level: 'error', where: at, msg: `keywords: ${chars} characters > 100` });
|
||||
if (/,\s|\s,/.test(kw)) out.push({ level: 'error', where: at, msg: 'keywords: spaces around commas waste characters' });
|
||||
if (bytes < 90) out.push({ level: 'warn', where: at, msg: `keywords: ${bytes}/100 bytes used; fill at least 90 (${100 - bytes} bytes free)` });
|
||||
if (chars < 90) out.push({ level: 'warn', where: at, msg: `keywords: ${chars}/100 characters used; fill at least 90 (${100 - chars} characters free)` });
|
||||
const terms = kw.split(',').map(t => t.trim().toLocaleLowerCase(locale)).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
for (const t of terms) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export interface StoreItem {
|
|||
/** Offer ids (Play offer ids; Apple: "intro" when there is an introductory offer, promotional offer codes,
|
||||
* "code:<name>" for offer code campaigns). */
|
||||
offers?: string[];
|
||||
/** Apple in-app purchases: the price schedule's base territory (region, currency) and its price today. */
|
||||
base?: { region: string; currency: string; price: number };
|
||||
/** The store's own USD price for regions without one of their own (Play newRegionsConfig / otherRegionsConfig). */
|
||||
usd?: number;
|
||||
/** Apple subscriptions: the group's reference name. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface WantItem {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ export function parseAppStoreListing(md: string): Record<string, AppleCopy> {
|
|||
for (const k of APPLE_REQUIRED) if (!f[k]) errors.push(`${locale}: missing ${k}`);
|
||||
for (const [k, v] of Object.entries(f) as [AppleField, string][])
|
||||
if (len(v) > APPLE_LIMITS[k]) errors.push(`${locale}: ${k} is ${len(v)} > ${APPLE_LIMITS[k]}`);
|
||||
if (f.keywords && Buffer.byteLength(f.keywords) > 100) errors.push(`${locale}: keywords are ${Buffer.byteLength(f.keywords)} bytes > 100`);
|
||||
if (f.keywords && /,\s/.test(f.keywords)) errors.push(`${locale}: keywords: no spaces after commas (they count)`);
|
||||
out[locale] = f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
//
|
||||
// Which profile a project uses (first hit): --account <name> / env STOREBOUND_PROFILE (set but empty = none), the
|
||||
// config's `account`, "account" in <project>/.storebound/credentials.json (written by `storebound accounts import`),
|
||||
// else the default: the only profile on this machine, or the one named "default".
|
||||
// else the default: the one `storebound accounts default <name>` set (the pointer file <credentials>/default-profile),
|
||||
// else the only profile on this machine, else the one named "default".
|
||||
//
|
||||
// One value, first hit wins:
|
||||
// 1. environment: PLAY_SERVICE_ACCOUNT (path to the JSON key, or the JSON itself), ASC_KEY_ID, ASC_ISSUER_ID,
|
||||
|
|
@ -22,7 +23,7 @@
|
|||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
|
||||
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
|
||||
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
|
||||
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
|
|
@ -78,8 +79,31 @@ export function profileNames(): string[] {
|
|||
return names.filter(n => PROFILE_RE.test(n)).sort();
|
||||
}
|
||||
|
||||
/** The default profile: the only one on this machine, else the one named "default"; null when that is not clear. */
|
||||
export function defaultProfile(names = profileNames()): string | null {
|
||||
/** The pointer file `storebound accounts default <name>` writes: one profile name, nothing else (never a key). */
|
||||
export const defaultPointerFile = (): string => join(profileDir(), 'default-profile');
|
||||
|
||||
/** The profile the pointer file names, when it is a valid name; null without one. Not checked against the folder. */
|
||||
export function defaultPointer(): string | null {
|
||||
try {
|
||||
const n = readFileSync(defaultPointerFile(), 'utf8').trim();
|
||||
return PROFILE_RE.test(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Sets (or with null clears) the default profile pointer. Throws on a name that is not a profile on this machine. */
|
||||
export function setDefaultPointer(name: string | null): string {
|
||||
const f = defaultPointerFile();
|
||||
if (name === null) { rmSync(f, { force: true }); return f; }
|
||||
if (!profileNames().includes(name)) throw new Error(`no profile "${name}" on this machine (${profileNames().join(', ') || 'none yet'}; storebound accounts lists them)`);
|
||||
mkdirSync(profileDir(), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(f, name + '\n', { mode: 0o600 });
|
||||
return f;
|
||||
}
|
||||
|
||||
/** The default profile: the one the pointer names (when it exists), else the only one on this machine, else the one
|
||||
* named "default"; null when that is not clear. */
|
||||
export function defaultProfile(names = profileNames(), pointer = defaultPointer()): string | null {
|
||||
if (pointer && names.includes(pointer)) return pointer;
|
||||
return names.length === 1 ? names[0]! : names.includes('default') ? 'default' : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -307,7 +307,7 @@ const fence = (t: string) => '```\n' + t + '\n```';
|
|||
const https = (u: string | undefined): string | undefined => (u && /^https:\/\/\S+$/.test(u.trim()) ? u.trim() : undefined);
|
||||
const YOUTUBE = /^https:\/\/(www\.youtube\.com\/watch\?v=[\w-]{6,}|youtu\.be\/[\w-]{6,})$/;
|
||||
|
||||
/** Keywords "a, b ,c" -> "a,b,c" (spaces after commas count against the 100 bytes on the App Store). */
|
||||
/** Keywords "a, b ,c" -> "a,b,c" (spaces after commas count against the 100 characters on the App Store). */
|
||||
export const normalizeKeywords = (k: string): string => k.split(/[,\n]/).map(x => x.trim()).filter(Boolean).join(',');
|
||||
|
||||
/** Image size, null when the header is not PNG / JPEG. */
|
||||
|
|
@ -453,7 +453,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
|
|||
add('promotionalText', f.promotionalText);
|
||||
if (f.keywords) {
|
||||
const k = normalizeKeywords(f.keywords);
|
||||
if (k !== f.keywords) warnings.push(`${l.locale}: keywords normalized (spaces after commas removed; they count against 100 bytes)`);
|
||||
if (k !== f.keywords) warnings.push(`${l.locale}: keywords normalized (spaces after commas removed; they count against 100 characters)`);
|
||||
add('keywords', k);
|
||||
}
|
||||
add('description', f.description);
|
||||
|
|
@ -472,7 +472,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
|
|||
|
||||
Imported from fastlane deliver (\`storebound import fastlane\`). Source for \`storebound apple listing\`. One
|
||||
\`## <ASC locale>\` section per locale, one fenced block per \`### <field>\`: \`name\` (optional, defaults to \`apple.name\`, else \`app.name\`),
|
||||
\`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 bytes, comma separated, no spaces after commas),
|
||||
\`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 characters, comma separated, no spaces after commas),
|
||||
\`description\` (4000), \`whatsNew\` (optional). Privacy policy, support and marketing URL come from
|
||||
\`storebound.config.ts\`.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
// (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+).
|
||||
import type { Finding, Level } from './validate-rules';
|
||||
import { mdTable, pct, textTable } from './table';
|
||||
import { reportingEnableUrl } from './store-apps';
|
||||
|
||||
export const PLAY_THRESHOLDS = { crash: 0.0109, anr: 0.0047, perDevice: 0.08 } as const;
|
||||
/** Warn when a rate reaches this share of its threshold. */
|
||||
|
|
@ -181,13 +182,14 @@ const levelFor = (v: number | null, limit: number): Level => (v === null ? 'ok'
|
|||
/** A Google 403 for an API that is not enabled in the caller's Cloud project. */
|
||||
export const REPORTING_OFF = /SERVICE_DISABLED|has not been used in project|accessNotConfigured/;
|
||||
|
||||
export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level } = {}): Finding[] {
|
||||
export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; principal?: string | null } = {}): Finding[] {
|
||||
const f: Finding[] = [];
|
||||
const play = h.play;
|
||||
if (play && 'error' in play) {
|
||||
const on = reportingEnableUrl(play.error, o.principal ?? null);
|
||||
f.push(REPORTING_OFF.test(play.error)
|
||||
? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: 'vitals not readable: the Google Play Developer Reporting API is off in the service account\'s Google Cloud project',
|
||||
fix: 'Google Cloud console > APIs & Services > Library: enable "Google Play Developer Reporting API" in that project, wait a few minutes (store-health skill)' }
|
||||
? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the Google Play Developer Reporting API is off in the service account's Google Cloud project${on.project ? ` ${on.project}` : ''}`,
|
||||
fix: `enable "Google Play Developer Reporting API" for that project: ${on.url} (Enable), wait a few minutes (store-health skill)` }
|
||||
: { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${play.error.slice(0, 200)}`, fix: 'store-health skill (Reporting API access)' });
|
||||
}
|
||||
else if (play) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// yours once per field. Every generated section carries a marker line until a human reviewed it:
|
||||
// <!-- storebound-translation from=en-US status=translate --> source text copied in, still to translate
|
||||
// <!-- storebound-translation from=en-US status=draft --> machine-translated: review, then `listing reviewed`
|
||||
// Limits per store field (characters; App Store keywords also 100 bytes, as the existing listing check counts them):
|
||||
// Limits per store field, in characters (App Store keywords too: live fields of 265 UTF-8 bytes are accepted):
|
||||
// App Store (appStoreVersionLocalizations / appInfoLocalizations): name 30, subtitle 30, promotionalText 170,
|
||||
// keywords 100, description 4000. https://developer.apple.com/help/app-store-connect/reference/app-information
|
||||
// Google Play (edits.listings): title 30, shortDescription 80, fullDescription 4000.
|
||||
|
|
@ -108,7 +108,7 @@ export const unmark = (sec: string): string => sec.replace(/^<!--\s*storebound-t
|
|||
// ---- keyword rules ----
|
||||
|
||||
/** App Store keywords: split on commas (also 、,،), trimmed, deduplicated (case-insensitive), words of the name /
|
||||
* subtitle dropped, joined with "," and cut at whole keywords to fit 100 characters and 100 bytes. Pure. */
|
||||
* subtitle dropped, joined with "," and cut at whole keywords to fit 100 characters. Pure. */
|
||||
export function normalizeKeywords(text: string, o: { name?: string; subtitle?: string; limit?: number } = {}): { text: string; dropped: { kw: string; why: string }[] } {
|
||||
const limit = o.limit ?? APPLE_LIMITS.keywords;
|
||||
const taken = new Set(`${o.name ?? ''} ${o.subtitle ?? ''}`.toLocaleLowerCase().split(/[\s,.:;!?·|/–-]+/).filter(w => w.length > 1));
|
||||
|
|
@ -123,14 +123,14 @@ export function normalizeKeywords(text: string, o: { name?: string; subtitle?: s
|
|||
seen.add(key);
|
||||
if (taken.has(key)) { dropped.push({ kw, why: 'already in the name or subtitle' }); continue; }
|
||||
const next = [...kept, kw].join(',');
|
||||
if (len(next) > limit || Buffer.byteLength(next) > limit) { dropped.push({ kw, why: `over ${limit}` }); continue; }
|
||||
if (len(next) > limit) { dropped.push({ kw, why: `over ${limit}` }); continue; }
|
||||
kept.push(kw);
|
||||
}
|
||||
return { text: kept.join(','), dropped };
|
||||
}
|
||||
|
||||
/** `chars` is what the store counts: characters, except App Store keywords, which App Store Connect limits in UTF-8 bytes
|
||||
* (`unit` says which). */
|
||||
/** `chars` is what the store counts: characters, App Store keywords included (App Store Connect counts characters,
|
||||
* not UTF-8 bytes: store-pitfalls 43). `unit` stays for readers of the JSON and is always 'chars'. */
|
||||
export interface FieldCheck { store: 'apple' | 'play'; field: string; chars: number; unit: 'chars' | 'bytes'; limit: number; problems: string[] }
|
||||
|
||||
/** Limits and rules of one translated section. `source` = the source language's fields (identical text is flagged). */
|
||||
|
|
@ -141,11 +141,11 @@ export function checkFields(store: 'apple' | 'play', f: Record<string, string |
|
|||
const v = f[k];
|
||||
if (v === undefined) continue;
|
||||
const limit = (FIELD_LIMITS[store] as Record<string, number>)[k]!;
|
||||
const bytes = store === 'apple' && k === 'keywords';
|
||||
const n = bytes ? Buffer.byteLength(v, 'utf8') : len(v);
|
||||
const c: FieldCheck = { store, field: k, chars: n, unit: bytes ? 'bytes' : 'chars', limit, problems: [] };
|
||||
if (n > limit) c.problems.push(`${n} > ${limit} ${bytes ? 'bytes' : 'characters'}`);
|
||||
if (bytes) {
|
||||
const isKw = store === 'apple' && k === 'keywords';
|
||||
const n = len(v);
|
||||
const c: FieldCheck = { store, field: k, chars: n, unit: 'chars', limit, problems: [] };
|
||||
if (n > limit) c.problems.push(`${n} > ${limit} characters`);
|
||||
if (isKw) {
|
||||
if (/,\s|\s,/.test(v)) c.problems.push('spaces around commas');
|
||||
const words = v.split(',').map(w => w.trim().toLocaleLowerCase()).filter(Boolean);
|
||||
const dup = words.filter((w, i) => words.indexOf(w) !== i);
|
||||
|
|
|
|||
304
cli/lib/onboard.ts
Normal file
304
cli/lib/onboard.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
// Onboarding an app that is already live: everything the stores and the code can prove goes into the config, so the
|
||||
// owner is asked only for what nothing proves. Pure parts of `storebound onboard` (cli/onboard.ts reads the stores):
|
||||
// - importCatalog: the live one-time products and subscriptions of both stores (ids, type, listings per language,
|
||||
// the price in USD: the US price, Apple's base territory as an override when it is not the USA) as the config's
|
||||
// products / subscriptions, so `storebound catalog diff` comes out clean. Store prices are never changed.
|
||||
// - contactProposal: app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app
|
||||
// details and App Store Connect, replacing only template placeholders; the App Review contact for
|
||||
// .storebound/credentials.json (personal data: masked in every output).
|
||||
// - signInDecision: reviewAccess.signInRequired from Apple's review detail and the code's auth signals, else a
|
||||
// question for the owner.
|
||||
// - deletionCandidates / findDeletionUrl: an account deletion page on the app's own website, never invented.
|
||||
import { type Language, playCodes, type Product, type ProductType, type ShipConfig, type Subscription, type SubscriptionGroup, type SubscriptionPeriod } from './config';
|
||||
import type { StoreItem } from './catalog';
|
||||
import { isPlaceholder, maskUser, type ReviewContact } from './credentials';
|
||||
import type { RemoteCatalog } from './listing-pull';
|
||||
|
||||
type Store = 'apple' | 'play';
|
||||
|
||||
export interface ImportNote { id: string; level: 'info' | 'warn'; msg: string }
|
||||
export interface CatalogImport {
|
||||
products: Product[];
|
||||
subscriptions: SubscriptionGroup[];
|
||||
/** ids added (not in the config before) / updated (price, type or listings changed) / kept as they were */
|
||||
added: string[];
|
||||
updated: string[];
|
||||
notes: ImportNote[];
|
||||
/** items that could not be written, with the reason (only when no store has a usable price or text) */
|
||||
skipped: { id: string; why: string }[];
|
||||
}
|
||||
|
||||
const cents = (n: number) => Math.round(n * 100) / 100;
|
||||
const langOf = (langs: Language[], store: Store, code: string) => langs.find(l => (store === 'apple' ? l.apple === code : playCodes(l).includes(code)))?.id;
|
||||
const PERIODS: SubscriptionPeriod[] = ['P1W', 'P1M', 'P2M', 'P3M', 'P6M', 'P1Y'];
|
||||
|
||||
/** The USD price a store item shows today: the US region, else the store's own USD price for other regions. */
|
||||
export function usdOf(i: StoreItem | undefined): number | undefined {
|
||||
if (!i) return undefined;
|
||||
const us = i.prices.find(p => p.region === 'US' && p.currency === 'USD');
|
||||
return us?.price ?? i.usd;
|
||||
}
|
||||
|
||||
/**
|
||||
* The config's products and subscriptions from what both stores sell today. Items in the config keep everything that
|
||||
* is not on the store (review screenshots, notes, offers); their USD price, type, platforms and listings follow the
|
||||
* store. An item on one store only is kept and marked for that store (`platforms`). Pure.
|
||||
*/
|
||||
export function importCatalog(config: Pick<ShipConfig, 'languages' | 'products' | 'subscriptions' | 'apple' | 'play'>,
|
||||
s: { apple: StoreItem[] | null; play: StoreItem[] | null; appleTexts?: RemoteCatalog; playTexts?: RemoteCatalog }): CatalogImport {
|
||||
const out: CatalogImport = { products: [], subscriptions: [], added: [], updated: [], notes: [], skipped: [] };
|
||||
const note = (id: string, msg: string, level: ImportNote['level'] = 'info') => out.notes.push({ id, level, msg });
|
||||
const both = !!config.apple && !!config.play;
|
||||
const byId = (xs: StoreItem[] | null) => new Map((xs ?? []).map(x => [x.id, x] as const));
|
||||
const A = byId(s.apple), P = byId(s.play);
|
||||
const oldProducts = new Map((config.products ?? []).map(p => [p.id, p] as const));
|
||||
const oldSubs = new Map((config.subscriptions ?? []).flatMap(g => g.subscriptions.map(x => [x.id, { group: g, sub: x }] as const)));
|
||||
const ids = [...new Set([...A.keys(), ...P.keys()])];
|
||||
const groups = new Map<string, SubscriptionGroup>();
|
||||
|
||||
for (const id of ids) {
|
||||
const a = A.get(id), p = P.get(id);
|
||||
const isSub = a ? a.kind === 'subscription' : p!.kind === 'subscription';
|
||||
if (a && p && (a.kind === 'subscription') !== (p.kind === 'subscription')) { out.skipped.push({ id, why: `a subscription on one store and a one-time product on the other: one id cannot be both (the stores keep it; fix by hand)` }); continue; }
|
||||
// price: US first (Apple's, else Play's), else the store's own USD price for new regions
|
||||
const aUsd = usdOf(a), pUsd = usdOf(p);
|
||||
const usd = aUsd ?? pUsd;
|
||||
if (usd === undefined) {
|
||||
const base = a?.base ?? p?.prices[0];
|
||||
out.skipped.push({ id, why: `no USD price on ${a && p ? 'either store' : a ? 'the App Store' : 'Google Play'}${base ? ` (base ${base.region} ${base.price} ${base.currency})` : ''}: the config prices in USD, so it is not written (catalog diff lists it as on the store only)` });
|
||||
continue;
|
||||
}
|
||||
if (aUsd !== undefined && pUsd !== undefined && cents(aUsd) !== cents(pUsd))
|
||||
note(id, `US price ${aUsd.toFixed(2)} on the App Store, ${pUsd.toFixed(2)} on Google Play: usd follows the App Store; catalog diff shows the Play difference (store prices are never changed here)`, 'warn');
|
||||
// Apple's base territory (not the USA): kept as an override when every store that sells the item has that price
|
||||
const overrides: { region: string; currency: string; price: number }[] = [];
|
||||
if (a?.base && a.base.region !== 'US') {
|
||||
const same = !p || p.prices.some(x => x.region === a.base!.region && x.currency === a.base!.currency && cents(x.price) === cents(a.base!.price));
|
||||
if (same) overrides.push({ region: a.base.region, currency: a.base.currency, price: a.base.price });
|
||||
else note(id, `App Store base territory ${a.base.region} (${a.base.price} ${a.base.currency}) differs from Google Play there: not kept as an override`, 'warn');
|
||||
}
|
||||
// listings: the config's, then Play's texts, then Apple's (Apple's limits are the config's)
|
||||
const old = isSub ? oldSubs.get(id)?.sub : oldProducts.get(id);
|
||||
const listings: Record<string, { title: string; description: string; benefits?: string[] }> = {};
|
||||
for (const [lang, l] of Object.entries(old?.listings ?? {})) listings[lang] = { ...l };
|
||||
for (const store of ['play', 'apple'] as const) {
|
||||
const texts = (store === 'apple' ? s.appleTexts : s.playTexts)?.[id] ?? {};
|
||||
for (const [code, t] of Object.entries(texts)) {
|
||||
const lang = langOf(config.languages, store, code);
|
||||
if (!lang) { note(id, `${store} language ${code} is not in languages: its texts are left out`); continue; }
|
||||
if (!t.title) continue;
|
||||
if ([...t.title].length > 30) { note(id, `${store} ${code}: title "${t.title}" is over the config's 30 characters: kept the ${listings[lang] ? 'other store\'s' : 'store'} text out of the config`, 'warn'); continue; }
|
||||
listings[lang] = { ...listings[lang], title: t.title, description: t.description || listings[lang]?.description || '', ...(store === 'play' && t.benefits?.length ? { benefits: t.benefits } : {}) };
|
||||
}
|
||||
}
|
||||
for (const [lang, l] of Object.entries(listings)) if (!l.description) { note(id, `${lang}: no description on the store: the title is used as the description`, 'warn'); l.description = l.title; }
|
||||
if (!Object.keys(listings).length) { out.skipped.push({ id, why: 'no title in any language of the config on the stores' }); continue; }
|
||||
const platforms = both && !(a && p) ? { platforms: [a ? 'ios' as const : 'android' as const] } : {};
|
||||
if (both && !(a && p)) note(id, `only on ${a ? 'the App Store' : 'Google Play'}: kept and marked platforms: ['${a ? 'ios' : 'android'}']`);
|
||||
if (isSub) {
|
||||
const period = (a?.period ?? p?.period) as SubscriptionPeriod | undefined;
|
||||
if (!period || !PERIODS.includes(period)) { out.skipped.push({ id, why: `billing period ${period ?? 'unknown'} is not one the config supports (${PERIODS.join(', ')})` }); continue; }
|
||||
if (a?.period && p?.period && a.period !== p.period) note(id, `bills every ${a.period} on the App Store, ${p.period} on Google Play: period follows the App Store`, 'warn');
|
||||
const prev = oldSubs.get(id);
|
||||
const sub: Subscription = { ...(prev?.sub ?? {}), id, period, level: a?.level ?? prev?.sub.level ?? 1, usd: cents(usd), listings, ...platforms,
|
||||
...(overrides.length || prev?.sub.overrides ? { overrides: mergeOverrides(prev?.sub.overrides, overrides) } : {}) } as Subscription;
|
||||
if (!both || (a && p)) delete sub.platforms;
|
||||
const gname = a?.group ?? prev?.group.name ?? 'Subscriptions';
|
||||
const g = groups.get(gname) ?? { ...(prev?.group ?? { name: gname, listings: {} }), name: gname, subscriptions: [] };
|
||||
if (!Object.keys(g.listings).length) {
|
||||
const first = Object.keys(listings)[0]!;
|
||||
g.listings = { [first]: { name: gname } };
|
||||
note(id, `group "${gname}": display name set to its reference name for ${first} (App Store group localizations are not read; edit if they differ)`);
|
||||
}
|
||||
g.subscriptions.push(sub);
|
||||
groups.set(gname, g);
|
||||
track(out, id, prev?.sub, sub);
|
||||
} else {
|
||||
const prev = oldProducts.get(id);
|
||||
let type: ProductType;
|
||||
if (a && a.kind !== 'one_time' && a.kind !== 'subscription') type = a.kind;
|
||||
else if (prev) type = prev.type;
|
||||
else { type = 'non_consumable'; note(id, 'Google Play does not say whether a one-time product is consumable: type non_consumable; change it to consumable if people buy it again and again', 'warn'); }
|
||||
const prod: Product = { ...(prev ?? {}), id, type, usd: cents(usd), listings: listings as Product['listings'], ...platforms,
|
||||
...(overrides.length || prev?.overrides ? { overrides: mergeOverrides(prev?.overrides, overrides) } : {}) } as Product;
|
||||
if (!both || (a && p)) delete prod.platforms;
|
||||
out.products.push(prod);
|
||||
track(out, id, prev, prod);
|
||||
}
|
||||
}
|
||||
// config items no store has (not created yet): kept as they are
|
||||
for (const p of config.products ?? []) if (!A.has(p.id) && !P.has(p.id)) { out.products.push(p); note(p.id, 'in the config, on no store yet: kept'); }
|
||||
for (const g of config.subscriptions ?? []) for (const x of g.subscriptions) if (!A.has(x.id) && !P.has(x.id)) {
|
||||
const grp = groups.get(g.name) ?? { ...g, subscriptions: [] };
|
||||
grp.subscriptions.push(x);
|
||||
groups.set(g.name, grp);
|
||||
note(x.id, 'in the config, on no store yet: kept');
|
||||
}
|
||||
out.subscriptions = [...groups.values()];
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeOverrides(prev: { region: string; currency: string; price: number; territory?: string }[] | undefined, add: { region: string; currency: string; price: number }[]) {
|
||||
const m = new Map((prev ?? []).map(o => [o.region, o] as const));
|
||||
for (const o of add) m.set(o.region, { ...m.get(o.region), ...o });
|
||||
return [...m.values()];
|
||||
}
|
||||
|
||||
function track(out: CatalogImport, id: string, prev: unknown, next: unknown): void {
|
||||
if (!prev) out.added.push(id);
|
||||
else if (JSON.stringify(prev) !== JSON.stringify(next)) out.updated.push(id);
|
||||
}
|
||||
|
||||
// ---- contact, URLs and the App Review contact ----
|
||||
|
||||
export interface StoreContact {
|
||||
play?: { contactEmail?: string; contactWebsite?: string; contactPhone?: string } | null;
|
||||
apple?: { supportUrl?: string; marketingUrl?: string; privacyPolicyUrl?: string; review?: AppleReviewDetail | null } | null;
|
||||
}
|
||||
/** appStoreReviewDetails attributes (App Store Connect), as read. */
|
||||
export interface AppleReviewDetail {
|
||||
contactFirstName?: string | null; contactLastName?: string | null; contactPhone?: string | null; contactEmail?: string | null;
|
||||
demoAccountName?: string | null; demoAccountPassword?: string | null; demoAccountRequired?: boolean | null; notes?: string | null;
|
||||
}
|
||||
|
||||
export interface FieldProposal { field: string; from: string | null; to: string; source: string; replace: boolean }
|
||||
|
||||
/** A value that is a template placeholder (example.com / example.org, "your-…"), or missing. */
|
||||
export const isTemplateValue = (v: string | undefined | null): boolean =>
|
||||
// the templates (`storebound init`) write example.com: https://example.com/..., support@example.com
|
||||
!v || isPlaceholder(v, { example: false }) || /^(https?:\/\/)?(www\.)?example\.com(\/|$)/i.test(v.trim()) || /@example\.com$/i.test(v.trim());
|
||||
|
||||
const httpsUrl = (u: string | undefined | null): string | null => {
|
||||
const s = (u ?? '').trim();
|
||||
if (!s) return null;
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`;
|
||||
return /^https:\/\/[^\s/]+\.[^\s/]+/i.test(withScheme) ? withScheme : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* app.* values from the stores. `replace` is true only where the config holds a template value (example.com,
|
||||
* support@example.com) or nothing; a real value that differs from the store is reported, never overwritten. Pure.
|
||||
*/
|
||||
export function contactProposal(app: Partial<ShipConfig['app']>, s: StoreContact): FieldProposal[] {
|
||||
const out: FieldProposal[] = [];
|
||||
const want = (field: keyof ShipConfig['app'], value: string | null | undefined, source: string) => {
|
||||
if (!value) return;
|
||||
if (out.some(o => o.field === field)) return;
|
||||
const cur = app[field] as string | undefined;
|
||||
if (cur === value) return;
|
||||
out.push({ field, from: cur ?? null, to: value, source, replace: isTemplateValue(cur) });
|
||||
};
|
||||
const email = s.play?.contactEmail?.trim();
|
||||
if (email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) want('contactEmail', email, 'Google Play app details (contactEmail)');
|
||||
want('website', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)');
|
||||
want('website', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)');
|
||||
want('supportUrl', httpsUrl(s.apple?.supportUrl), 'App Store version (supportUrl)');
|
||||
want('supportUrl', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)');
|
||||
want('privacyPolicyUrl', httpsUrl(s.apple?.privacyPolicyUrl), 'App Store app information (privacyPolicyUrl)');
|
||||
want('marketingUrl', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The App Review contact from Apple's review detail, for .storebound/credentials.json (personal data). */
|
||||
export function reviewContactOf(r: AppleReviewDetail | null | undefined): ReviewContact | null {
|
||||
if (!r?.contactEmail) return null;
|
||||
return { firstName: r.contactFirstName ?? '', lastName: r.contactLastName ?? '', phone: r.contactPhone ?? '', email: r.contactEmail };
|
||||
}
|
||||
|
||||
/** For logs: the name's initials, the masked e-mail, the phone as its last 2 digits only. Never the full values. */
|
||||
export function maskContact(c: ReviewContact): string {
|
||||
const digits = c.phone.replace(/\D/g, '');
|
||||
return `${c.firstName ? c.firstName[0] + '.' : ''}${c.lastName ? ` ${c.lastName[0]}.` : ''} ${maskUser(c.email)}${digits ? `, phone ***${digits.slice(-2)}` : ''}`.trim();
|
||||
}
|
||||
|
||||
// ---- sign-in for review ----
|
||||
|
||||
export interface SignInDecision {
|
||||
signInRequired: boolean | null;
|
||||
/** why, in one line */
|
||||
because: string;
|
||||
/** Apple's review detail holds a demo account (name + password): import it, never print it */
|
||||
demoAccount: { username: string; password: string } | null;
|
||||
/** the owner question, only when nothing proves it */
|
||||
question: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* reviewAccess.signInRequired from what is proven: Apple's live review detail (demoAccountRequired, set by whoever
|
||||
* submitted the app and accepted by App Review) wins; else no auth code in the project means no sign-in; else
|
||||
* it is an owner question. Never defaults to yes. Pure.
|
||||
*/
|
||||
export function signInDecision(o: { auth: string[]; apple: AppleReviewDetail | null | undefined; configured?: boolean }): SignInDecision {
|
||||
const r = o.apple;
|
||||
const demo = r?.demoAccountName && r.demoAccountPassword ? { username: r.demoAccountName, password: r.demoAccountPassword } : null;
|
||||
if (r && r.demoAccountRequired === true)
|
||||
return { signInRequired: true, because: `App Store review detail: demo account required${demo ? ' (a demo account is set there)' : ' (but none is set there)'}`, demoAccount: demo, question: null };
|
||||
if (r && r.demoAccountRequired === false)
|
||||
return { signInRequired: false, because: `App Store review detail: no demo account required${o.auth.length ? ` (the code has sign-in, ${o.auth[0]}, so it is optional for reviewers)` : ''}`, demoAccount: demo, question: null };
|
||||
if (!o.auth.length) return { signInRequired: false, because: 'no sign-in or account code in the project (scan auth signals)', demoAccount: demo, question: null };
|
||||
return { signInRequired: null, because: `the code has sign-in (${o.auth.slice(0, 2).join('; ')}) and no store says whether reviewers need it`, demoAccount: demo,
|
||||
question: 'Can a reviewer use every main feature without signing in? (no: reviewAccess.signInRequired true and a demo account in .storebound/credentials.json reviewDemoAccount)' };
|
||||
}
|
||||
|
||||
// ---- account deletion page ----
|
||||
|
||||
/** Pages to try on the app's own site, in order (Play's account deletion requirement: a web page to request it). */
|
||||
export function deletionCandidates(urls: (string | null | undefined)[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const u of urls) {
|
||||
let base: URL;
|
||||
try { base = new URL(u ?? ''); } catch { continue; }
|
||||
if (base.protocol !== 'https:' || isTemplateValue(base.href)) continue;
|
||||
for (const p of ['/delete-account', '/account/delete', '/delete', '/account-deletion', '/delete-account.html'])
|
||||
{ const c = new URL(p, base.origin).href; if (!out.includes(c)) out.push(c); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DELETE_WORDS = /delet|deletion|remove (my|your) (account|data)|hesab[ıi]n[ıi]? sil|konto l[öo]schen|supprim/i;
|
||||
|
||||
/** Links on a page (privacy policy) whose text or address speaks of deleting an account or data. Pure. */
|
||||
export function deletionLinks(html: string, pageUrl: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of html.matchAll(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)) {
|
||||
const href = m[1]!, text = m[2]!.replace(/<[^>]+>/g, ' ');
|
||||
if (!DELETE_WORDS.test(text) && !DELETE_WORDS.test(href)) continue;
|
||||
let abs: string;
|
||||
try { abs = new URL(href, pageUrl).href; } catch { continue; }
|
||||
if (!/^https:/.test(abs) || out.includes(abs)) continue;
|
||||
out.push(abs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DeletionResult { url: string | null; how: string; tried: string[] }
|
||||
/** GET of a page: status and text (a timeout or network error is status null). */
|
||||
export type PageGet = (url: string) => Promise<{ status: number | null; text: string; finalUrl?: string }>;
|
||||
|
||||
/**
|
||||
* Looks for an account deletion page: the usual paths on the website and the privacy policy's host, then links in the
|
||||
* privacy policy page that mention delete or deletion, then an anchor on the privacy page itself (#delete...). A
|
||||
* page counts only when it answers 200 and speaks of deleting. Never invents a URL.
|
||||
*/
|
||||
export async function findDeletionUrl(o: { website?: string | null; privacyUrl?: string | null }, get: PageGet): Promise<DeletionResult> {
|
||||
const tried: string[] = [];
|
||||
const ok = async (u: string) => {
|
||||
tried.push(u);
|
||||
const r = await get(u).catch(() => ({ status: null, text: '' }));
|
||||
return r.status === 200 && DELETE_WORDS.test(r.text.replace(/<script[\s\S]*?<\/script>/gi, '')) ? r : null;
|
||||
};
|
||||
for (const c of deletionCandidates([o.website, o.privacyUrl])) if (await ok(c)) return { url: c, how: `the page ${new URL(c).pathname} on the site answers and speaks of deletion`, tried };
|
||||
if (o.privacyUrl && !isTemplateValue(o.privacyUrl)) {
|
||||
tried.push(o.privacyUrl);
|
||||
const page = await get(o.privacyUrl).catch(() => ({ status: null, text: '' }));
|
||||
if (page.status === 200) {
|
||||
for (const l of deletionLinks(page.text, o.privacyUrl)) {
|
||||
if (l.split('#')[0] === o.privacyUrl.split('#')[0]) return { url: l, how: 'a section of the privacy policy linked as account / data deletion', tried };
|
||||
if (await ok(l)) return { url: l, how: 'linked from the privacy policy as account / data deletion', tried };
|
||||
}
|
||||
const anchor = /\bid\s*=\s*["']([^"']*delet[^"']*)["']/i.exec(page.text)?.[1];
|
||||
if (anchor) return { url: `${o.privacyUrl.split('#')[0]}#${anchor}`, how: 'a deletion section of the privacy policy page', tried };
|
||||
}
|
||||
}
|
||||
return { url: null, how: 'not found: a real gap, Play requires a web page to request account deletion (account-deletion skill)', tried };
|
||||
}
|
||||
|
|
@ -2,7 +2,16 @@
|
|||
// the Android manifest (source or the merged one of a local build) requests or removes the advertising ID permission,
|
||||
// and code that suggests App Privacy types without a Play Data safety equivalent (customer support, AR scanning).
|
||||
// Pure over the scan facts; null fields mean "not found", never "not scanned" (that is `undefined` signals).
|
||||
import { grep, type ProjectFacts, where } from './scan/project';
|
||||
import { grep, grepOwn, type ProjectFacts, where } from './scan/project';
|
||||
import { SIGNALS } from './scan/rules';
|
||||
|
||||
/** Sign-in evidence in the project's own code (scan's auth signals: auth SDKs and sign-in calls / fields / endpoints;
|
||||
* import lines and module paths never count): "file:line what" per hit, empty when the code has no sign-in. */
|
||||
export function authEvidence(p: ProjectFacts): string[] {
|
||||
const sdk = p.sdks.filter(s => s.sdk.category === 'auth').flatMap(s => s.deps.slice(0, 1).map(d => `${d.file}:${d.line} ${s.sdk.name}`));
|
||||
const code = grepOwn(p, SIGNALS.signIn, 3).hits.map(h => `${h.file}:${h.line}`);
|
||||
return [...sdk, ...code];
|
||||
}
|
||||
|
||||
export const AD_ID_PERMISSION = 'com.google.android.gms.permission.AD_ID';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
// the desktop's App folders screen add to it; `storebound apps folders` and the desktop show it; `storebound ws ...`
|
||||
// without a storebound.workspace.json works over it. The desktop keeps its per-folder extras (mapped accounts, saved
|
||||
// reads) in its own state by the same id. Only paths and names: no keys, nothing from inside the folders.
|
||||
// { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }] }
|
||||
// { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }],
|
||||
// "storeApps": [{ "id": "<16 hex>", "store": "apple", "appId": "6400000000", "account": "teamA", "name": "Demo App",
|
||||
// "bundleId": "com.example.demo", "added": "<iso>" }] }
|
||||
// storeApps: apps that are live on a store but have no project on this machine (`storebound apps add --store apple
|
||||
// --app-id <id> --account <profile>`). The desktop shows them read-only (status, reviews, analytics) under that
|
||||
// profile's account, noted "no local project"; they carry no path. Absent when there are none (older readers ignore it).
|
||||
// Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept).
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
|
|
@ -11,7 +16,10 @@ import { homedir } from 'node:os';
|
|||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
|
||||
export interface RegistryProject { id: string; path: string; name: string; added: string }
|
||||
export interface Registry { version: 1; projects: RegistryProject[] }
|
||||
/** A store app without a local project: store + app id (Apple: the numeric app id; Play: the package name), the
|
||||
* credential profile that reads it. */
|
||||
export interface StoreOnlyApp { id: string; store: 'apple' | 'play'; appId: string; account: string | null; name: string; bundleId: string | null; added: string }
|
||||
export interface Registry { version: 1; projects: RegistryProject[]; storeApps?: StoreOnlyApp[] }
|
||||
|
||||
export const registryFile = (env: Record<string, string | undefined> = process.env): string => env.STOREBOUND_REGISTRY || join(homedir(), '.storebound', 'projects.json');
|
||||
|
||||
|
|
@ -20,6 +28,26 @@ export const folderId = (absPath: string): string => createHash('sha256').update
|
|||
|
||||
const ID = /^[0-9a-f]{16}$/;
|
||||
|
||||
/** The id of a store-only entry: stable per store and app id. */
|
||||
export const storeAppId = (store: 'apple' | 'play', appId: string): string => createHash('sha256').update(`store:${store}:${appId}`).digest('hex').slice(0, 16);
|
||||
export const APPLE_APP_ID = /^\d{6,12}$/;
|
||||
export const PACKAGE_NAME = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
||||
|
||||
/** Store-only entries of the file's JSON; malformed ones are dropped. Pure. */
|
||||
function parseStoreApps(list: unknown): StoreOnlyApp[] {
|
||||
const out: StoreOnlyApp[] = [];
|
||||
if (Array.isArray(list)) for (const x of list) {
|
||||
const o = x as Partial<StoreOnlyApp>;
|
||||
if (o?.store !== 'apple' && o?.store !== 'play') continue;
|
||||
const appId = typeof o.appId === 'string' ? o.appId.trim() : '';
|
||||
if (!(o.store === 'apple' ? APPLE_APP_ID : PACKAGE_NAME).test(appId) || out.some(a => a.store === o.store && a.appId === appId)) continue;
|
||||
out.push({ id: storeAppId(o.store, appId), store: o.store, appId, account: typeof o.account === 'string' && o.account ? o.account : null,
|
||||
name: typeof o.name === 'string' && o.name.trim() ? o.name : appId, bundleId: typeof o.bundleId === 'string' && o.bundleId ? o.bundleId : o.store === 'play' ? appId : null,
|
||||
added: typeof o.added === 'string' ? o.added : '' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parses the file's text; drops entries that are not well formed (an id, an absolute path). Pure. */
|
||||
export function parseRegistry(text: string): Registry {
|
||||
let raw: unknown;
|
||||
|
|
@ -33,7 +61,8 @@ export function parseRegistry(text: string): Registry {
|
|||
out.push({ id: typeof o.id === 'string' && ID.test(o.id) && !out.some(x => x.id === o.id) ? o.id : folderId(o.path), path: o.path,
|
||||
name: typeof o.name === 'string' && o.name.trim() ? o.name : basename(o.path), added: typeof o.added === 'string' ? o.added : '' });
|
||||
}
|
||||
return { version: 1, projects: out };
|
||||
const storeApps = parseStoreApps((raw as { storeApps?: unknown })?.storeApps);
|
||||
return storeApps.length ? { version: 1, projects: out, storeApps } : { version: 1, projects: out };
|
||||
}
|
||||
|
||||
export function readRegistry(file = registryFile()): Registry {
|
||||
|
|
@ -48,7 +77,7 @@ export function writeRegistry(r: Registry, file = registryFile()): void {
|
|||
try { JSON.parse(readFileSync(file, 'utf8')); } catch { copyFileSync(file, `${file}.unreadable`); }
|
||||
}
|
||||
const tmp = `${file}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify({ version: 1, projects: r.projects }, null, 2) + '\n');
|
||||
writeFileSync(tmp, JSON.stringify({ version: 1, projects: r.projects, ...(r.storeApps?.length ? { storeApps: r.storeApps } : {}) }, null, 2) + '\n');
|
||||
renameSync(tmp, file);
|
||||
}
|
||||
|
||||
|
|
@ -58,14 +87,48 @@ export function withFolder(r: Registry, path: string, name: string | undefined,
|
|||
const have = r.projects.find(p => p.path === abs);
|
||||
if (have) return { registry: r, project: have, added: false };
|
||||
const project = { id: folderId(abs), path: abs, name: name?.trim() || basename(abs), added: now };
|
||||
return { registry: { version: 1, projects: [...r.projects, project] }, project, added: true };
|
||||
return { registry: { ...r, version: 1, projects: [...r.projects, project] }, project, added: true };
|
||||
}
|
||||
|
||||
/** Adds a store-only app (the same store + app id again updates its account, name and bundle id). Pure. */
|
||||
export function withStoreApp(r: Registry, a: { store: 'apple' | 'play'; appId: string; account?: string | null; name?: string; bundleId?: string | null }, now: string): { registry: Registry; app: StoreOnlyApp; added: boolean } {
|
||||
const appId = a.appId.trim();
|
||||
if (!(a.store === 'apple' ? APPLE_APP_ID : PACKAGE_NAME).test(appId)) throw new Error(a.store === 'apple' ? `--app-id ${appId}: the numeric Apple ID of the app (App Store Connect > App Information)` : `--app-id ${appId}: the Play package name (com.example.app)`);
|
||||
const list = r.storeApps ?? [];
|
||||
const have = list.find(x => x.store === a.store && x.appId === appId);
|
||||
const app: StoreOnlyApp = { id: storeAppId(a.store, appId), store: a.store, appId, account: a.account ?? have?.account ?? null, name: a.name?.trim() || have?.name || appId,
|
||||
bundleId: a.bundleId ?? have?.bundleId ?? (a.store === 'play' ? appId : null), added: have?.added ?? now };
|
||||
if (have && JSON.stringify(have) === JSON.stringify(app)) return { registry: r, app: have, added: false };
|
||||
return { registry: { ...r, version: 1, storeApps: [...list.filter(x => x !== have), app] }, app, added: !have };
|
||||
}
|
||||
|
||||
/** Removes a store-only app by id or "<store>:<app id>". Pure. */
|
||||
export function withoutStoreApp(r: Registry, key: string): { registry: Registry; removed: StoreOnlyApp | null } {
|
||||
const removed = (r.storeApps ?? []).find(x => x.id === key || `${x.store}:${x.appId}` === key) ?? null;
|
||||
if (!removed) return { registry: r, removed };
|
||||
const rest = (r.storeApps ?? []).filter(x => x !== removed);
|
||||
const { storeApps: _, ...base } = r;
|
||||
return { registry: rest.length ? { ...base, storeApps: rest } : base, removed };
|
||||
}
|
||||
|
||||
export function addStoreApp(a: Parameters<typeof withStoreApp>[1], file = registryFile()): { app: StoreOnlyApp; added: boolean; changed: boolean } {
|
||||
const cur = readRegistry(file);
|
||||
const { registry, app, added } = withStoreApp(cur, a, new Date().toISOString());
|
||||
if (registry !== cur) writeRegistry(registry, file);
|
||||
return { app, added, changed: registry !== cur };
|
||||
}
|
||||
|
||||
export function removeStoreApp(key: string, file = registryFile()): StoreOnlyApp | null {
|
||||
const { registry, removed } = withoutStoreApp(readRegistry(file), key);
|
||||
if (removed) writeRegistry(registry, file);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Removes a folder by path or id. Pure. */
|
||||
export function withoutFolder(r: Registry, pathOrId: string): { registry: Registry; removed: RegistryProject | null } {
|
||||
const abs = ID.test(pathOrId) ? '' : resolve(pathOrId);
|
||||
const removed = r.projects.find(p => p.id === pathOrId || p.path === abs) ?? null;
|
||||
return { registry: removed ? { version: 1, projects: r.projects.filter(p => p !== removed) } : r, removed };
|
||||
return { registry: removed ? { ...r, version: 1, projects: r.projects.filter(p => p !== removed) } : r, removed };
|
||||
}
|
||||
|
||||
/** Reads, adds and writes (only when it changed). */
|
||||
|
|
|
|||
|
|
@ -44,15 +44,17 @@ export function toTs(v: unknown, ind = ''): string {
|
|||
return one.length <= 110 && !one.includes('\n') && entries.every(([, x]) => typeof x !== 'object' || Array.isArray(x)) ? one : `{\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}}`;
|
||||
}
|
||||
|
||||
/** The config text with `key` set to `value` (plus an optional comment line above it). */
|
||||
/** The config text with the top-level `key` set to `value` (an object, or an array: `products`, `subscriptions`;
|
||||
* plus an optional comment line above it when it is new). The `key:` with the smallest indent is the top-level one. */
|
||||
export function upsertBlock(text: string, file: string, key: string, value: unknown, comment?: string): string {
|
||||
if (file.endsWith('.json')) {
|
||||
const j = JSON.parse(text) as Record<string, unknown>;
|
||||
j[key] = value;
|
||||
return JSON.stringify(j, null, 2) + '\n';
|
||||
}
|
||||
const re = new RegExp(`^([ \\t]*)${key}\\s*:\\s*\\{`, 'm');
|
||||
const m = re.exec(text);
|
||||
const re = new RegExp(`^([ \\t]*)${key}\\s*:\\s*[{[]`, 'gm');
|
||||
let m: RegExpExecArray | null = null;
|
||||
for (let x = re.exec(text); x; x = re.exec(text)) if (!m || x[1]!.length < m[1]!.length) m = x;
|
||||
if (m) {
|
||||
const ind = m[1]!;
|
||||
const open = m.index + m[0].length - 1;
|
||||
|
|
|
|||
|
|
@ -146,6 +146,32 @@ export function appleCheck(outcome: { apps: number } | { error: unknown }): Acco
|
|||
return { store: 'ios', status: 'error', principal: null, apps: null, message: `Could not reach App Store Connect: ${msg}`, fix: 'Check the connection and try again.', docs: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to switch the Play Developer Reporting API on for the caller's Cloud project: the project from Google's 403
|
||||
* ("has not been used in project 123…", consumer "projects/123…", activationUrl "?project=…"), else from the service
|
||||
* account e-mail (<name>@<project-id>.iam.gserviceaccount.com). Pure.
|
||||
*/
|
||||
export function reportingEnableUrl(errorText: string, principal: string | null): { project: string | null; url: string } {
|
||||
const project = /[?&]project=([a-z0-9-]+)/i.exec(errorText)?.[1] ?? /projects\/([a-z0-9-]+)/i.exec(errorText)?.[1] ?? /in project ([a-z0-9-]+)/i.exec(errorText)?.[1]
|
||||
?? (principal ? /@([a-z][a-z0-9-]{4,61}[a-z0-9])\.iam\.gserviceaccount\.com$/.exec(principal)?.[1] : undefined) ?? null;
|
||||
return { project, url: `${REPORTING_API_URL}${project ? `?project=${project}` : ''}` };
|
||||
}
|
||||
|
||||
/** Is this error Google's "API not enabled in the Cloud project" 403 (SERVICE_DISABLED)? */
|
||||
export const isServiceDisabled = (e: unknown): boolean => {
|
||||
const full = String((e as Error)?.message ?? e);
|
||||
return statusOf(e) === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full);
|
||||
};
|
||||
|
||||
/** Play apps known on this machine without the Reporting API: the package names of the app folders' configs and the
|
||||
* store-only Play entries of ~/.storebound/projects.json. Unverified on the store (marked in `sku`). Pure. */
|
||||
export function localPlayApps(known: { packageName: string; name: string; source: string }[]): StoreApp[] {
|
||||
const out: StoreApp[] = [];
|
||||
for (const k of known) if (k.packageName && !out.some(a => a.id === k.packageName))
|
||||
out.push({ store: 'android', id: k.packageName, bundleId: k.packageName, name: k.name || k.packageName, platforms: ['ANDROID'], sku: null, primaryLocale: null, latest: null, icon: null });
|
||||
return out.sort((x, y) => x.name.localeCompare(y.name));
|
||||
}
|
||||
|
||||
/** Play service account health from the token exchange and an `apps:search` outcome. */
|
||||
export function playCheck(principal: string | null, outcome: { apps: number } | { error: unknown }): AccountCheck {
|
||||
const who = principal ?? 'the service account';
|
||||
|
|
@ -156,9 +182,11 @@ export function playCheck(principal: string | null, outcome: { apps: number } |
|
|||
: { store: 'android', status: 'permission', principal, apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: invite, docs: PLAY_USERS_URL };
|
||||
}
|
||||
const code = statusOf(outcome.error), msg = short(outcome.error), full = String((outcome.error as Error)?.message ?? outcome.error);
|
||||
if (code === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full))
|
||||
return { store: 'android', status: 'api-disabled', principal, apps: null, message: 'The Google Play Developer Reporting API is off in this service account\'s Google Cloud project.',
|
||||
fix: 'Google Cloud console > APIs & Services > Library: enable "Google Play Developer Reporting API" (and "Google Play Android Developer API") in the service account\'s project, wait a few minutes, test again.', docs: REPORTING_API_URL };
|
||||
if (code === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full)) {
|
||||
const on = reportingEnableUrl(full, principal);
|
||||
return { store: 'android', status: 'api-disabled', principal, apps: null, message: `The Google Play Developer Reporting API is off in this service account's Google Cloud project${on.project ? ` (${on.project})` : ''}.`,
|
||||
fix: `Enable "Google Play Developer Reporting API" for that project: ${on.url} (press Enable; also "Google Play Android Developer API" if it is off), wait a few minutes, test again.`, docs: on.url };
|
||||
}
|
||||
if (code === null && /invalid_grant|Invalid JWT|account not found|disabled/i.test(full))
|
||||
return { store: 'android', status: 'revoked', principal, apps: null, message: `Google refused the key: ${msg}`,
|
||||
fix: 'The JSON key was deleted or the service account disabled. Google Cloud console > IAM & Admin > Service accounts > Keys: add a new JSON key, then add it here again.', docs: null };
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@
|
|||
// these functions turn it into an ordered list of findings. Order = the order you would fix things in.
|
||||
|
||||
export type Level = 'blocker' | 'warn' | 'ok';
|
||||
|
||||
/** Screenshot sets App Store Connect takes instead of a required one (App Store Connect Help, "Screenshot
|
||||
* specifications", checked 2026-10-13): iPhone needs the 6.9" set or, without it, the 6.5" set; iPad needs the 13" set
|
||||
* (either 13" display type). The smaller sizes are scaled down from these. A live app with only the 6.5" set is fine
|
||||
* (store-pitfalls 44). */
|
||||
export const ACCEPTED_INSTEAD: Record<string, string[]> = {
|
||||
APP_IPHONE_67: ['APP_IPHONE_65'],
|
||||
APP_IPHONE_65: ['APP_IPHONE_67'],
|
||||
APP_IPAD_PRO_3GEN_129: ['APP_IPAD_PRO_129'],
|
||||
APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'],
|
||||
};
|
||||
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string }
|
||||
|
||||
export interface UrlCheck { url: string; status: number | null; error?: string; body?: string }
|
||||
|
|
@ -154,7 +165,12 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
for (const l of s.locales) {
|
||||
const miss = (['description', 'keywords', 'subtitle'] as const).filter(k => !l[k]);
|
||||
if (miss.length) add('blocker', 'metadata', `${l.locale}: missing ${miss.join(', ')}`, 'store/appstore.md, then storebound apple listing');
|
||||
for (const t of s.requiredDisplayTypes) if (!l.screenshots[t]) add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots`, 'storebound apple screenshots');
|
||||
for (const t of s.requiredDisplayTypes) {
|
||||
if (l.screenshots[t]) continue;
|
||||
const alt = (ACCEPTED_INSTEAD[t] ?? []).find(x => l.screenshots[x]);
|
||||
if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots');
|
||||
else add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots${ACCEPTED_INSTEAD[t] ? ` (or ${ACCEPTED_INSTEAD[t]!.join(' / ')})` : ''}`, 'storebound apple screenshots');
|
||||
}
|
||||
}
|
||||
if (s.locales.length && !f.some(x => x.code === 'metadata' || x.code === 'screenshots' || x.code === 'locale')) add('ok', 'metadata', `${s.locales.length} locales with text and screenshots`);
|
||||
|
||||
|
|
@ -209,14 +225,27 @@ export function playRules(s: PlaySnapshot): Finding[] {
|
|||
const f: Finding[] = [];
|
||||
const add = (level: Level, code: string, msg: string, fix?: string) => f.push({ store: 'play', level, code, msg, fix });
|
||||
const have = new Map(s.listings.map(l => [l.language, l] as const));
|
||||
const fallback: Record<string, string[]> = {};
|
||||
if (!have.has(s.defaultLanguage)) add('blocker', 'listing', `no listing for the default language ${s.defaultLanguage}`, 'storebound play listing');
|
||||
for (const lang of s.expectedLanguages) if (!have.has(lang)) add('blocker', 'listing', `${lang}: no listing`, 'storebound play listing');
|
||||
for (const l of s.listings) {
|
||||
const miss = (['title', 'shortDescription', 'fullDescription'] as const).filter(k => !l[k]);
|
||||
if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing');
|
||||
for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) if ((l.images[type] ?? 0) < n)
|
||||
for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) {
|
||||
if ((l.images[type] ?? 0) >= n) continue;
|
||||
// Play shows the default language's graphics in a language that has none of its own (store-pitfalls 45): only
|
||||
// the default language's missing graphics block; a language with fewer than needed but more than none is
|
||||
// a real gap (Play takes them as they are).
|
||||
const def = have.get(s.defaultLanguage);
|
||||
if (l.language !== s.defaultLanguage && !(l.images[type] ?? 0) && def && (def.images[type] ?? 0) >= n) {
|
||||
(fallback[type] ??= []).push(l.language);
|
||||
continue;
|
||||
}
|
||||
add('blocker', 'images', `${l.language}: ${type} ${l.images[type] ?? 0}/${n}${PLAY_TABLET_IMAGES[type] ? ' (play.tablets is on)' : ''}`, 'storebound play images');
|
||||
}
|
||||
}
|
||||
for (const [type, langs] of Object.entries(fallback))
|
||||
add('ok', 'images-fallback', `${type}: ${langs.length} language(s) without their own use the default language's (${s.defaultLanguage}) graphics: ${langs.slice(0, 6).join(', ')}${langs.length > 6 ? ' ...' : ''}`);
|
||||
if (s.listings.length && !f.some(x => x.code === 'listing' || x.code === 'images')) add('ok', 'listing', `${s.listings.length} listings with text and graphics`);
|
||||
|
||||
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
|
||||
|
|
|
|||
|
|
@ -328,6 +328,9 @@ export const TOOLS: ToolDef[] = [
|
|||
{ name: 'catalog_pull', title: 'Pull IAP and subscription texts', command: ['catalog', 'pull'], write: true, network: true, idempotent: true,
|
||||
description: 'Store titles and descriptions (Play: benefits) of in-app purchases and subscriptions vs the config listings, as a proposal diff. storebound.config.ts is never rewritten; with dry_run false the proposal is saved to .storebound/pull/catalog.json.',
|
||||
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||
{ name: 'onboard', title: 'Onboard a live app', command: ['onboard'], write: true, network: true, idempotent: true,
|
||||
description: 'For an app that is already live: reads the live products and subscriptions (USD / base prices), contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page from the stores, the code and the app\'s site, and lists the owner questions nothing proves. Read-only on the stores, never changes store prices. dry_run shows the plan; with dry_run false storebound.config.ts and .storebound/credentials.json are updated (personal data masked, never printed).',
|
||||
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||
{ name: 'whats_new_languages', title: 'Release notes per language', command: ['whats-new'], write: true, network: false, idempotent: true,
|
||||
description: 'Draft release notes from git into one file per language (store/whats-new/<language id>.md): the first language gets the draft, the others status=translate files for you to translate (keep the header line, set status=translated). Then whats_new_check and whats_new_apply. Local files only.',
|
||||
props: { languages: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated language ids or store codes, source language first' }, since: { type: 'string', description: 'Git tag or ref (default: latest tag)' } },
|
||||
|
|
@ -419,7 +422,7 @@ export const FORBIDDEN_ARGS = ['--write', '--yes', '--manual-release', '--cancel
|
|||
* `scan --write` (compliance answers into the config) stays unreachable: scan is not in this list.
|
||||
*/
|
||||
export const LOCAL_WRITE_TOOLS: Record<string, readonly string[]> = {
|
||||
apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'],
|
||||
apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'], onboard: ['--write'],
|
||||
};
|
||||
|
||||
export function inputSchema(t: ToolDef): Record<string, unknown> {
|
||||
|
|
|
|||
187
cli/onboard.ts
Normal file
187
cli/onboard.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// Onboarding an app that is already live on a store (after `storebound init` and `listing pull`): reads what the
|
||||
// stores and the code prove and puts it into the project, so the owner is asked only what nothing proves.
|
||||
// storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]
|
||||
// Read-only on both stores (GETs; a Play edit that is opened, read and deleted, never committed). Without --write it
|
||||
// prints the plan; --write (never with --dry) changes only local files:
|
||||
// 1. products / subscriptions in storebound.config.ts from the live catalog of both stores: ids, type, listings per
|
||||
// language, usd = the US price (Apple: its base territory as an override when it is not the USA), an item on one
|
||||
// store only kept with platforms. `storebound catalog diff` comes out clean. Store prices are never changed.
|
||||
// 2. app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app details and App Store
|
||||
// Connect, where the config still holds a template value (example.com); the App Review contact (Apple's review
|
||||
// detail) into .storebound/credentials.json reviewContact when it has none. Personal data is masked in all output.
|
||||
// 3. reviewAccess.signInRequired from Apple's review detail and the code's auth signals (never a default yes); a demo
|
||||
// account set on App Store Connect goes into .storebound/credentials.json reviewDemoAccount, never printed.
|
||||
// 4. accounts.deletionUrl, when the app has accounts and none is set: a deletion page found on the app's own site
|
||||
// (website, privacy policy links). Not found is reported as the gap it is, never invented.
|
||||
// Then the owner questions that are left (one batch).
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { appleItems, appleTexts, playItems, playTexts } from './catalog';
|
||||
import { ascClient, type J } from './apple/asc';
|
||||
import type { StoreItem } from './lib/catalog';
|
||||
import { reviewContact, reviewDemoAccount } from './lib/credentials';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
import { type AppleReviewDetail, contactProposal, type DeletionResult, findDeletionUrl, importCatalog, maskContact, type PageGet, reviewContactOf, signInDecision, type StoreContact } from './lib/onboard';
|
||||
import { authEvidence } from './lib/project-signals';
|
||||
import { detectProject } from './lib/scan/project';
|
||||
import { upsertBlock, upsertNestedString } from './lib/scan/config-edit';
|
||||
import { playClient } from './play/client';
|
||||
import { textTable } from './lib/table';
|
||||
|
||||
const TAG = 'onboard';
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
/** GET a public page with a timeout (redirects followed); network errors are status null. */
|
||||
export const pageGet: PageGet = async url => {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const r = await fetch(url, { signal: ctl.signal, redirect: 'follow', headers: { 'user-agent': 'storebound (account deletion page check)' } });
|
||||
const text = (r.headers.get('content-type') ?? '').includes('text') ? (await r.text()).slice(0, 500_000) : '';
|
||||
return { status: r.status, text, finalUrl: r.url };
|
||||
} catch { return { status: null, text: '' }; } finally { clearTimeout(t); }
|
||||
};
|
||||
|
||||
async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']>> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const app = (await c.asc('GET', `/v1/apps/${appId}`)).data;
|
||||
const primary: string = app?.attributes?.primaryLocale ?? 'en-US';
|
||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
|
||||
const v: J = (await c.editableVersion()) ?? versions.find((x: J) => (x.attributes.appStoreState ?? x.attributes.appVersionState) === 'READY_FOR_SALE') ?? versions[0] ?? null;
|
||||
let supportUrl: string | undefined, marketingUrl: string | undefined, review: AppleReviewDetail | null = null;
|
||||
if (v) {
|
||||
const locs = await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`);
|
||||
const l = locs.find((x: J) => x.attributes.locale === primary) ?? locs[0];
|
||||
supportUrl = l?.attributes?.supportUrl ?? undefined;
|
||||
marketingUrl = l?.attributes?.marketingUrl ?? undefined;
|
||||
review = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null;
|
||||
}
|
||||
const info = await c.editableAppInfo();
|
||||
const infoLocs = info ? await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`) : [];
|
||||
const il = infoLocs.find((x: J) => x.attributes.locale === primary) ?? infoLocs[0];
|
||||
return { supportUrl, marketingUrl, privacyPolicyUrl: il?.attributes?.privacyPolicyUrl ?? undefined, review };
|
||||
}
|
||||
|
||||
async function playFacts(ctx: Ctx): Promise<NonNullable<StoreContact['play']>> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const play = await playClient(root, config.play!.packageName);
|
||||
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
|
||||
try {
|
||||
const d = await play.call<{ contactEmail?: string; contactWebsite?: string; contactPhone?: string }>('GET', `${play.API}/edits/${e.id}/details`);
|
||||
return { contactEmail: d.contactEmail, contactWebsite: d.contactWebsite, contactPhone: d.contactPhone };
|
||||
} finally {
|
||||
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** .storebound/credentials.json with `key` set when it has none (0600). Returns whether it was written. */
|
||||
function setProjectSecret(root: string, key: 'reviewContact' | 'reviewDemoAccount', value: unknown): boolean {
|
||||
mkdirSync(join(root, '.storebound'), { recursive: true });
|
||||
const f = join(root, '.storebound', 'credentials.json');
|
||||
const cur = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) as Record<string, unknown> : {};
|
||||
cur[key] = value;
|
||||
writeFileSync(f, JSON.stringify(cur, null, 2) + '\n', { mode: 0o600 });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root, path: configPath } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
const store = a.get('store');
|
||||
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
||||
const useApple = (!store || store === 'apple') && !!config.apple;
|
||||
const usePlay = (!store || store === 'play') && !!config.play;
|
||||
const write = a.has('write') && !a.has('dry');
|
||||
const json = a.has('json');
|
||||
const questions: string[] = [];
|
||||
const report: Record<string, unknown> = {};
|
||||
let text = readFileSync(configPath, 'utf8');
|
||||
const before = text;
|
||||
|
||||
// 1. catalog
|
||||
if (!a.has('no-catalog')) {
|
||||
log(TAG, 'reading the live products and subscriptions');
|
||||
const read = async <T>(what: string, f: () => Promise<T>): Promise<T | null> => { try { return await f(); } catch (e) { warn(TAG, `${what}: ${(e as Error).message.slice(0, 300)}`); return null; } };
|
||||
const apple: StoreItem[] | null = useApple ? await read('App Store products', () => appleItems(ctx)) : null;
|
||||
const play: StoreItem[] | null = usePlay ? await read('Play products', () => playItems(ctx)) : null;
|
||||
const at = useApple && apple ? await read('App Store product texts', () => appleTexts(ctx)) ?? undefined : undefined;
|
||||
const pt = usePlay && play ? await read('Play product texts', () => playTexts(ctx)) ?? undefined : undefined;
|
||||
// a store that could not be read keeps the config's items for that store as they are
|
||||
const cat = importCatalog(config, { apple, play, appleTexts: at, playTexts: pt });
|
||||
report.catalog = { added: cat.added, updated: cat.updated, skipped: cat.skipped, notes: cat.notes, products: cat.products.length, subscriptions: cat.subscriptions.reduce((n, g) => n + g.subscriptions.length, 0) };
|
||||
if (!json) {
|
||||
const rows = [...cat.products.map(p => [p.id, p.type, p.usd.toFixed(2), (p.overrides ?? []).map(o => `${o.region} ${o.price} ${o.currency}`).join(' '), (p.platforms ?? ['both']).join(' '), Object.keys(p.listings).join(' ')]),
|
||||
...cat.subscriptions.flatMap(g => g.subscriptions.map(s => [s.id, `sub ${s.period} (${g.name})`, s.usd.toFixed(2), (s.overrides ?? []).map(o => `${o.region} ${o.price} ${o.currency}`).join(' '), (s.platforms ?? ['both']).join(' '), Object.keys(s.listings).join(' ')]))];
|
||||
if (rows.length) console.log(textTable(['product', 'type', 'usd', 'base / overrides', 'stores', 'languages'], rows));
|
||||
for (const n of cat.notes) (n.level === 'warn' ? warn : log)(TAG, `${n.id}: ${n.msg}`);
|
||||
for (const s of cat.skipped) warn(TAG, `${s.id}: not written: ${s.why}`);
|
||||
}
|
||||
if (apple || play) {
|
||||
if (cat.products.length || config.products) text = upsertBlock(text, configPath, 'products', cat.products);
|
||||
if (cat.subscriptions.length || config.subscriptions) text = upsertBlock(text, configPath, 'subscriptions', cat.subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. contact and URLs, 3. sign-in for review
|
||||
let appleF: Awaited<ReturnType<typeof appleFacts>> | null = null, playF: Awaited<ReturnType<typeof playFacts>> | null = null;
|
||||
if (useApple) { try { appleF = await appleFacts(ctx); } catch (e) { warn(TAG, `App Store app details: ${(e as Error).message.slice(0, 300)}`); } }
|
||||
if (usePlay) { try { playF = await playFacts(ctx); } catch (e) { warn(TAG, `Play app details: ${(e as Error).message.slice(0, 300)}`); } }
|
||||
const contact = contactProposal(config.app, { apple: appleF, play: playF });
|
||||
report.contact = contact;
|
||||
for (const c of contact) {
|
||||
if (c.replace) text = upsertNestedString(text, configPath, 'app', c.field, c.to);
|
||||
if (!json) (c.replace ? log : warn)(TAG, c.replace ? `app.${c.field}: ${c.to} (${c.source}; was ${c.from ?? 'unset'})` : `app.${c.field}: the config has ${c.from}, the store ${c.to} (${c.source}); kept the config's`);
|
||||
}
|
||||
const rc = reviewContactOf(appleF?.review);
|
||||
const haveRc = !!reviewContact(root);
|
||||
report.reviewContact = rc ? { from: 'App Store review detail', masked: maskContact(rc), action: haveRc ? 'kept (already in .storebound/credentials.json)' : 'import' } : null;
|
||||
if (rc && !json) log(TAG, `App Review contact ${maskContact(rc)}: ${haveRc ? 'already in .storebound/credentials.json (kept)' : 'into .storebound/credentials.json reviewContact'}`);
|
||||
if (!rc && useApple && !haveRc) questions.push('App Review contact (first and last name, phone, e-mail): App Store Connect has none; put it in .storebound/credentials.json reviewContact');
|
||||
|
||||
const facts = detectProject(root);
|
||||
const auth = authEvidence(facts);
|
||||
const sign = signInDecision({ auth, apple: appleF?.review });
|
||||
report.signIn = { signInRequired: sign.signInRequired, because: sign.because, demoAccountOnStore: !!sign.demoAccount, configured: config.reviewAccess?.signInRequired ?? null };
|
||||
if (!json) log(TAG, `sign-in for review: ${sign.signInRequired === null ? 'unknown' : sign.signInRequired ? 'required' : 'not required'} (${sign.because})`);
|
||||
if (sign.signInRequired !== null && config.reviewAccess?.signInRequired !== sign.signInRequired) {
|
||||
if (config.reviewAccess === undefined || config.reviewAccess.signInRequired === undefined) text = upsertBlock(text, configPath, 'reviewAccess', { ...(config.reviewAccess ?? {}), signInRequired: sign.signInRequired }, 'from storebound onboard: App Review sign-in');
|
||||
else warn(TAG, `reviewAccess.signInRequired is ${config.reviewAccess.signInRequired} in the config, the evidence says ${sign.signInRequired} (${sign.because}); kept the config's`);
|
||||
}
|
||||
if (sign.question) questions.push(sign.question);
|
||||
const haveDemo = !!reviewDemoAccount(root);
|
||||
if (sign.demoAccount && !json) log(TAG, `demo account on App Store Connect: ${haveDemo ? 'one is already in .storebound/credentials.json (kept)' : 'into .storebound/credentials.json reviewDemoAccount (not printed)'}`);
|
||||
if (sign.signInRequired && !sign.demoAccount && !haveDemo) questions.push('Demo account for App Review (sign-in is required, none is set on App Store Connect): put it in .storebound/credentials.json reviewDemoAccount');
|
||||
|
||||
// 4. account deletion page
|
||||
const accounts = !!config.accounts?.enabled || auth.length > 0;
|
||||
let deletion: DeletionResult | null = null;
|
||||
if (accounts && !config.accounts?.deletionUrl && !a.has('no-web')) {
|
||||
const site = contact.find(c => c.field === 'website' && c.replace)?.to ?? config.app.website;
|
||||
const privacy = contact.find(c => c.field === 'privacyPolicyUrl' && c.replace)?.to ?? appleF?.privacyPolicyUrl ?? config.app.privacyPolicyUrl;
|
||||
deletion = await findDeletionUrl({ website: site, privacyUrl: privacy }, pageGet);
|
||||
if (!json) (deletion.url ? log : warn)(TAG, deletion.url ? `account deletion page: ${deletion.url} (${deletion.how})` : `account deletion page: ${deletion.how}; tried ${deletion.tried.length} page(s)`);
|
||||
if (deletion.url) {
|
||||
text = config.accounts ? upsertNestedString(text, configPath, 'accounts', 'deletionUrl', deletion.url)
|
||||
: upsertBlock(text, configPath, 'accounts', { enabled: true, deletionUrl: deletion.url }, 'from storebound onboard: the app has sign-in code');
|
||||
} else if (config.play) questions.push('Account deletion page (Play requires a web page to request account deletion): none was found on the app\'s site; publish one, then set accounts.deletionUrl (account-deletion skill)');
|
||||
}
|
||||
report.deletion = deletion;
|
||||
report.questions = questions;
|
||||
|
||||
const changed = text !== before;
|
||||
const secrets = [rc && !haveRc ? 'reviewContact' : null, sign.demoAccount && !haveDemo ? 'reviewDemoAccount' : null].filter(Boolean);
|
||||
report.write = { config: changed, credentials: secrets, written: write };
|
||||
if (write) {
|
||||
if (changed) writeFileSync(configPath, text);
|
||||
if (rc && !haveRc) setProjectSecret(root, 'reviewContact', rc);
|
||||
if (sign.demoAccount && !haveDemo) setProjectSecret(root, 'reviewDemoAccount', sign.demoAccount);
|
||||
}
|
||||
if (json) { printJson(report); return; }
|
||||
if (questions.length) console.log(`\nowner questions (nothing on the stores or in the code proves these):\n${questions.map((q, i) => ` ${i + 1}. ${q}`).join('\n')}`);
|
||||
const what = [changed && 'storebound.config.ts', secrets.length && `.storebound/credentials.json (${secrets.join(', ')})`].filter(Boolean).join(' + ');
|
||||
if (!what) done(TAG, 'nothing to change');
|
||||
else done(TAG, write ? `written: ${what}; next: storebound catalog diff, storebound validate` : `would write ${what} (run with --write)`);
|
||||
}
|
||||
|
|
@ -238,7 +238,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| **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, validate |
|
||||
| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, 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 | |
|
||||
|
|
@ -365,13 +365,13 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `appStoreVersions_appClipDefaultExperience_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appClipDefaultExperience` | read | |
|
||||
| `appStoreVersions_appClipDefaultExperience_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appClipDefaultExperience` | read | |
|
||||
| `appStoreVersions_appClipDefaultExperience_updateToOneRelationship` | PATCH | `/v1/appStoreVersions/{id}/relationships/appClipDefaultExperience` | write (dry by default) | |
|
||||
| `appStoreVersions_appStoreReviewDetail_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreReviewDetail` | read | apple/review-details, validate |
|
||||
| `appStoreVersions_appStoreReviewDetail_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreReviewDetail` | read | apple/review-details, onboard, validate |
|
||||
| `appStoreVersions_appStoreReviewDetail_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreReviewDetail` | read | |
|
||||
| `appStoreVersions_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionExperimentsV2` | read | |
|
||||
| `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, validate |
|
||||
| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, 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, 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, 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 | |
|
||||
|
|
@ -459,7 +459,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `apps_gameCenterEnabledVersions_getToManyRelated` | GET | `/v1/apps/{id}/gameCenterEnabledVersions` | read | |
|
||||
| `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | |
|
||||
| `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, console/app-id, console/verify, lib/store-apps |
|
||||
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status |
|
||||
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status, apps, onboard |
|
||||
| `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate |
|
||||
| `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | |
|
||||
| `apps_inAppPurchases_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchases` | read | |
|
||||
|
|
@ -1565,9 +1565,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `androidpublisher.appstorecatalog.recentupdateevents.list` | GET | `androidpublisher/v3/appstorecatalog/{appStorePackageName}/recentUpdateEvents` | read | |
|
||||
| **androidpublisher.edits** (5) | | | | |
|
||||
| `androidpublisher.edits.commit` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:commit` | owner only (typed confirm) | play/client |
|
||||
| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, play/client, validate |
|
||||
| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, onboard, play/client, validate |
|
||||
| `androidpublisher.edits.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | read | |
|
||||
| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, play/client, validate |
|
||||
| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, onboard, play/client, validate |
|
||||
| `androidpublisher.edits.validate` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:validate` | write (dry by default) | play/client |
|
||||
| **androidpublisher.edits.apks** (3) | | | | |
|
||||
| `androidpublisher.edits.apks.addexternallyhosted` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/externallyHosted` | write (dry by default) | |
|
||||
|
|
@ -1581,7 +1581,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| **androidpublisher.edits.deobfuscationfiles** (1) | | | | |
|
||||
| `androidpublisher.edits.deobfuscationfiles.upload` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/deobfuscationFiles/{deobfuscationFileType}` | write (dry by default) | |
|
||||
| **androidpublisher.edits.details** (3) | | | | |
|
||||
| `androidpublisher.edits.details.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | read | apps, play/listing, play/status, validate |
|
||||
| `androidpublisher.edits.details.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | read | apps, onboard, play/listing, play/status, validate |
|
||||
| `androidpublisher.edits.details.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | write (dry by default) | |
|
||||
| `androidpublisher.edits.details.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | write (dry by default) | play/listing |
|
||||
| **androidpublisher.edits.expansionfiles** (4) | | | | |
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
|||
| `listing translate --to de,ja [--from en] [--translate "<cmd>"]` | local | per-language listing sections in both listing files (App Store name, subtitle, promotional text, keywords, description; Play title, short, full) for your agent (or a hook) to translate, marked as drafts until reviewed; keywords cleaned up |
|
||||
| `listing check` / `listing reviewed --lang <ids>` | local | every language section against the store limits and keyword rules, untranslated and unreviewed drafts (exit 1 on a problem) / remove the draft marker after a human read it |
|
||||
| `flow list \| validate \| run <name> [NAME=value] [--dry \| --apply [--yes]] [--from <step> \| --resume]` | local + whatever the steps call | repo-local workflows (`store/workflows/*.yml`): steps in order with progress; writes run as dry runs unless `--apply`, owner steps are listed as your click and never run (see [Repo-local workflows](studios.md#repo-local-workflows)) |
|
||||
| `onboard [--store] [--write] [--no-web] [--no-catalog]` | API, read-only + local | an app that is already live: products and subscriptions with their USD / base prices (catalog diff clean), contact e-mail, website, privacy / support URLs, App Review contact and demo account (`.storebound/credentials.json`, masked), sign-in for review (Apple review detail + code auth signals), the account deletion page on the app's site; then only the owner questions nothing proves. Never changes store prices |
|
||||
| `catalog pull [--write]` | API, read-only + local | IAP and subscription texts on the stores vs the config: a proposal diff (`.storebound/pull/catalog.json`); the config is never rewritten |
|
||||
| `ads scan --ios <path>` | local | AdAttributionKit / SKAdNetwork: ad network ids, postback copy endpoints, attribution windows and cooldown, conversion value updates; exit 1 on a blocker |
|
||||
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
||||
| `api list \| coverage \| apple\|play <operation>` | API | every App Store Connect / Google Play operation (see [Every endpoint](api.md)); GET runs, writes are dry unless `--yes` in a terminal |
|
||||
| `desktop [--yes] [--dry] [--download]` | local + download | the desktop app of this CLI's version, opened on this folder: installs it for this user when missing (size, SHA-256 and signature checked; asks once, `--yes`), updates an older one, notes a newer one (`--force` to downgrade); `--download` only saves the checked installer |
|
||||
| `accounts [list] \| import [--dry] \| use <profile>` | local | the store accounts of this machine (`~/.storebound/credentials`, shared with the desktop app); `import` moves a project's store keys into a profile (same key = same profile), never prints values |
|
||||
| `accounts [list] \| import [--dry] \| use <profile> \| default <profile>` | local | the store accounts of this machine (`~/.storebound/credentials`, shared with the desktop app); `import` moves a project's store keys into a profile (same key = same profile), never prints values; `default` sets the machine default profile (env > a profile the project names > the project's own keys > this default > the only profile) |
|
||||
| `apps folders \| add [<folder>] \| remove <folder>` | local | the app folders of this machine (`~/.storebound/projects.json`), the same list as the desktop app |
|
||||
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
|
||||
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
||||
|
|
|
|||
|
|
@ -53,6 +53,26 @@ catalog", "the reviewer could not sign in", "make store screenshots in 5 languag
|
|||
update rejected", "put build 42 on TestFlight", "how are our crash rates", "answer the new reviews", "did the new
|
||||
screenshots help". The matching skill takes over.
|
||||
|
||||
## An app that is already live
|
||||
|
||||
`storebound init`, then pull what the stores already have instead of typing it in:
|
||||
|
||||
```sh
|
||||
bunx storebound apple listing pull --write && bunx storebound play listing pull --write # texts per language
|
||||
bunx storebound onboard # the plan: products + prices, contact, review contact, sign-in, deletion page
|
||||
bunx storebound onboard --write # into storebound.config.ts and .storebound/credentials.json
|
||||
bunx storebound catalog diff # clean: the config now sells what the stores sell
|
||||
```
|
||||
|
||||
`onboard` is read-only on the stores and never changes a store price. It imports the live one-time products and
|
||||
subscriptions (usd = the US price; Apple's base territory as an override when it is not the USA; an item on one store
|
||||
only keeps `platforms`), replaces template contact values (`support@example.com`, example.com URLs) with Play's and App
|
||||
Store Connect's, puts the App Review contact and a demo account set on App Store Connect into
|
||||
`.storebound/credentials.json` (never printed), decides "sign-in required for review" from Apple's review detail and the
|
||||
code, and looks for the account deletion page on the app's own site. What nothing proves is listed as owner questions
|
||||
at the end, in one batch. An iOS app whose source is not on this machine: `storebound apps add --store apple --app-id
|
||||
<id> --account <profile>` shows it in the desktop, read-only.
|
||||
|
||||
## Coming from fastlane
|
||||
|
||||
`storebound import fastlane` turns an existing fastlane setup into storebound files. It only reads (no Ruby is run),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 52 | 2026-10 | An iOS app that is live on the App Store but whose source is not on this machine could not appear in the desktop: the machine's app list (`~/.storebound/projects.json`) took only folders. | `storebound apps add --store apple\|play --app-id <id> --account <profile>` writes a store-only entry (`storeApps`, no path; `apps folders` shows "no local project", `apps remove apple:<id>`); the desktop adds it to the app list of the account with that profile (`registry::with_store_only`, `store_apps_cached` / `store_apps_refresh`), shown read-only as "Store only"; writing the folder list keeps the entries. `tests/onboard.test.ts`, `src-tauri/tests/machine.rs`, the boot check's `shared-profiles` case (zero uncaught errors). |
|
||||
| 51 | 2026-10 | With several credential profiles the only way to have a machine default was naming one `default`. | `storebound accounts default <profile>` (and `--clear`) writes the pointer file `<credentials>/default-profile`; `accounts list` shows the default; order: env > a profile the project names > the project's own keys > the pointer > the only profile (or `default`) (`defaultProfile`, `setDefaultPointer` in `cli/lib/credentials.ts`; `tests/machine.test.ts`). |
|
||||
| 50 | 2026-10 | Onboarding an app with sign-in left "account deletion URL" as an owner question although the app's own website had the page. | `storebound onboard` looks on the app's site before asking: /delete-account, /account/delete, /delete, /account-deletion on the website and the privacy policy's host, then links in the privacy policy that mention delete / deletion, then a deletion section of the privacy page; a page counts only when it answers 200 and speaks of deleting. Not found stays a finding ("a real gap, Play requires it"); never invented (`findDeletionUrl` in `cli/lib/onboard.ts`; `tests/onboard.test.ts`). |
|
||||
| 49 | 2026-10 | "Sign-in required for review" defaulted to yes during onboarding, so every app got a demo-account question. | `signInDecision`: Apple's live review detail (`demoAccountRequired`) wins, else no auth code in the project (scan's auth signals, import lines ignored) means no; only when neither proves it is it an owner question. A demo account set on App Store Connect goes into `.storebound/credentials.json` `reviewDemoAccount`, never printed (`cli/lib/onboard.ts`, `cli/onboard.ts`; `tests/onboard.test.ts`). |
|
||||
| 48 | 2026-10 | Onboarding left `support@example.com` and the template URLs in the config and asked the owner for the contact e-mail, website and App Review contact, all of which the stores have. | `storebound onboard` fills app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app details and App Store Connect where the config holds a template value (a real value that differs is reported, kept); the App Review contact goes into `.storebound/credentials.json` `reviewContact`, masked in every line (phone: last 2 digits) (`contactProposal`, `maskContact`; `tests/onboard.test.ts`). |
|
||||
| 47 | 2026-10 | Setting storebound up on live apps left their products out of the config "because there were no USD prices": the import read only texts (`catalog pull`), never prices. | `storebound onboard --write` imports the live one-time products and subscriptions of both stores: ids, type, listings per language, usd = the US price (else Play's own USD price for new regions), Apple's price schedule base territory as an override when it is not the USA, an item on one store only kept with `platforms`; `catalog diff` comes out clean; store prices are never changed, an item with no USD price anywhere is skipped with the reason (`importCatalog`; `tests/onboard.test.ts` runs `catalog diff` on the written config). |
|
||||
| 46 | 2026-10 | `apps list --store play` and Play vitals failed outright when the Play Developer Reporting API was off in the service account's Cloud project, with a generic "enable it" hint. | The finding names the project and the exact link (`https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=<id>`, the id from Google's 403 or the service account e-mail: `reportingEnableUrl`); `apps list` no longer fails: it lists the Play apps of this machine's app folders and store-only entries instead, marked as not read from Play (`knownPlayPackages`, `localPlayApps`); `healthFindings` uses the same link. `tests/onboard.test.ts`, `tests/store-real-shapes.test.ts`. |
|
||||
| 45 | 2026-10 | `validate` reported 34 blockers for a Play app whose 17 extra languages have no icon or feature graphic of their own; Play shows the default language's graphics there. | `playRules`: a non-default language with none of its own uses the default language's graphics (one note per image type); only the default language's missing graphics block (`cli/lib/validate-rules.ts`; `tests/wave1.test.ts`). |
|
||||
| 44 | 2026-10 | "en-US: no APP_IPHONE_67 screenshots" blocked live apps that have the 6.5" set: App Store Connect requires the 6.9" set or, without it, the 6.5" set (and scales down from it). | `ACCEPTED_INSTEAD` in `cli/lib/validate-rules.ts`: the 6.5" set satisfies the 6.9" requirement (and either 13" iPad type the other); then a recommendation, not a blocker (`tests/wave1.test.ts`, a live-shape set list with only APP_IPHONE_65). |
|
||||
| 43 | 2026-10 | The App Store keyword field is limited to 100 characters, not UTF-8 bytes (entry 4 was wrong): live versions READY_FOR_SALE read on 2026-10-13 hold Thai 95 characters / 265 bytes, Russian 91 / 169, Japanese 49 / 123, Turkish 100 / 105, Polish 99 / 105, and App Store Connect accepted them. | `check` (`cli/lib/copy.ts`), `check --aso` (`auditAppleKeywords`), `listing check` (`checkFields`), `listing translate` (`normalizeKeywords`) and `aso suggest` (`fitKeywordField`) count characters; `tests/listing-translate.test.ts`, `tests/wave1.test.ts`, `tests/aso-plus.test.ts` with Thai, Turkish and Polish fields over 100 bytes. |
|
||||
| 42 | 2026-10 | `apple testflight distribute --build 1 --group Team --yes` failed with 400 on `GET /v1/betaGroups?filter[builds]=`: Apple's spec declares the filter, App Store Connect refuses it, and the spec has no GET of a build's groups. Before the groups existed it said `unknown group "Team" ()`. | Distribute reads each target group's builds (`GET /v1/betaGroups/{id}/relationships/builds`), reports internal all-builds groups as "already available" and never sends them the build; `unknownGroupMessage` points at `groups sync` for configured groups. `APPLE_LIVE_REJECTED` (`cli/api/client.ts`, also refused by `storebound api`); `tests/asc-query-guard.test.ts` checks every App Store Connect GET with a query in `cli/` against the spec's declared parameters and that list; the fake App Store Connect answers 400 to them (`tests/idempotency.test.ts`). |
|
||||
| 41 | 2026-10 | Every Play App content card ended in an owner-only Save, so the owner came back once per card (privacy policy, ads, app access, advertising ID, government, financial, health, target audience, content rating, data safety). Saving an App content declaration is not public: it goes to Google only with the next "Send changes for review" in Publishing overview (verified 2026-10-13). | Card Saves are the agent's (`declaration-save`): `declarationSaveAllowed` (exactly Save / Kaydet, a button on an /app-content/<card> page) and `flowAllowed` for content rating and target audience, while `publishingControl` refuses "Send changes for review", "Submit N changes for review", Submit, Publish, "Restart review" everywhere; the plans' `buttons.final` is "Send changes for review"; `console fill` presses the declaration's Save; `console plan play-app-content --all` walks every card and ends with one owner list (`cli/lib/console-forms.ts`, `cli/lib/console-plan.ts`, `cli/lib/console-play-plans.ts`, `cli/console/fill.ts`; `tests/console-play-plans.test.ts`, `tests/console-flow.test.ts`). |
|
||||
| 40 | 2026-10 | Finding a new app's Play Console app id (`play.appId`) took several steps: the app list shows 10 apps per page and the new app was not on page 1. The id was in the URL Create app redirects to (/developers/<devId>/app/<appId>/app-dashboard) all along. | `console verify play-app-record --url <url>` reads the id from the redirect (or the capture JSON's url), shows the `play.appId` change as a dry diff and `--write` upserts it (`upsertNestedString`); `storebound play app-id` finds it by package name across captured app list pages (search box or pages) as an agent step (`cli/lib/play-app-id.ts`, `cli/play/app-id.ts`; `tests/play-app-id.test.ts`, a modeled 3-page list). |
|
||||
|
|
@ -47,7 +57,7 @@ fails without the fix.
|
|||
| 7 | 2026-10 | Console UIs: the App Store Connect sign-in runs in an `idmsa.apple.com` iframe; "User Access" is a plain heading, not a labeled group; Chrome drops session cookies without `--restore-last-session`. | `console` session and fill (`cli/console/`), `tests/console-*.test.ts`. |
|
||||
| 6 | 2026-10 | Screenshot fonts: only part of the glyphs a language needs were available, so Turkish / Polish / Vietnamese characters fell back to another font silently. | `shots fonts` / `shots check` check every language's characters against the fonts (`tests/glyphs.test.ts`). |
|
||||
| 5 | 2026-10 | Account features do not imply email collection; Data safety said "email collected" without evidence. | `scan` marks email only with code evidence, else "ask owner" (`cli/lib/scan/declare.ts`). |
|
||||
| 4 | 2026-10 | The App Store keyword limit (100) counts UTF-8 bytes, not characters. | `check` counts bytes (`cli/lib/copy.ts`). |
|
||||
| 4 | 2026-10 | (Superseded by 43: the limit counts characters.) The App Store keyword limit (100) was taken to count UTF-8 bytes. | See 43. |
|
||||
| 3 | 2026-10 | App Store app names are unique per storefront: "already being used" although the developer owns no such app. | `apple.name` (App Store name with a suffix the owner picks); `app.name` stays the brand. |
|
||||
| 2 | 2026-10 | Dry runs that print no plan ("listing: 20 locales") break "show the dry run to the owner". | `cli/lib/dry.ts` `dryPlan(title, rows)`; `tests/pitfalls.test.ts` scans `cli/**` for `--dry` branches that print only a count (allowlist with TODOs for commands being fixed). |
|
||||
| 1 | 2026-10 | App Store Connect's `/inAppPurchasesV2` list lags right after `apple iap` creates a product; `apple iap-review` then skipped it silently. | `apple iap-review` names config products missing from the store's list and exits 1; `apple iap` and `play products` re-read the list after creating and warn (`cli/lib/store-lag.ts`, `tests/pitfalls.test.ts`). |
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ answers the compliance facts no file proves, all in one batch at the start.
|
|||
- Screenshot sizes: 6.9" 1320 x 2868 (`APP_IPHONE_67`) is required; 6.5" 1284 x 2778 (`APP_IPHONE_65`) recommended.
|
||||
PNG without alpha. Up to 10 per set. Apple processes uploads async; failures show as FAILED later.
|
||||
- App Store icon (1024) must have **no alpha channel** (`storebound icons` writes rgb24).
|
||||
- Limits: name 30, subtitle 30, promotional text 170, keywords 100 bytes (UTF-8, no spaces after commas),
|
||||
- Limits: name 30, subtitle 30, promotional text 170, keywords 100 characters (not bytes; no spaces after commas),
|
||||
description 4000. No "What's New" on a first version.
|
||||
- Guideline 5.1.1: the privacy policy link must be inside the app too (Settings > Privacy), not only in the listing.
|
||||
- `xcodebuild` with Capacitor needs a UTF-8 locale (`export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8`).
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ true; never use competitor or other apps' or games' names, "best / #1", or unrel
|
|||
`APPLE_ADS_*` credentials are set (`ads apple status` works), Apple Ads keyword suggestions. For each: relevance
|
||||
(in your own text +1, in competitor names +1), competition (top apps with the word in their name, "3/16"),
|
||||
popularity (Apple Ads' relative 0-100 score; "unknown" otherwise). Show the owner the proposed field (old -> new,
|
||||
bytes) and the left-out words with the reason. Only after a yes: `storebound aso suggest --locale en-US --write`
|
||||
characters) and the left-out words with the reason. Only after a yes: `storebound aso suggest --locale en-US --write`
|
||||
(terminal); it writes the field with a draft line above it. The owner reviews, deletes the line, runs
|
||||
`check --aso`. Never put words in that do not describe the app, other apps' names or brands.
|
||||
8. Review: `storebound aso audit` (MCP: `aso_audit`, offline) puts it together: keyword field findings, free keyword
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Local files only: nothing is sent to a store until the owner pushes with `apple
|
|||
Google Play title 30, short description 80, full description 4000. Shorten, never cut mid-word.
|
||||
- **Keywords are researched, not translated:** the words people in that market search for, comma separated,
|
||||
no spaces after commas, no duplicates, no words already in the name or subtitle (they are indexed anyway), no
|
||||
competitor or trademark names, 100 characters (bytes for CJK) used well (aso-basics).
|
||||
competitor or trademark names, 100 characters (characters in every script, not bytes) used well (aso-basics).
|
||||
- Subtitle and short description: the benefit in plain words, no "best / #1 / free", no prices, no other platforms.
|
||||
- Arabic and Hebrew stay RTL; Chinese / Japanese / Korean need no spaces in keywords.
|
||||
4. Optional provider hook (the owner's choice): `--translate "<command>"` runs the command once per field with the
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ Before pushing local texts over a console edit, pull the live ones and read the
|
|||
Both print the diff against `store/appstore.md` / `store/listing.md` and write only with `--write` (MCP:
|
||||
`apple_listing_pull` / `play_listing_pull` with dry_run false). New locales are appended as sections; a Play section
|
||||
shared by several codes is only changed when the store has the same text for all of them.
|
||||
- An app that was live before storebound: `storebound onboard` (then `--write`) imports the live products and
|
||||
subscriptions with their USD / base prices, contact values, the App Review contact and demo account, sign-in for
|
||||
review and the account deletion page; then `storebound catalog diff` is clean. Ask the owner only the questions it
|
||||
lists at the end.
|
||||
- `storebound catalog pull [--write]`: IAP and subscription titles / descriptions (Play: benefits) against the config.
|
||||
`storebound.config.ts` is never rewritten: `--write` saves `.storebound/pull/catalog.json` as a proposal; merge the
|
||||
listings into the config yourself (the agent), then `storebound check`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: store-pitfalls
|
||||
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - first: do the work, don't ask the owner for every step; store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries and re-runs, search ranks that are only a trend, no Play search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page, a signed-in console read as a sign-in page, console forms driven agent-first, stale Capacitor web assets, a declined flow step logged ok. Read it before any store task, and when a store command does something you did not expect.
|
||||
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - first: do the work, don't ask the owner for every step; store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in characters (not bytes), Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries and re-runs, search ranks that are only a trend, no Play search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page, a signed-in console read as a sign-in page, console forms driven agent-first, stale Capacitor web assets, a declined flow step logged ok. Read it before any store task, and when a store command does something you did not expect.
|
||||
---
|
||||
|
||||
# Store pitfalls
|
||||
|
|
@ -41,11 +41,8 @@ testers) and answers, in ONE batch at the start, only the facts no file proves (
|
|||
keep `app.name` for Play and the brand. Do not pick the suffix yourself.
|
||||
- **Guard:** `apple.name` overrides the App Store name.
|
||||
|
||||
## 4. Keyword field "too long" at fewer than 100 characters
|
||||
- **Symptom:** App Store Connect rejects keywords that look short enough, mostly in Turkish, German, CJK.
|
||||
- **Cause:** the 100 limit counts UTF-8 bytes, not characters ("ş" is 2 bytes, a CJK character 3).
|
||||
- **Do:** shorten by bytes; drop words, not commas.
|
||||
- **Guard:** `storebound check` counts bytes for keywords.
|
||||
## 4. Keyword field length (superseded by 43)
|
||||
- The limit is 100 characters, not UTF-8 bytes (see 43). Count characters; drop whole words, not commas.
|
||||
|
||||
## 5. Data safety says "email collected" for an app that never asks for one
|
||||
- **Symptom:** the proposal marks email (or phone) as collected because the app has accounts.
|
||||
|
|
@ -564,3 +561,68 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
- **Guard:** `tests/asc-query-guard.test.ts` (every App Store Connect GET with a query in `cli/` against the spec and
|
||||
`APPLE_LIVE_REJECTED`), the fake App Store Connect's 400 for such parameters, `unknownGroupMessage`;
|
||||
`tests/idempotency.test.ts` (distribute), `tests/testflight.test.ts`.
|
||||
|
||||
## 43. Keyword field: 100 characters, not bytes
|
||||
- **Symptom:** `check` / `listing check` / `check --aso` called live keyword fields too long ("105 bytes > 100") that App
|
||||
Store Connect accepted.
|
||||
- **Cause:** App Store Connect counts characters. Live READY_FOR_SALE versions (read 2026-10-13): Thai 95 characters /
|
||||
265 bytes, Russian 91 / 169, Turkish 100 / 105, Polish 99 / 105. Entry 4 (bytes) was wrong.
|
||||
- **Do:** count characters (`[...text].length`); fill up to 100 characters in any script.
|
||||
- **Guard:** every keyword check counts characters (`cli/lib/copy.ts`, `aso.ts`, `listing-translate.ts`,
|
||||
`aso-intel.ts`); tests with Thai, Turkish and Polish fields over 100 bytes.
|
||||
|
||||
## 44. "No 6.7-inch iPhone screenshots" on a live app with the 6.5-inch set
|
||||
- **Symptom:** `validate` blocked every locale of a live app: "en-US: no APP_IPHONE_67 screenshots".
|
||||
- **Cause:** App Store Connect requires the 6.9" set (APP_IPHONE_67) or, when it is missing, the 6.5" set
|
||||
(APP_IPHONE_65), and scales the smaller sizes from it.
|
||||
- **Do:** treat the 6.5" set as meeting the requirement; adding a 6.9" set is a recommendation.
|
||||
- **Guard:** `ACCEPTED_INSTEAD` in `cli/lib/validate-rules.ts` (a warning `screenshots-size`, no blocker).
|
||||
|
||||
## 45. Play graphics missing in extra languages
|
||||
- **Symptom:** `validate --play` listed an icon and a feature graphic blocker for every non-default language.
|
||||
- **Cause:** Play shows the default language's graphics in a language that has none of its own.
|
||||
- **Do:** only the default language's graphics are required; a language with none uses them (a note).
|
||||
- **Guard:** `playRules` (`images-fallback` note per type).
|
||||
|
||||
## 46. Play Developer Reporting API off
|
||||
- **Symptom:** `apps list --store play` and Play vitals failed with a 403 SERVICE_DISABLED.
|
||||
- **Cause:** the Reporting API is not enabled in the service account's Google Cloud project.
|
||||
- **Do:** open the link the finding prints (`…/playdeveloperreporting.googleapis.com?project=<id>`), press Enable,
|
||||
wait a few minutes. Meanwhile `apps list` shows the Play apps of this machine's folders and store-only entries.
|
||||
- **Guard:** `reportingEnableUrl`, `knownPlayPackages` (`cli/apps.ts`), `healthFindings`.
|
||||
|
||||
## 47. Onboarding a live app left its products out ("no USD prices")
|
||||
- **Symptom:** after `init` + `listing pull` the config had no products although the app sells some.
|
||||
- **Cause:** only texts were pulled, never prices; Apple's price schedule can have a base territory other than the USA.
|
||||
- **Do:** `storebound onboard` (then `--write`): products and subscriptions from both stores with usd = the US price,
|
||||
Apple's base territory as an override, one-store items marked with `platforms`. Then `storebound catalog diff`
|
||||
(clean). Store prices are never changed; an item with no USD price anywhere is named with its base price.
|
||||
- **Guard:** `importCatalog` (`cli/lib/onboard.ts`), `tests/onboard.test.ts`.
|
||||
|
||||
## 48. Contact e-mail, website and App Review contact asked of the owner
|
||||
- **Symptom:** onboarding kept `support@example.com` and asked for the website and review contact.
|
||||
- **Cause:** nothing read Play's app details or App Store Connect's review detail.
|
||||
- **Do:** `storebound onboard` reads them; template values are replaced, real ones kept; the review contact goes to
|
||||
`.storebound/credentials.json` (masked in output, the phone never printed).
|
||||
- **Guard:** `contactProposal`, `reviewContactOf`, `maskContact`.
|
||||
|
||||
## 49. Sign-in for review defaulted to yes
|
||||
- **Symptom:** every onboarded app got "sign-in required: add a demo account".
|
||||
- **Do:** decide from Apple's review detail (`demoAccountRequired`; a demo account there is imported, never printed)
|
||||
and from the code's auth signals; ask only when neither proves it.
|
||||
- **Guard:** `signInDecision`.
|
||||
|
||||
## 50. Account deletion URL asked although the site has the page
|
||||
- **Do:** `storebound onboard` tries /delete-account, /account/delete, /delete, /account-deletion and the privacy
|
||||
policy's deletion links; not found is reported as the real gap it is (Play requires the page). Never invent one.
|
||||
- **Guard:** `findDeletionUrl`.
|
||||
|
||||
## 51. No default credential profile except one named "default"
|
||||
- **Do:** `storebound accounts default <profile>` (`--clear`). Order: env > the project's named profile > the
|
||||
project's own keys > this default > the only profile.
|
||||
- **Guard:** `defaultProfile`, `tests/machine.test.ts`.
|
||||
|
||||
## 52. A live app with no local project is missing from the desktop
|
||||
- **Do:** `storebound apps add --store apple --app-id <id> --account <profile>`; the desktop shows it read-only
|
||||
("Store only": status, reviews, analytics). `apps remove apple:<id>` takes it out.
|
||||
- **Guard:** `storeApps` in `~/.storebound/projects.json` (`cli/lib/registry.ts`, `registry.rs`), the boot check.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Source for `storebound apple listing`. One `## <ASC locale>` section per locale (the first word is the locale code:
|
||||
`en-US`, `tr`, `de-DE`, `ja`, `zh-Hans`, `ar-SA`, ...), one fenced block per `### <field>`, field names exactly:
|
||||
`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 bytes, comma
|
||||
`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 characters, comma
|
||||
separated, no spaces after commas, do not repeat the app name), `description` (4000), `whatsNew` (optional; not allowed
|
||||
on the first version). Shared values (privacy policy, support and marketing URL) come from `storebound.config.ts`.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Source for `storebound apple listing`. One `## <ASC locale>` section per locale (the first word is the locale code:
|
||||
`en-US`, `tr`, `de-DE`, `ja`, `zh-Hans`, `ar-SA`, ...), one fenced block per `### <field>`, field names exactly:
|
||||
`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 bytes, comma
|
||||
`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 characters, comma
|
||||
separated, no spaces after commas, do not repeat the app name), `description` (4000), `whatsNew` (optional; not allowed
|
||||
on the first version). Shared values (privacy policy, support and marketing URL) come from `storebound.config.ts`.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Source for `storebound apple listing`. One `## <ASC locale>` section per locale (the first word is the locale code:
|
||||
`en-US`, `tr`, `de-DE`, `ja`, `zh-Hans`, `ar-SA`, ...), one fenced block per `### <field>`, field names exactly:
|
||||
`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 bytes, comma
|
||||
`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 characters, comma
|
||||
separated, no spaces after commas, do not repeat the app name), `description` (4000), `whatsNew` (optional; not allowed
|
||||
on the first version). Shared values (privacy policy, support and marketing URL) come from `storebound.config.ts`.
|
||||
|
||||
|
|
|
|||
|
|
@ -153,20 +153,21 @@ describe('candidates and the 100-byte field', () => {
|
|||
expect(c.find(x => x.term === 'shopping')).toMatchObject({ popularity: 64, popularityFrom: 'exact', sources: ['competitors', 'apple-ads'] });
|
||||
expect(c.find(x => x.term === 'aisle')!.popularity).toBeNull();
|
||||
});
|
||||
test('fit: <= 100 bytes, current terms kept, indexed and duplicate words dropped', () => {
|
||||
test('fit: <= 100 characters, current terms kept, indexed and duplicate words dropped', () => {
|
||||
const ranked = candidates({ ...base, ads: [] });
|
||||
const p = fitKeywordField({ locale: 'en-US', current: base.keywords, name: base.name, subtitle: base.subtitle, ranked });
|
||||
expect(p.kept).toEqual(['todo', 'checklist', 'family']);
|
||||
expect(p.dropped).toEqual([{ term: 'list', why: 'already in the name / subtitle (indexed)' }]);
|
||||
expect(p.added).toContain('packing');
|
||||
expect(p.after).not.toMatch(/,\s|\s,/);
|
||||
expect(p.bytes).toBeLessThanOrEqual(100);
|
||||
expect(p.chars).toBeLessThanOrEqual(100);
|
||||
expect(new Set(p.after.split(',')).size).toBe(p.after.split(',').length);
|
||||
// bytes, not characters: Turkish letters take two
|
||||
// characters, not bytes: Turkish letters take two bytes and still count one (pitfall 43)
|
||||
const many = Array.from({ length: 30 }, (_, i) => ({ term: `şğü${i}`, kind: 'word' as const, sources: ['description' as const], ours: 2, relevance: 1, inNames: 0, ofApps: 0, popularity: null, popularityFrom: null, status: 'candidate' as const }));
|
||||
const t = fitKeywordField({ locale: 'tr', current: '', name: 'X', subtitle: '', ranked: many });
|
||||
expect(t.bytes).toBeLessThanOrEqual(100);
|
||||
expect(Buffer.byteLength(t.after)).toBe(t.bytes);
|
||||
expect(t.chars).toBeLessThanOrEqual(100);
|
||||
expect([...t.after].length).toBe(t.chars);
|
||||
expect(Buffer.byteLength(t.after)).toBeGreaterThan(t.chars);
|
||||
expect(fitKeywordField({ locale: 'en-US', current: 'todo', name: 'X', subtitle: '', ranked, fresh: true }).kept).toEqual([]);
|
||||
});
|
||||
test('write: keywords block replaced, draft line above it, found again, then removable', () => {
|
||||
|
|
@ -202,7 +203,7 @@ describe('audit parts', () => {
|
|||
expect(u.stuffed).toContain('grocery');
|
||||
});
|
||||
test('audit: next 3 actions, PPO cannot test the subtitle, Play experiments are a guide', () => {
|
||||
const a = buildAudit({ today: '2026-10-12', storefront: 'US', findings: [{ level: 'warn', where: 'apple en-US', msg: 'keywords: 40/100 bytes used; fill at least 90 (60 bytes free)' }],
|
||||
const a = buildAudit({ today: '2026-10-12', storefront: 'US', findings: [{ level: 'warn', where: 'apple en-US', msg: 'keywords: 40/100 characters used; fill at least 90 (60 characters free)' }],
|
||||
drafts: [], appName: 'Example Notes', ranks: [], competitors: null, analytics: [], variants: ['b'],
|
||||
apple: { 'en-US': { subtitle: 'x', keywords: 'a' } }, play: [{ lang: 'en-US', title: 'T', short: 'Short', full: 'Full text.', whatsNew: '' }] });
|
||||
expect(a.next).toHaveLength(3);
|
||||
|
|
@ -242,7 +243,7 @@ describe('commands', () => {
|
|||
const p = project(BASE, files);
|
||||
mockFetch([]);
|
||||
let r = await capture(() => aso(p.ctx(['suggest', '--locale', 'en-US', '--json'])));
|
||||
const j = jsonOut(r.out) as { written: boolean; results: { ads: string; proposal: { before: string; after: string; bytes: number }; candidates: { popularity: number | null }[] }[] };
|
||||
const j = jsonOut(r.out) as { written: boolean; results: { ads: string; proposal: { before: string; after: string; chars: number }; candidates: { popularity: number | null }[] }[] };
|
||||
expect(j.written).toBe(false);
|
||||
expect(j.results[0]!.ads).toMatch(/no Apple Ads credentials.*popularity unknown/);
|
||||
expect(j.results[0]!.candidates.every(c => c.popularity === null)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ describe('rules', () => {
|
|||
expect(long.text.length).toBeLessThanOrEqual(100);
|
||||
expect(long.text.endsWith(',')).toBe(false);
|
||||
expect(long.dropped.at(-1)!.why).toBe('over 100');
|
||||
// multi-byte: 100 bytes, not characters
|
||||
expect(Buffer.byteLength(normalizeKeywords(Array.from({ length: 20 }, (_, i) => `日本語${i}`).join(',')).text)).toBeLessThanOrEqual(100);
|
||||
// multi-byte: 100 characters, not bytes (App Store Connect counts characters; pitfall 43)
|
||||
const ja = normalizeKeywords(Array.from({ length: 30 }, (_, i) => `日本語${i}`).join(',')).text;
|
||||
expect([...ja].length).toBeLessThanOrEqual(100);
|
||||
expect(Buffer.byteLength(ja)).toBeGreaterThan(100);
|
||||
});
|
||||
test('limits per store field, keyword rules, untranslated and identical text', () => {
|
||||
const c = checkFields('apple', { subtitle: 'x'.repeat(31), keywords: 'a, b,a', description: 'Same long description text' }, { description: 'Same long description text' }, 'draft');
|
||||
|
|
@ -98,11 +100,21 @@ describe('listing commands', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('listing check counts App Store keywords in UTF-8 bytes, the other fields in characters', () => {
|
||||
test('listing check counts App Store keywords in characters, like App Store Connect (pitfall 43)', () => {
|
||||
const ru = 'игра,бег,прыжки,монеты,уровни,рекорд,аркада,быстро'; // 50 characters, 93 bytes
|
||||
const kw = checkFields('apple', { keywords: ru, subtitle: 'Быстрая аркада' }).find(c => c.field === 'keywords')!;
|
||||
expect(kw).toMatchObject({ chars: 93, unit: 'bytes', problems: [] });
|
||||
expect(checkFields('apple', { keywords: ru + ',звук' }).find(c => c.field === 'keywords')!.problems).toContain('102 > 100 bytes');
|
||||
expect(kw).toMatchObject({ chars: 50, unit: 'chars', problems: [] });
|
||||
// live shape: a Thai field of 95 characters / 265 bytes, READY_FOR_SALE on the App Store
|
||||
const th = [...'ปลา,ตกปลา,เกมปลา,ทะเล,มหาสมุทร,ตู้ปลา,สัตว์น้ำ,ผ่อนคลาย,สะสม,ฉลาม,ปลาวาฬ,ปะการัง,ดำน้ำ,ฟาร์มปลา,เลี้ยงปลา'].slice(0, 95).join('');
|
||||
expect([...th].length).toBe(95);
|
||||
expect(Buffer.byteLength(th)).toBeGreaterThan(100);
|
||||
expect(checkFields('apple', { keywords: th }).find(c => c.field === 'keywords')!.problems).toEqual([]);
|
||||
// Turkish 100 characters / 105 bytes (live) passes; 101 characters does not
|
||||
const tr = 'balık,akvaryum,okyanus,deniz,köpekbalığı,mercan,dalış,sakin,rahatlatıcı,koleksiyon,çiftlik,yüzme,su'.slice(0, 100).padEnd(100, 'a');
|
||||
expect([...tr].length).toBe(100);
|
||||
expect(Buffer.byteLength(tr)).toBeGreaterThan(100);
|
||||
expect(checkFields('apple', { keywords: tr }).find(c => c.field === 'keywords')!.problems).toEqual([]);
|
||||
expect(checkFields('apple', { keywords: tr + 'b' }).find(c => c.field === 'keywords')!.problems).toContain('101 > 100 characters');
|
||||
expect(checkFields('apple', { subtitle: 'Быстрая аркада' })[0]).toMatchObject({ chars: 14, unit: 'chars' });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { applyImport, planImport, useProfile } from '../cli/lib/accounts';
|
||||
import { activeProfile, androidSigning, ascKey, defaultProfile, playServiceAccount, reviewContact, setConfigAccount } from '../cli/lib/credentials';
|
||||
import { activeProfile, androidSigning, ascKey, defaultPointer, defaultProfile, playServiceAccount, reviewContact, setConfigAccount, setDefaultPointer } from '../cli/lib/credentials';
|
||||
import { compareVersions, consent, decide, installPlan, isRunning, question, runningCheck } from '../cli/lib/desktop-install';
|
||||
import { addFolder, folderId, parseRegistry, readRegistry, removeFolder, withFolder, withoutFolder } from '../cli/lib/registry';
|
||||
import { desktopProjectId } from '../cli/lib/workspace';
|
||||
|
|
@ -46,6 +46,43 @@ function project(base: string, name: string, o: { keyId?: string; email?: string
|
|||
}
|
||||
|
||||
describe('credentials: one machine, many projects', () => {
|
||||
test('accounts default <name>: a pointer file; env > project-named > project file > pointer > single profile', () => machine(({ creds, base }) => {
|
||||
mkdirSync(creds, { recursive: true });
|
||||
for (const n of ['teamA', 'teamB']) writeFileSync(join(creds, `${n}.json`), JSON.stringify({ ASC_KEY_ID: `${n.toUpperCase()}00000`.slice(0, 10), ASC_ISSUER_ID: 'iss', ASC_P8: PEM('x') }));
|
||||
const bare = join(base, 'bare');
|
||||
mkdirSync(bare);
|
||||
// two profiles, none named default, no pointer: no default
|
||||
expect(defaultProfile()).toBeNull();
|
||||
expect(activeProfile(bare)).toBeNull();
|
||||
expect(() => setDefaultPointer('nope')).toThrow(/no profile "nope"/);
|
||||
setDefaultPointer('teamB');
|
||||
expect(readFileSync(join(creds, 'default-profile'), 'utf8')).toBe('teamB\n');
|
||||
expect(statSync(join(creds, 'default-profile')).mode & 0o777).toBe(0o600);
|
||||
expect(defaultPointer()).toBe('teamB');
|
||||
expect(defaultProfile()).toBe('teamB');
|
||||
expect(activeProfile(bare)).toEqual({ name: 'teamB', from: 'default' });
|
||||
expect(ascKey(bare).keyId).toBe('TEAMB00000');
|
||||
// a pointer beats a profile named "default" and the single-profile rule
|
||||
writeFileSync(join(creds, 'default.json'), JSON.stringify({ ASC_KEY_ID: 'DEFAULT000', ASC_ISSUER_ID: 'iss', ASC_P8: PEM('x') }));
|
||||
expect(defaultProfile()).toBe('teamB');
|
||||
// the project's own keys beat the pointer; a profile the project names beats both; env beats all
|
||||
const a = project(base, 'Star Dash');
|
||||
expect(ascKey(a).keyId).toBe('ABCDE12345');
|
||||
const file = join(a, '.storebound', 'credentials.json');
|
||||
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, 'utf8')), account: 'teamA' }));
|
||||
expect(ascKey(a).keyId).toBe('TEAMA00000');
|
||||
process.env.ASC_KEY_ID = 'ENV0000000';
|
||||
expect(ascKey(a).keyId).toBe('ENV0000000');
|
||||
delete process.env.ASC_KEY_ID;
|
||||
// a pointer to a profile that is gone falls back to the old rule
|
||||
writeFileSync(join(creds, 'default-profile'), 'gone\n');
|
||||
expect(defaultProfile()).toBe('default');
|
||||
setDefaultPointer(null);
|
||||
expect(existsSync(join(creds, 'default-profile'))).toBe(false);
|
||||
// the pointer file is not a profile
|
||||
expect(readdirSync(creds).filter(n => n.endsWith('.json')).sort()).toEqual(['default.json', 'teamA.json', 'teamB.json']);
|
||||
}));
|
||||
|
||||
test('project-only values from the project, store keys from the profile, env over everything', () => machine(({ creds, base }) => {
|
||||
const a = project(base, 'Star Dash');
|
||||
// the project's own keys work without any profile
|
||||
|
|
|
|||
272
tests/onboard.test.ts
Normal file
272
tests/onboard.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// Onboarding a live app without owner questions for what the stores and the code prove (storebound onboard): the live
|
||||
// catalog with its USD / base prices (catalog diff clean afterwards), contact and URLs, the App Review contact and demo
|
||||
// account (into .storebound/credentials.json, never printed), sign-in for review, the account deletion page; plus the
|
||||
// Play Reporting API fallback of `apps list` and store-only app entries. Recorded shapes; every id is a placeholder.
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { catalogDiff, type StoreItem, wantedCatalog } from '../cli/lib/catalog';
|
||||
import { loadConfig, type ShipConfig } from '../cli/lib/config';
|
||||
import { contactProposal, deletionLinks, findDeletionUrl, importCatalog, isTemplateValue, maskContact, signInDecision } from '../cli/lib/onboard';
|
||||
import { toTs } from '../cli/lib/scan/config-edit';
|
||||
import { run as onboard } from '../cli/onboard';
|
||||
import { run as catalog } from '../cli/catalog';
|
||||
import { parseArgs } from '../cli/lib/args';
|
||||
import type { Ctx } from '../cli/lib/ctx';
|
||||
import { ascEnv, BASE, capture, type Call, json, mockFetch, notFound, playEnv, project, restore } from './helpers/store-mock';
|
||||
|
||||
afterEach(restore);
|
||||
|
||||
const PRO = 'com.example.notes.pro', TIPS = 'com.example.notes.tips', PLUS = 'com.example.notes.plus_monthly';
|
||||
const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 200 } } });
|
||||
const price = (id: string, territory: string, point: string) => ({ type: 'inAppPurchasePrices', id, attributes: { startDate: null }, relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: point } }, territory: { data: { type: 'territories', id: territory } } } });
|
||||
const point = (id: string, customerPrice: string) => ({ type: 'inAppPurchasePricePoints', id, attributes: { customerPrice, proceeds: '0' } });
|
||||
const terr = (id: string, currency: string) => ({ type: 'territories', id, attributes: { currency } });
|
||||
const money = (currencyCode: string, units: string, nanos = 0) => ({ currencyCode, units, nanos });
|
||||
|
||||
/** App Store Connect + Google Play as they answered for a live app (sanitized): Pro has its price schedule's base
|
||||
* territory in Turkey, Tips is on the App Store only, Plus monthly is a Play-only subscription. */
|
||||
function liveStores(o: { review?: Record<string, unknown> } = {}): Call[] {
|
||||
const P = '/androidpublisher/v3/applications/com.example.notes';
|
||||
return mockFetch([
|
||||
// ---- App Store Connect: catalog
|
||||
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([
|
||||
{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, name: 'Pro', inAppPurchaseType: 'NON_CONSUMABLE', state: 'APPROVED' } },
|
||||
{ type: 'inAppPurchases', id: 'i-tips', attributes: { productId: TIPS, name: 'Tip', inAppPurchaseType: 'CONSUMABLE', state: 'APPROVED' } }]) },
|
||||
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([
|
||||
{ type: 'inAppPurchaseLocalizations', id: 'l1', attributes: { locale: 'en-US', name: 'Notes Pro', description: 'Unlimited notebooks' } },
|
||||
{ type: 'inAppPurchaseLocalizations', id: 'l2', attributes: { locale: 'tr', name: 'Notlar Pro', description: 'Sınırsız defter' } }]) },
|
||||
{ match: /^\/v2\/inAppPurchases\/i-tips\/inAppPurchaseLocalizations/, answer: page([{ type: 'inAppPurchaseLocalizations', id: 'l3', attributes: { locale: 'en-US', name: 'Small tip', description: 'Thanks!' } }]) },
|
||||
{ match: /^\/v2\/inAppPurchases\/i-(pro|tips)\/iapPriceSchedule$/, answer: c => ({ data: { type: 'inAppPurchasePriceSchedules', id: c.path.split('/')[3] }, links: {} }) },
|
||||
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-pro\/manualPrices/, answer: page([price('m1', 'TUR', 'pp-tr')], [point('pp-tr', '99.99'), terr('TUR', 'TRY')]) },
|
||||
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-pro\/automaticPrices/, answer: page([price('a1', 'USA', 'pp-us'), price('a2', 'DEU', 'pp-de')], [point('pp-us', '2.99'), point('pp-de', '2.99'), terr('USA', 'USD'), terr('DEU', 'EUR')]) },
|
||||
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-pro\/baseTerritory/, answer: { data: terr('TUR', 'TRY') } },
|
||||
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-tips\/manualPrices/, answer: page([price('m2', 'USA', 'pp-us1')], [point('pp-us1', '0.99'), terr('USA', 'USD')]) },
|
||||
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-tips\/automaticPrices/, answer: page([]) },
|
||||
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-tips\/baseTerritory/, answer: { data: terr('USA', 'USD') } },
|
||||
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
||||
// ---- App Store Connect: app details and review detail
|
||||
{ match: /^\/v1\/apps\/6400000101$/, answer: { data: { type: 'apps', id: '6400000101', attributes: { bundleId: 'com.example.notes', primaryLocale: 'en-US' } } } },
|
||||
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page([{ type: 'appStoreVersions', id: 'v1', attributes: { versionString: '2.3.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION' } }]) },
|
||||
{ match: /^\/v1\/appStoreVersions\/v1\/appStoreVersionLocalizations/, answer: page([{ type: 'appStoreVersionLocalizations', id: 'vl1', attributes: { locale: 'en-US', supportUrl: 'https://notes.example.org/help', marketingUrl: 'https://notes.example.org' } }]) },
|
||||
{ match: /^\/v1\/appStoreVersions\/v1\/appStoreReviewDetail/, answer: { data: { type: 'appStoreReviewDetails', id: 'rd1', attributes: { contactFirstName: 'Ada', contactLastName: 'Example', contactPhone: '+1 555 0100 77', contactEmail: 'review@example.org',
|
||||
demoAccountName: 'demo-reviewer', demoAccountPassword: 'demo-secret-sentinel', demoAccountRequired: true, notes: 'Sign in with the demo account.', ...o.review } } } },
|
||||
{ match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai1', attributes: { state: 'READY_FOR_DISTRIBUTION' } }]) },
|
||||
{ match: /^\/v1\/appInfos\/ai1\/appInfoLocalizations/, answer: page([{ type: 'appInfoLocalizations', id: 'ail1', attributes: { locale: 'en-US', name: 'Example Notes', privacyPolicyUrl: 'https://notes.example.org/privacy' } }]) },
|
||||
// ---- Google Play
|
||||
{ match: new RegExp(`^${P}/oneTimeProducts\\?pageSize=100`), answer: { oneTimeProducts: [{ productId: PRO }] } },
|
||||
{ match: new RegExp(`^${P}/oneTimeProducts/${PRO.replace(/\./g, '\\.')}$`), answer: { productId: PRO, listings: [{ languageCode: 'en-US', title: 'Notes Pro', description: 'Unlimited notebooks' }],
|
||||
purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', regionalPricingAndAvailabilityConfigs: [{ regionCode: 'US', price: money('USD', '2', 990000000), availability: 'AVAILABLE' }, { regionCode: 'TR', price: money('TRY', '99', 990000000), availability: 'AVAILABLE' }],
|
||||
newRegionsConfig: { usdPrice: money('USD', '2', 990000000), eurPrice: money('EUR', '2', 990000000), availability: 'AVAILABLE' } }] } },
|
||||
{ match: new RegExp(`^${P}/subscriptions\\?pageSize=100`), answer: { subscriptions: [{ productId: PLUS, listings: [{ languageCode: 'en-US', title: 'Plus', description: 'Sync across devices', benefits: ['Sync'] }],
|
||||
basePlans: [{ basePlanId: 'monthly', state: 'ACTIVE', autoRenewingBasePlanType: { billingPeriodDuration: 'P1M' }, regionalConfigs: [{ regionCode: 'US', price: money('USD', '4', 990000000) }], otherRegionsConfig: { usdPrice: money('USD', '4', 990000000) } }] }] } },
|
||||
{ match: new RegExp(`^${P}/subscriptions/${PLUS.replace(/\./g, '\\.')}/basePlans/monthly/offers`), answer: { subscriptionOffers: [] } },
|
||||
{ method: 'POST', match: new RegExp(`^${P}/edits$`), answer: { id: 'e1' } },
|
||||
{ match: new RegExp(`^${P}/edits/e1/details$`), answer: { defaultLanguage: 'en-US', contactEmail: 'hello@example.org', contactWebsite: 'https://notes.example.org', contactPhone: '+1 555 0100 88' } },
|
||||
{ method: 'DELETE', match: new RegExp(`^${P}/edits/e1$`), answer: {} },
|
||||
// ---- the app's own website
|
||||
{ match: /^\/delete-account$/, answer: () => new Response('<html><body><h1>Delete your account</h1><form>email</form></body></html>', { status: 200, headers: { 'content-type': 'text/html' } }) },
|
||||
]);
|
||||
}
|
||||
|
||||
/** A project whose storebound.config.ts holds `config` (as init + listing pull leave it) and code with a sign-in. */
|
||||
function liveProject(config: ShipConfig = BASE) {
|
||||
const p = project(config, { 'src/auth.ts': 'export const login = (auth, email, password) => signInWithEmailAndPassword(auth, email, password);\n', 'package.json': '{"name":"notes","dependencies":{}}' });
|
||||
writeFileSync(p.loaded.path, `import type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = ${toTs(config)};\n\nexport default config;\n`);
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('importCatalog (pure)', () => {
|
||||
const apple: StoreItem[] = [
|
||||
{ id: PRO, kind: 'non_consumable', locales: ['en-US', 'tr'], base: { region: 'TR', currency: 'TRY', price: 99.99 }, prices: [{ region: 'TR', currency: 'TRY', price: 99.99 }, { region: 'US', currency: 'USD', price: 2.99 }] },
|
||||
{ id: TIPS, kind: 'consumable', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 0.99 }] },
|
||||
];
|
||||
const play: StoreItem[] = [
|
||||
{ id: PRO, kind: 'one_time', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 2.99 }, { region: 'TR', currency: 'TRY', price: 99.99 }] },
|
||||
{ id: PLUS, kind: 'subscription', period: 'P1M', locales: ['en-US'], prices: [{ region: 'DE', currency: 'EUR', price: 4.99 }], usd: 4.99 },
|
||||
];
|
||||
const texts = { apple: { [PRO]: { 'en-US': { title: 'Notes Pro', description: 'Unlimited notebooks' } }, [TIPS]: { 'en-US': { title: 'Small tip', description: 'Thanks!' } } },
|
||||
play: { [PRO]: { 'en-US': { title: 'Notes Pro', description: 'Unlimited notebooks' } }, [PLUS]: { 'en-US': { title: 'Plus', description: 'Sync' }, 'xx-XX': { title: 'X', description: 'Y' } } } };
|
||||
test('USD from the US price, Apple base territory as an override, one-store items marked, catalog diff clean', () => {
|
||||
const r = importCatalog(BASE, { apple, play, appleTexts: texts.apple, playTexts: texts.play });
|
||||
expect(r.skipped).toEqual([]);
|
||||
expect(r.products.find(p => p.id === PRO)).toMatchObject({ type: 'non_consumable', usd: 2.99, overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] });
|
||||
expect(r.products.find(p => p.id === PRO)!.platforms).toBeUndefined();
|
||||
expect(r.products.find(p => p.id === TIPS)).toMatchObject({ type: 'consumable', usd: 0.99, platforms: ['ios'] });
|
||||
// a Play subscription without a US region: the store's own USD price for other regions
|
||||
expect(r.subscriptions[0]!.subscriptions[0]).toMatchObject({ id: PLUS, period: 'P1M', usd: 4.99, platforms: ['android'] });
|
||||
expect(r.notes.some(n => n.msg.includes('xx-XX is not in languages'))).toBe(true);
|
||||
const cfg = { ...BASE, products: r.products, subscriptions: r.subscriptions };
|
||||
const { rows } = catalogDiff(wantedCatalog(cfg), { apple, play });
|
||||
expect(rows.filter(x => x.level === 'error')).toEqual([]);
|
||||
});
|
||||
test('no USD price on any store: skipped with the reason, never a made-up price', () => {
|
||||
const r = importCatalog(BASE, { apple: [{ id: PRO, kind: 'non_consumable', locales: [], base: { region: 'TR', currency: 'TRY', price: 99.99 }, prices: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }], play: null, appleTexts: texts.apple });
|
||||
expect(r.products).toEqual([]);
|
||||
expect(r.skipped[0]!.why).toContain('no USD price on the App Store (base TR 99.99 TRY)');
|
||||
});
|
||||
test('the config\'s own fields stay; a Play-only one-time product is non_consumable with a note', () => {
|
||||
const cfg = { ...BASE, products: [{ id: PRO, type: 'non_consumable' as const, usd: 1.99, listings: { en: { title: 'Old', description: 'Old' } }, reviewScreenshot: 'store/iap-review/pro.png' }] };
|
||||
const r = importCatalog(cfg, { apple: null, play: [play[0]!, { id: 'com.example.notes.gems', kind: 'one_time', locales: [], prices: [{ region: 'US', currency: 'USD', price: 1.99 }] }], playTexts: { 'com.example.notes.gems': { 'en-US': { title: 'Gems', description: '100 gems' } }, ...texts.play } });
|
||||
expect(r.products.find(p => p.id === PRO)).toMatchObject({ usd: 2.99, reviewScreenshot: 'store/iap-review/pro.png', listings: { en: { title: 'Notes Pro' } } });
|
||||
expect(r.updated).toContain(PRO);
|
||||
expect(r.notes.filter(n => n.id === 'com.example.notes.gems').map(n => n.msg).join(' | ')).toContain('does not say whether a one-time product is consumable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contact, sign-in, deletion page (pure)', () => {
|
||||
test('store contact replaces template values only', () => {
|
||||
const c = contactProposal({ ...BASE.app, supportUrl: 'https://help.example.net' }, { play: { contactEmail: 'hello@example.org', contactWebsite: 'notes.example.org' }, apple: { supportUrl: 'https://notes.example.org/help', privacyPolicyUrl: 'https://notes.example.org/privacy' } });
|
||||
expect(c.filter(x => x.replace).map(x => `${x.field}=${x.to}`)).toEqual(['contactEmail=hello@example.org', 'website=https://notes.example.org', 'privacyPolicyUrl=https://notes.example.org/privacy']);
|
||||
expect(c.find(x => x.field === 'supportUrl')).toMatchObject({ replace: false, from: 'https://help.example.net' });
|
||||
expect(isTemplateValue('support@example.com')).toBe(true);
|
||||
expect(isTemplateValue('https://example.com/privacy')).toBe(true);
|
||||
expect(isTemplateValue('https://notes.example.org')).toBe(false);
|
||||
expect(maskContact({ firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100 77', email: 'review@example.org' })).toBe('A. E. r***@example.org, phone ***77');
|
||||
});
|
||||
test('sign-in: Apple review detail first, then the code, never a default yes', () => {
|
||||
expect(signInDecision({ auth: [], apple: null })).toMatchObject({ signInRequired: false, question: null });
|
||||
expect(signInDecision({ auth: ['src/auth.ts:1'], apple: null })).toMatchObject({ signInRequired: null });
|
||||
expect(signInDecision({ auth: ['src/auth.ts:1'], apple: null }).question).toContain('without signing in');
|
||||
expect(signInDecision({ auth: ['src/auth.ts:1'], apple: { demoAccountRequired: false } })).toMatchObject({ signInRequired: false, question: null });
|
||||
const d = signInDecision({ auth: [], apple: { demoAccountRequired: true, demoAccountName: 'demo', demoAccountPassword: 'pw' } });
|
||||
expect(d).toMatchObject({ signInRequired: true, demoAccount: { username: 'demo', password: 'pw' } });
|
||||
expect(d.because).not.toContain('pw');
|
||||
});
|
||||
test('deletion page: candidates, then privacy policy links; not found stays a gap', async () => {
|
||||
const pages: Record<string, { status: number; text: string }> = {
|
||||
'https://notes.example.org/privacy': { status: 200, text: '<p>Privacy</p><a href="/account/remove">Request deletion of your data</a>' },
|
||||
'https://notes.example.org/account/remove': { status: 200, text: '<h1>Delete account</h1>' },
|
||||
};
|
||||
const get = async (u: string) => pages[u] ?? { status: 404, text: 'not found' };
|
||||
const r = await findDeletionUrl({ website: 'https://notes.example.org', privacyUrl: 'https://notes.example.org/privacy' }, get);
|
||||
expect(r).toMatchObject({ url: 'https://notes.example.org/account/remove', how: 'linked from the privacy policy as account / data deletion' });
|
||||
expect(r.tried.slice(0, 2)).toEqual(['https://notes.example.org/delete-account', 'https://notes.example.org/account/delete']);
|
||||
const anchor = await findDeletionUrl({ privacyUrl: 'https://notes.example.org/p' }, async u => (u === 'https://notes.example.org/p' ? { status: 200, text: '<h2 id="delete-your-data">Deleting</h2>' } : { status: 404, text: '' }));
|
||||
expect(anchor.url).toBe('https://notes.example.org/p#delete-your-data');
|
||||
const none = await findDeletionUrl({ website: 'https://notes.example.org' }, async () => ({ status: 404, text: '' }));
|
||||
expect(none).toMatchObject({ url: null, how: 'not found: a real gap, Play requires a web page to request account deletion (account-deletion skill)' });
|
||||
expect(await findDeletionUrl({ website: 'https://example.com' }, async () => ({ status: 200, text: 'delete' }))).toMatchObject({ url: null, tried: [] });
|
||||
expect(deletionLinks('<a href="https://x.example.org/hesap">Hesabını sil</a><a href="/about">About</a>', 'https://x.example.org/')).toEqual(['https://x.example.org/hesap']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storebound onboard (recorded live shapes)', () => {
|
||||
test('--write: catalog with prices, contact, review contact and demo account (never printed), sign-in, deletion page; catalog diff clean', async () => {
|
||||
ascEnv(); playEnv();
|
||||
const p = liveProject();
|
||||
const calls = liveStores();
|
||||
const r = await capture(() => onboard(p.ctx(['--write'], ['write'])));
|
||||
const all = r.out + r.err;
|
||||
// personal data and the demo password never reach the output
|
||||
expect(all).not.toContain('demo-secret-sentinel');
|
||||
expect(all).not.toContain('demo-reviewer');
|
||||
expect(all).not.toContain('555 0100');
|
||||
expect(all).not.toContain('review@example.org');
|
||||
expect(all).toContain('r***@example.org, phone ***77');
|
||||
expect(all).toContain('sign-in for review: required (App Store review detail: demo account required (a demo account is set there))');
|
||||
expect(all).toContain('account deletion page: https://notes.example.org/delete-account');
|
||||
// read-only on the stores: only the throwaway Play edit is created and deleted
|
||||
expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path.replace(/^.*\/applications\/[^/]+/, '')}`)).toEqual(['POST /edits', 'DELETE /edits/e1']);
|
||||
const creds = JSON.parse(readFileSync(join(p.root, '.storebound', 'credentials.json'), 'utf8'));
|
||||
expect(creds.reviewDemoAccount).toEqual({ username: 'demo-reviewer', password: 'demo-secret-sentinel' });
|
||||
expect(creds.reviewContact).toEqual({ firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100 77', email: 'review@example.org' });
|
||||
const loaded = await loadConfig(p.loaded.path);
|
||||
const c = loaded.config;
|
||||
expect(c.app).toMatchObject({ contactEmail: 'hello@example.org', website: 'https://notes.example.org', supportUrl: 'https://notes.example.org/help', privacyPolicyUrl: 'https://notes.example.org/privacy' });
|
||||
expect(c.reviewAccess).toEqual({ signInRequired: true });
|
||||
expect(c.accounts).toEqual({ enabled: true, deletionUrl: 'https://notes.example.org/delete-account' });
|
||||
expect(c.products!.map(x => `${x.id} ${x.type} ${x.usd} ${(x.platforms ?? []).join()} ${(x.overrides ?? []).map(o => `${o.region}:${o.price}`).join()}`)).toEqual([
|
||||
`${PRO} non_consumable 2.99 TR:99.99`, `${TIPS} consumable 0.99 ios `]);
|
||||
expect(c.subscriptions![0]!.subscriptions[0]).toMatchObject({ id: PLUS, period: 'P1M', usd: 4.99, platforms: ['android'], listings: { en: { title: 'Plus', benefits: ['Sync'] } } });
|
||||
// the same stores, read by catalog diff against the written config: no mismatch
|
||||
liveStores();
|
||||
const ctx: Ctx = { loaded: { ...loaded }, args: parseArgs(['diff', '--json'], ['json']) };
|
||||
const d = await capture(() => catalog(ctx));
|
||||
expect(d.exit).toBeNull();
|
||||
expect(d.err + d.out).toContain('"errors": 0');
|
||||
});
|
||||
test('without --write nothing is written; sign-in unproven becomes the one owner question', async () => {
|
||||
ascEnv(); playEnv();
|
||||
const p = liveProject();
|
||||
liveStores({ review: { demoAccountRequired: null, demoAccountName: null, demoAccountPassword: null } });
|
||||
const before = readFileSync(p.loaded.path, 'utf8');
|
||||
const r = await capture(() => onboard(p.ctx([])));
|
||||
expect(readFileSync(p.loaded.path, 'utf8')).toBe(before);
|
||||
expect(r.out).toContain('owner questions');
|
||||
expect(r.out).toContain('Can a reviewer use every main feature without signing in?');
|
||||
expect(r.err + r.out).toContain('(run with --write)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Play Reporting API off', () => {
|
||||
const SERVICE_DISABLED = JSON.stringify({ error: { code: 403, message: 'Google Play Developer Reporting API has not been used in project 123456789000 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=123456789000 then retry.', status: 'PERMISSION_DENIED',
|
||||
details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: 'SERVICE_DISABLED', domain: 'googleapis.com', metadata: { service: 'playdeveloperreporting.googleapis.com', consumer: 'projects/123456789000' } }] } });
|
||||
test('the exact link for the project; apps list falls back to the folder list and store-only entries', async () => {
|
||||
const { reportingEnableUrl, playCheck } = await import('../cli/lib/store-apps');
|
||||
expect(reportingEnableUrl(SERVICE_DISABLED, null)).toEqual({ project: '123456789000', url: 'https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000' });
|
||||
expect(reportingEnableUrl('403', ['sb', 'example-project-01.iam.gserviceaccount.com'].join('@')).project).toBe('example-project-01');
|
||||
const c = playCheck('sb@example.com', { error: new Error(`GET /apps:search -> 403 ${SERVICE_DISABLED}`) });
|
||||
expect(c.status).toBe('api-disabled');
|
||||
expect(c.fix).toContain('https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000');
|
||||
const { healthFindings } = await import('../cli/lib/health');
|
||||
expect(healthFindings({ date: '2026-10-13', play: { error: `-> 403 ${SERVICE_DISABLED}` } })[0]!.fix).toContain('?project=123456789000');
|
||||
|
||||
playEnv();
|
||||
const saved = process.env.STOREBOUND_REGISTRY;
|
||||
const base = mkdtempSync(join(tmpdir(), 'sb-reg-'));
|
||||
process.env.STOREBOUND_REGISTRY = join(base, 'projects.json');
|
||||
try {
|
||||
const folder = liveProject();
|
||||
writeFileSync(process.env.STOREBOUND_REGISTRY, JSON.stringify({ version: 1, projects: [{ id: 'aaaaaaaaaaaaaaaa', path: folder.root, name: 'Example Notes', added: '' }],
|
||||
storeApps: [{ store: 'play', appId: 'com.example.legacy', name: 'Legacy', account: 'teamA' }, { store: 'apple', appId: '6400000999', name: 'iOS only' }] }));
|
||||
mockFetch([{ match: /^\/v1beta1\/apps:search/, answer: () => new Response(SERVICE_DISABLED, { status: 403, headers: { 'content-type': 'application/json' } }) }]);
|
||||
const { listApps } = await import('../cli/apps');
|
||||
const r = await listApps('play', base, false);
|
||||
expect(r.apps.map(a => `${a.id} ${a.name}`)).toEqual(['com.example.notes Example Notes', 'com.example.legacy Legacy']);
|
||||
expect(r.warnings[0]).toContain('Reporting API is off');
|
||||
expect(r.warnings[0]).toContain('?project=123456789000');
|
||||
expect(r.warnings[1]).toContain('listed 2 Play app(s)');
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('store-only apps in the machine list', () => {
|
||||
test('apps add --store apple --app-id --account: an entry with no folder; folders keep working; remove by store:id', async () => {
|
||||
const { readRegistry, withStoreApp, withoutStoreApp, parseRegistry, storeAppId, withFolder } = await import('../cli/lib/registry');
|
||||
const saved = process.env.STOREBOUND_REGISTRY;
|
||||
const base = mkdtempSync(join(tmpdir(), 'sb-reg-'));
|
||||
process.env.STOREBOUND_REGISTRY = join(base, 'projects.json');
|
||||
try {
|
||||
const { run: apps } = await import('../cli/apps');
|
||||
const r = await capture(() => apps(parseArgs(['add', '--store', 'apple', '--app-id', '6400000999', '--account', 'teamA', '--name', 'Demo App'], [])));
|
||||
expect(r.err + r.out).toContain('added apple app 6400000999 (Demo App) with no local project, profile teamA');
|
||||
const reg = readRegistry();
|
||||
expect(reg.storeApps).toEqual([{ id: storeAppId('apple', '6400000999'), store: 'apple', appId: '6400000999', account: 'teamA', name: 'Demo App', bundleId: null, added: reg.storeApps![0]!.added }]);
|
||||
expect(reg.projects).toEqual([]);
|
||||
// a folder added later keeps the store-only entry
|
||||
const f = withFolder(reg, '/w/Star Dash', undefined, 't').registry;
|
||||
expect(f.storeApps).toHaveLength(1);
|
||||
expect(parseRegistry(JSON.stringify(f)).storeApps).toHaveLength(1);
|
||||
const again = await capture(() => apps(parseArgs(['add', '--store', 'apple', '--app-id', '6400000999', '--account', 'teamA', '--name', 'Demo App'], [])));
|
||||
expect(again.err + again.out).toContain('already in the list');
|
||||
const list = await capture(() => apps(parseArgs(['folders'], [])));
|
||||
expect(list.out).toContain('no local project');
|
||||
expect(() => withStoreApp(reg, { store: 'apple', appId: 'com.example.x' }, 't')).toThrow(/numeric Apple ID/);
|
||||
expect(() => withStoreApp(reg, { store: 'play', appId: '123' }, 't')).toThrow(/package name/);
|
||||
expect(withoutStoreApp(reg, 'apple:6400000999').removed?.name).toBe('Demo App');
|
||||
const rm = await capture(() => apps(parseArgs(['remove', 'apple:6400000999'], [])));
|
||||
expect(rm.err + rm.out).toContain('removed apple app 6400000999');
|
||||
expect(readRegistry().storeApps).toBeUndefined();
|
||||
expect(JSON.parse(readFileSync(process.env.STOREBOUND_REGISTRY, 'utf8'))).toEqual({ version: 1, projects: [] });
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// keep the json helper referenced for recorded answers built inline
|
||||
void json; void notFound;
|
||||
|
|
@ -60,11 +60,12 @@ describe('copy parsing', () => {
|
|||
expect(Object.keys(c)).toEqual(['en-US', 'tr']);
|
||||
expect(c['en-US']!.subtitle).toBe('Short hook under the name');
|
||||
});
|
||||
test('appstore: missing field, keyword spaces, byte limit', () => {
|
||||
test('appstore: missing field, keyword spaces, character limit (pitfall 43)', () => {
|
||||
const sec = (kw: string) => `## de-DE\n### subtitle\n\`\`\`\nA\n\`\`\`\n### promotionalText\n\`\`\`\nB\n\`\`\`\n### keywords\n\`\`\`\n${kw}\n\`\`\`\n### description\n\`\`\`\nD\n\`\`\`\n`;
|
||||
expect(parseAppStoreListing(sec('a,b'))['de-DE']!.keywords).toBe('a,b');
|
||||
expect(() => parseAppStoreListing(sec('a, b'))).toThrow('no spaces after commas');
|
||||
expect(() => parseAppStoreListing(sec('ö'.repeat(60)))).toThrow('bytes > 100');
|
||||
expect(parseAppStoreListing(sec('ö'.repeat(100)))['de-DE']!.keywords).toHaveLength(100);
|
||||
expect(() => parseAppStoreListing(sec('ö'.repeat(101)))).toThrow('keywords is 101 > 100');
|
||||
expect(() => parseAppStoreListing('## fr-FR\n### subtitle\n```\nx\n```\n')).toThrow('fr-FR: missing promotionalText');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ describe('Play account check with the real 403', () => {
|
|||
const after = new Error(`GET /apps:search?pageSize=1000 -> 403 ${errorBody(SERVICE_DISABLED_BODY, 800)}`);
|
||||
expect(playCheck('sb@example.com', { error: after }).status).toBe('api-disabled');
|
||||
});
|
||||
test('the CLI: check says api-disabled; list fails with the reason and the fix on one line', async () => {
|
||||
test('the CLI: check says api-disabled with the project\'s link; list does not fail: the finding and the local apps (pitfall 46)', async () => {
|
||||
playKey();
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
|
|
@ -110,17 +110,24 @@ describe('Play account check with the real 403', () => {
|
|||
}) as typeof fetch;
|
||||
const root = mkdtempSync(join(tmpdir(), 'sb-real-'));
|
||||
const c = await checkAccount('play', root);
|
||||
expect(c).toMatchObject({ status: 'api-disabled', principal: 'sb@example.com' });
|
||||
await expect(listApps('play', root, false)).rejects.toThrow(/-> 403 \{"error":/);
|
||||
expect(c).toMatchObject({ status: 'api-disabled', principal: 'sb@example.com', docs: 'https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000' });
|
||||
const savedReg = process.env.STOREBOUND_REGISTRY;
|
||||
process.env.STOREBOUND_REGISTRY = join(root, 'projects.json');
|
||||
const cwd = process.cwd();
|
||||
process.chdir(root);
|
||||
try {
|
||||
let msg = '';
|
||||
try { await appsRun(parseArgs(['list', '--store', 'play', '--json'], ['json', 'icons'])); } catch (e) { msg = (e as Error).message; }
|
||||
expect(msg).not.toContain('\n');
|
||||
expect(msg).toContain('Reporting API is off');
|
||||
expect(msg).toContain('Fix: Google Cloud console');
|
||||
} finally { process.chdir(cwd); }
|
||||
const r = await listApps('play', root, false);
|
||||
expect(r.apps).toEqual([]);
|
||||
expect(r.warnings[0]).not.toContain('\n');
|
||||
expect(r.warnings[0]).toContain('Reporting API is off');
|
||||
expect(r.warnings[0]).toContain('Fix: Enable "Google Play Developer Reporting API" for that project: https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000');
|
||||
let failed = false;
|
||||
try { await appsRun(parseArgs(['list', '--store', 'play', '--json'], ['json', 'icons'])); } catch { failed = true; }
|
||||
expect(failed).toBe(false);
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
if (savedReg === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = savedReg;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ describe('mcp tools', () => {
|
|||
}
|
||||
}
|
||||
// the local-write exception covers only the pull tools, and only with dry_run false
|
||||
expect(Object.keys(LOCAL_WRITE_TOOLS).sort()).toEqual(['apple_listing_pull', 'catalog_pull', 'play_listing_pull']);
|
||||
expect(Object.keys(LOCAL_WRITE_TOOLS).sort()).toEqual(['apple_listing_pull', 'catalog_pull', 'onboard', 'play_listing_pull']);
|
||||
for (const n of Object.keys(LOCAL_WRITE_TOOLS)) expect(toArgv(TOOLS.find(t => t.name === n)!, {}).argv).not.toContain('--write');
|
||||
const phased = TOOLS.find(t => t.name === 'apple_phased_release')!;
|
||||
expect(phased.props!.action!.enum).toEqual(['on', 'pause']);
|
||||
|
|
@ -148,18 +148,20 @@ describe('mcp: screenshot and video tools', () => {
|
|||
});
|
||||
|
||||
describe('aso', () => {
|
||||
test('App Store keywords count bytes, not characters (limit and fill hint)', () => {
|
||||
const msgs = (kw: string) => auditAppleKeywords('ru', { keywords: kw } as never, 'X').map(f => f.msg);
|
||||
const short = 'игра,бег,прыжки,монеты,уровни,рекорд,аркада'; // 43 characters, 80 bytes
|
||||
expect(msgs(short).some(m => m.includes('80/100 bytes') && m.includes('fill at least 90'))).toBe(true);
|
||||
const full = short + ',быстро'; // 50 characters but 93 bytes: full enough
|
||||
expect(msgs(full).some(m => m.includes('fill at least 90'))).toBe(false);
|
||||
expect(msgs(full + ',звук').some(m => m.includes('102 bytes > 100'))).toBe(true);
|
||||
test('App Store keywords count characters, not bytes (limit and fill hint; pitfall 43)', () => {
|
||||
const msgs = (kw: string) => auditAppleKeywords('pl', { keywords: kw } as never, 'X').map(f => f.msg);
|
||||
// live shape: a Polish field of 99 characters / 105 bytes, READY_FOR_SALE
|
||||
const pl = 'ryby,akwarium,ocean,morze,rekin,koralowiec,nurkowanie,relaks,kolekcja,hodowla,pływanie,łowienie,żółw';
|
||||
expect([...pl].length).toBeLessThanOrEqual(100);
|
||||
expect(Buffer.byteLength(pl)).toBeGreaterThan(100);
|
||||
expect(msgs(pl).filter(m => /> 100/.test(m))).toEqual([]);
|
||||
expect(msgs('ryby,akwarium').some(m => m.includes('13/100 characters used') && m.includes('fill at least 90'))).toBe(true);
|
||||
expect(msgs('ż'.repeat(101)).some(m => m.includes('101 characters > 100'))).toBe(true);
|
||||
});
|
||||
test('keyword field rules', () => {
|
||||
const f = auditAppleKeywords('en-US', { subtitle: 'Relaxing puzzle', keywords: 'match,puzzle,match,free game' }, 'Tile Match');
|
||||
const text = f.map(x => `${x.level}:${x.msg}`).join('\n');
|
||||
expect(text).toContain('warn:keywords: 28/100 bytes used');
|
||||
expect(text).toContain("warn:keywords: 28/100 characters used");
|
||||
expect(text).toContain('"match" listed twice');
|
||||
expect(text).toContain('repeat words from the name / subtitle (already indexed): match, puzzle');
|
||||
expect(text).toContain('filler words that rarely help: free, game');
|
||||
|
|
@ -324,6 +326,35 @@ describe('validate rules', () => {
|
|||
h.tracks = [{ track: 'production', releases: [{ status: 'halted', versionCodes: ['8'], userFraction: 0.1 }] }];
|
||||
expect(playRules(h).find(x => x.code === 'tracks')!.level).toBe('warn');
|
||||
});
|
||||
test('play: a non-default language without graphics uses the default language\'s (pitfall 45)', () => {
|
||||
// live shape: 18 listings, only the default language (tr-TR) has an icon and a feature graphic
|
||||
const s = playOk();
|
||||
const extra = ['en-US', 'de-DE', 'fr-FR', 'es-ES', 'it-IT', 'pt-BR', 'ru-RU', 'ja-JP', 'ko-KR', 'zh-CN', 'ar', 'hi-IN', 'id', 'pl-PL', 'nl-NL', 'th', 'vi'];
|
||||
s.defaultLanguage = 'tr-TR';
|
||||
s.expectedLanguages = ['tr-TR', ...extra];
|
||||
s.listings = [{ language: 'tr-TR', title: 't', shortDescription: 's', fullDescription: 'f', images: { icon: 1, featureGraphic: 1, phoneScreenshots: 4 } },
|
||||
...extra.map(language => ({ language, title: 't', shortDescription: 's', fullDescription: 'f', images: { icon: 0, featureGraphic: 0, phoneScreenshots: 4 } }))];
|
||||
const f = playRules(s);
|
||||
expect(f.filter(x => x.level === 'blocker')).toEqual([]);
|
||||
expect(f.filter(x => x.code === 'images-fallback').map(x => x.msg)).toEqual([
|
||||
"icon: 17 language(s) without their own use the default language's (tr-TR) graphics: en-US, de-DE, fr-FR, es-ES, it-IT, pt-BR ...",
|
||||
"featureGraphic: 17 language(s) without their own use the default language's (tr-TR) graphics: en-US, de-DE, fr-FR, es-ES, it-IT, pt-BR ...",
|
||||
]);
|
||||
// the default language's own missing graphics still block
|
||||
s.listings[0]!.images.featureGraphic = 0;
|
||||
expect(playRules(s).filter(x => x.level === 'blocker').map(x => x.msg)).toEqual(expect.arrayContaining(['tr-TR: featureGraphic 0/1']));
|
||||
expect(playRules(s).filter(x => x.level === 'blocker').length).toBe(18);
|
||||
});
|
||||
test('apple: a live app with only the 6.5-inch set passes; 6.9-inch is a recommendation (pitfall 44)', () => {
|
||||
const s = appleOk();
|
||||
// live shape: GET /v1/appStoreVersionLocalizations/{id}/appScreenshotSets answered one APP_IPHONE_65 set per locale
|
||||
s.locales = s.locales.map(l => ({ ...l, screenshots: { APP_IPHONE_65: 6 } }));
|
||||
const f = appleRules(s);
|
||||
expect(f.filter(x => x.level === 'blocker')).toEqual([]);
|
||||
expect(f.filter(x => x.code === 'screenshots-size').map(x => `${x.level}:${x.msg}`)[0]).toBe('warn:en-US: no APP_IPHONE_67 set; Apple accepts the APP_IPHONE_65 set (6 screenshots) and scales it for the other sizes. Recommended, not required: add APP_IPHONE_67');
|
||||
s.locales[0]!.screenshots = { APP_IPHONE_61: 3 };
|
||||
expect(appleRules(s).filter(x => x.level === 'blocker').map(x => x.msg)).toEqual(['en-US: no APP_IPHONE_67 screenshots (or APP_IPHONE_65)']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('release helpers', () => {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ class Sidecar {
|
|||
// The mocked host: what src-tauri/src/commands.rs answers for one saved state
|
||||
|
||||
interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string }
|
||||
interface HostFixture { projects_list: Project[]; accounts_list: { id: string }[]; workspace_get: { overrides: { ignored: string[] } } & Record<string, unknown> }
|
||||
interface HostFixture { projects_list: Project[]; accounts_list: { id: string }[]; workspace_get: { overrides: { ignored: string[] } } & Record<string, unknown>;
|
||||
/** the host's answer before any store read: the machine's store-only apps (projects.json storeApps) under their account */
|
||||
store_apps_cached?: { account: string; entry: unknown }[] }
|
||||
|
||||
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
||||
|
||||
|
|
@ -115,7 +117,7 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
|
|||
case 'projects_list': return projects();
|
||||
case 'accounts_list': return fx.accounts_list;
|
||||
case 'workspace_get': return fx.workspace_get;
|
||||
case 'store_apps_cached': return fx.accounts_list.map(x => ({ account: x.id, entry: null }));
|
||||
case 'store_apps_cached': return fx.store_apps_cached ?? fx.accounts_list.map(x => ({ account: x.id, entry: null }));
|
||||
case 'cache_get': {
|
||||
const st = live && STORE_READS[String(a.method)];
|
||||
return st ? { value: toolResult(String(a.method), live![st]), at: new Date(Date.now() - 5 * 60_000).toISOString() } : null;
|
||||
|
|
@ -328,6 +330,17 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
|
|||
notes.push(`Accounts: ${accts.map(a => a.profile ?? '(keychain)').join(', ')}`);
|
||||
} else problems.push('no Accounts entry in the sidebar');
|
||||
}
|
||||
// store-only apps of the machine list (projects.json storeApps): in the portfolio, opened as "Store only", no folder
|
||||
for (const e of fx.store_apps_cached ?? []) for (const app of ((e.entry as { value?: { apps?: { name: string; registered?: boolean }[] } } | null)?.value?.apps ?? []).filter(x => x.registered)) {
|
||||
const item = page.locator('nav.side .nav-app', { hasText: app.name }).first();
|
||||
if (!(await item.count())) { problems.push(`${app.name} (store-only entry): not in the sidebar`); continue; }
|
||||
await item.click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
const text = await page.locator('#content').innerText();
|
||||
await check(app.name);
|
||||
if (!/Store only/i.test(text)) problems.push(`${app.name}: not shown as "Store only" (${text.replace(/\s+/g, ' ').slice(0, 200)})`);
|
||||
else notes.push(`${app.name}: store-only entry, no local project`);
|
||||
}
|
||||
// a folder that pins another storebound version says so on its app page
|
||||
for (const p of fx.projects_list.filter(x => (x as { storeboundPin?: string }).storeboundPin && isDir(x.path))) {
|
||||
const pin = (p as { storeboundPin?: string }).storeboundPin!;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue