upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint

This commit is contained in:
CRTkafa 2026-10-24 12:10:23 +08:00
parent 26f9559e1a
commit fb1609cba7
24 changed files with 599 additions and 27 deletions

View file

@ -4,6 +4,11 @@
## Unreleased
- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch).
||||||| parent of abe0821 (upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint)
- `apple upload` / `play upload` `--pre-check "<cmd with {file}>"` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence <path.json>` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`.
- New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate "<cmd>"`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language.
- `apple game-center --locale-map en=en-GB` (config `games.localeMap`) maps a config language to the live Game Center locale; the dry run suggests it when the live Game Center has exactly one variant of the language.
- `play games`: a 403 SERVICE_DISABLED / accessNotConfigured names the Google Play Game Services Publishing API, the enable URL for the project and `console plan play-games-services`.
- Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `<next patch>-dev.<UTC commit time>` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session).
- `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `<frames>/<lang>/<size>/<name>.png` (feature: `<lang>/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`.
- `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID.

View file

@ -1,5 +1,7 @@
// Game Center through the App Store Connect API, from the `games` config section (cli/lib/game-services.ts).
// storebound apple game-center [status] [--dry] [--yes] [--json]
// storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json]
// --locale-map (config games.localeMap): config language -> the locale the live Game Center uses; the dry run suggests it
// when Game Center has exactly one variant of a language (en-GB) and the config says another (en-US).
// status: read-only (App ID capability, Game Center detail, the editable version's switch, leaderboards and
// achievements on the store vs the config, the project's entitlement). Default: the upsert. Every write is a
// find-then-create (createOnce: a conflict or an unclear answer reads the store again, never a second create) or a
@ -10,7 +12,7 @@ import { consoleLink } from '../lib/console-links';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry';
import { type GcExisting, gcFindings, type ItemPlan, planGameCenter, planRows, usesGameCenter } from '../lib/game-services';
import { type GcExisting, gcFindings, gcLocaleMap, type ItemPlan, liveGcLocales, planGameCenter, planRows, suggestLocaleMap, usesGameCenter } from '../lib/game-services';
import { done, log, warn } from '../lib/log';
import { createOnce } from '../lib/upsert';
import { type Asc, ascClient, type J } from './asc';
@ -61,21 +63,24 @@ export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
const sub = a._[0] ?? 'sync';
if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound apple game-center [status] [--dry] [--yes] [--json]');
if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json]');
if (!usesGameCenter(config)) throw new Error('games.gameCenter is not true in storebound.config.ts (game-services skill): nothing to do');
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const c = await ascClient(root, bundleId, config.apple?.appId);
const appId = await c.app();
const s = await readGameCenter(c, bundleId);
const g = config.games!;
const plan = planGameCenter(g, config.languages, s);
const localeMap = gcLocaleMap(g, a.many('locale-map'));
const plan = planGameCenter(g, config.languages, s, localeMap);
const suggest = suggestLocaleMap(config.languages, liveGcLocales(s), localeMap);
const project = gcFindings(config, readProjectEntitlements(root));
if (a.has('json')) { console.log(JSON.stringify({ ...stateSummary(s, plan), project, plan }, null, 2)); return; }
if (a.has('json')) { console.log(JSON.stringify({ ...stateSummary(s, plan), project, localeMap, ...(suggest.length ? { suggestLocaleMap: suggest } : {}), plan }, null, 2)); return; }
if (s.capability === false) warn(TAG, `the App ID ${bundleId} has no GAME_CENTER capability: storebound apple signing ensure --dry, then without --dry (games.gameCenter adds it)`);
if (s.capability === null) warn(TAG, `bundle id ${bundleId} not found in Certificates, Identifiers & Profiles: storebound apple bundle`);
for (const f of project) (f.level === 'ok' ? log : warn)(TAG, `${f.msg}${f.fix ? ` (${f.fix})` : ''}`);
for (const x of suggest) warn(TAG, `config language "${x.id}" is ${x.from}, but the live Game Center has only ${x.to} for that language, so ${x.from} would be created next to it. Map it: storebound apple game-center --locale-map ${x.id}=${x.to} (or games: { localeMap: { ${x.id}: '${x.to}' } } in the config)`);
const rows = [
{ item: 'Game Center (app)', action: s.detail ? 'keep' : 'create', detail: 'POST /v1/gameCenterDetails' },
...(s.version ? [{ item: `Game Center (version ${s.version.versionString})`, action: s.version.enabled ? 'keep' : 'create', detail: 'POST /v1/gameCenterAppVersions' }] : []),
@ -122,7 +127,7 @@ export async function run(ctx: Ctx): Promise<void> {
}
// read back: the store must now match the config (lists can lag right after a create: say so, do not fail)
const after = await readGameCenter(c, bundleId);
const left = planGameCenter(g, config.languages, after).filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep'));
const left = planGameCenter(g, config.languages, after, localeMap).filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep'));
if (!after.detail || left.length) warn(TAG, `read back: ${!after.detail ? 'no Game Center detail yet; ' : ''}${left.map(p => `${p.kind} ${p.id}`).join(', ') || ''} not listed as written yet (App Store Connect lists lag after a create): run storebound apple game-center status in a minute`);
done(TAG, `Game Center: ${after.leaderboards.length} leaderboard(s), ${after.achievements.length} achievement(s); images and review go with the next version (${consoleLink('asc.version')})`);
}

View file

@ -1,5 +1,5 @@
// Upload an IPA to App Store Connect and wait until Apple has processed the build.
// storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
// storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
// Default IPA: the newest in <apple.build.out or build/ios>/export (and its <version>-<build>/ folders), or with
// --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist.
// An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed
@ -16,6 +16,7 @@
// with `apple testflight wait` (waitForBuild) until processingState is VALID (exit 0), INVALID / FAILED (exit 1) or the
// timeout (exit 7).
// An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts).
import { runPreCheck } from '../lib/pre-check';
import { appleSdkProblem } from '../lib/store-minimums';
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
import { basename, join, relative } from 'node:path';
@ -251,6 +252,7 @@ export async function run(ctx: Ctx): Promise<void> {
// upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call
const privacy = bundleTrackingIssues(ipaManifests(ipa));
if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`);
const pre = await runPreCheck({ args: a, config, root, resolve, file: ipa, shown: relative(root, ipa), tag: TAG });
const size = statSync(ipa).size;
const dry = a.has('dry');
const json = a.has('json');
@ -294,7 +296,7 @@ export async function run(ctx: Ctx): Promise<void> {
? [describeReq(reqs.upload), describeReq(reqs.file('<upload id>')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('<file id>', '<md5>')), 'GET /v1/buildUploads/<id> until COMPLETE']
: [showCmd(altoolArgs(ipa, { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }), null)];
if (wait) plan.push(`GET /v1/builds?filter[version]=${info.build} until processingState is VALID (timeout ${a.str('timeout', '60m')})`);
const summary = { ipa: relative(root, ipa), bundleId: info.bundleId, version: info.version, build: info.build, sizeMB: Math.round(size / 104857.6) / 10, via, plan };
const summary = { ipa: relative(root, ipa), bundleId: info.bundleId, version: info.version, build: info.build, sizeMB: Math.round(size / 104857.6) / 10, via, plan, ...(pre ? { preCheck: pre } : {}) };
if (dry) {
if (json) { printJson(summary); return; }
log(TAG, `${summary.ipa}: ${info.bundleId} ${info.version} (${info.build}), ${summary.sizeMB} MB, build number free`);

View file

@ -5,6 +5,7 @@
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
import { localGameFindings } from './games-status';
import { marksNotes, marksPath, parseMarks } from './lib/shots-copy-translate';
import { templateBlockers } from './lib/template-answers';
import { COPYRIGHT_MISSING, templatePlaceholders } from './lib/validate-rules';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
@ -212,6 +213,13 @@ export async function run(ctx: Ctx): Promise<void> {
if (e.length) throw new Error(e.join('; '));
return `${Object.keys(copy).length} languages x ${config.shots!.scenes.length} scenes`;
});
if (config.shots) {
try {
const cp = resolve(config.shots.copy);
const mf = marksPath(cp);
if (existsSync(mf)) for (const n of marksNotes(parseMarks(readFileSync(mf, 'utf8')), relative(process.cwd(), cp) || cp)) note(n);
} catch { /* unreadable marks: shots copy check says so */ }
}
// Capacitor: the native builds ship the web assets cap sync copied last (pitfall 20)
for (const store of ['apple', 'play'] as const) {
if (!config[store] || prebuildFor(config, store)) continue;

View file

@ -103,6 +103,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright; signed-in apps: shots.capture.auth) | ios-sim (simctl) | android-emu (adb) | folder; --dry'],
'shots login': [() => import('./shots/login'), 'opens a visible browser (fresh profile) at shots.capture.baseUrl or --url; you sign in to a demo account by hand, Enter saves .storebound/auth.json (0600) for shots.capture.auth.storageState; --timeout <s>'],
'shots fonts': [() => import('./shots/fonts'), 'download font subsets for the copy (CJK / Arabic / Cyrillic ...)'],
'shots copy': [() => import('./shots/copy'), 'translate --to de,es [--from en] [--translate "<command>"]: fill store/shots/copy.json per language (machine-translated until reviewed, caption limits); check | reviewed --lang de'],
'shots render': [() => import('./shots/render'), 'store screenshots + feature graphic in every size and language'],
'shots jpeg': [() => import('./shots/jpeg'), 'JPEG q92 copies of the Play renders (4x faster uploads)'],
'shots check': [() => import('./shots/check'), 'local store checks of the renders (size, alpha, counts, overflow) and folder frames; --variants, --json'],

View file

@ -73,6 +73,7 @@
"configuration",
"env-file",
"keychain",
"locale-map",
"no-install",
"no-prebuild",
"out",
@ -113,6 +114,7 @@
],
"apple game-center": [
"keychain",
"locale-map",
"no-install"
],
"apple iap": [
@ -223,6 +225,7 @@
"build",
"env-file",
"keychain",
"locale-map",
"new-version",
"no-install",
"no-submit",
@ -250,11 +253,13 @@
],
"apple signing": [
"keychain",
"locale-map",
"no-install"
],
"apple status": [
"cancel-submission",
"keychain",
"locale-map",
"no-install",
"platform",
"timeout",
@ -265,6 +270,7 @@
"build",
"env-file",
"keychain",
"locale-map",
"new-version",
"no-install",
"no-submit",
@ -324,6 +330,7 @@
"build",
"csv",
"env-file",
"evidence",
"group",
"internal-only",
"invite",
@ -331,6 +338,7 @@
"locale",
"no-wait",
"older-than",
"pre-check",
"progress",
"remove",
"result-file",
@ -440,6 +448,7 @@
"flutter",
"godot",
"keychain",
"locale-map",
"name",
"no-install",
"react-native",
@ -545,6 +554,7 @@
],
"games status": [
"keychain",
"locale-map",
"no-install"
],
"health": [
@ -780,9 +790,11 @@
],
"play upload": [
"env-file",
"evidence",
"include-drafts",
"name",
"not-sent-for-review",
"pre-check",
"progress",
"prune",
"result-file",
@ -928,6 +940,14 @@
"src",
"variants"
],
"shots copy": [
"all",
"force",
"from",
"lang",
"to",
"translate"
],
"shots fonts": [],
"shots gallery": [
"lang",
@ -1024,6 +1044,7 @@
"jobs",
"keychain",
"lang",
"locale-map",
"no-health",
"no-install",
"no-local",
@ -1140,6 +1161,7 @@
"end",
"env-file",
"every",
"evidence",
"experiment",
"expo",
"fields",
@ -1188,6 +1210,7 @@
"listing-text",
"listings",
"locale",
"locale-map",
"lookup",
"manual-release",
"max-level",
@ -1243,6 +1266,7 @@
"plist",
"port",
"poster",
"pre-check",
"production",
"profile",
"progress",
@ -1401,7 +1425,7 @@
"storebound apple events submit --name \"...\" [--dry] [--yes] into a review submission (the owner's step; asks)"
],
"apple game-center": [
"storebound apple game-center [status] [--dry] [--yes] [--json]"
"storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json]"
],
"apple iap": [
"storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]"
@ -1480,7 +1504,7 @@
"storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]"
],
"apple upload": [
"storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]"
"storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"<cmd with {file}>\"] [--evidence <path.json>] [--dry] [--yes] [--json]"
],
"apple win-back": [
"storebound apple win-back [--only sub1,sub2] [--dry] [--yes] [--json]"
@ -1629,7 +1653,7 @@
"storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]"
],
"play upload": [
"storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]"
"storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check \"<cmd with {file}>\"] [--evidence <path.json>] [--dry] [--yes] [--json]"
],
"prices": [
"storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]",
@ -1677,6 +1701,11 @@
"shots check": [
"storebound shots check [--lang en,tr] [--variants] [--json]"
],
"shots copy": [
"storebound shots copy translate --to de,es [--from en] [--translate \"<command>\"] [--force] [--dry] [--json]",
"storebound shots copy check [--json] marked languages, caption limits; exit 1 on a problem",
"storebound shots copy reviewed --lang de[,es] | --all [--dry] a human read the copy: remove its marker"
],
"shots fonts": [
"storebound shots fonts [--json] (re-run after editing the copy or the fonts)"
],

View file

@ -47,7 +47,7 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
const s = await readGameCenter(c, config.apple.bundleId);
out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null,
leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s)), why: gcUse.why ?? undefined };
leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s, g.localeMap)), why: gcUse.why ?? undefined };
} catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; }
}
if (config.play && usesPlayGames(config) && o.play !== false) {

View file

@ -768,6 +768,15 @@ export interface ShipConfig {
/** Words `whats-new check` must not flag as internal jargon (a product or game term that collides), any case. */
allowWords?: string[];
};
/** Uploads (`apple upload`, `play upload`). */
upload?: {
/** Shell command run before any byte is sent; `{file}` is the IPA / AAB path. A nonzero exit refuses the upload
* (ship-step error code PRE_CHECK_FAILED). `--pre-check` overrides it. */
preCheck?: string;
/** Where the pre-check evidence JSON (file, sha256, size, command, exit code, stdout tail, time, storebound
* version) is written. `--evidence` overrides it. */
evidence?: string;
};
/** Native builds (`apple build`, `play build`). */
build?: {
/** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a

View file

@ -49,7 +49,7 @@ export function playGamesPart(config: ShipConfig, p: PlanIds): Part {
{ n: 5, text: `Credentials > Add credential > Android: pick ${pkg}; it links to an OAuth client in Google Cloud. Create it at ${GOOGLE_CLOUD_CREDENTIALS} > Create credentials > OAuth client ID > Android (package ${pkg}, the SHA-1 of step 4) > Create, then back in Play Console pick that client > Save changes.`, click: ['Add credential', 'Save changes'], answers: ['package', 'sha1'] },
{ n: 6, text: `Only with a game server (ask-owner): Add credential > Game server: create a Web application OAuth client at ${GOOGLE_CLOUD_CREDENTIALS}, pick it, Save changes. Its client id goes into the game's requestServerSideAccess; the client secret stays on the server (never in chat or files here).`, answers: ['webClient'] },
{ n: 7, text: 'Copy the project id (Configuration page, "Project ID" / Get resources): write it to games.playGames.appId, and put the resources XML into android/app/src/main/res/values/games-ids.xml. `storebound check` then compares it with the manifest\'s com.google.android.gms.games.APP_ID.' },
{ n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). Play needs at least 5 achievements before it publishes the games project.` },
{ n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). If it answers 403 SERVICE_DISABLED / accessNotConfigured, the Google Play Game Services Publishing API is off in the service account's Cloud project: open https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=<project id of the service account key> (the error names the project), press Enable, wait a minute, run it again. Play needs at least 5 achievements before it publishes the games project.` },
{ n: 9, text: 'Testers: Play Games Services > Testers lists who can sign in before publishing (the owner adds them; storebound never invites testers).' },
{ n: 10, text: `Stop before "${PGS_PUBLISH}" (Review and publish): the owner presses it. Then read the page text and pipe it to \`storebound console verify play-games-services --from-text -\`.` },
],

View file

@ -28,7 +28,7 @@ import { homedir, tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string }
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string; project_id?: string }
export interface AscKey { keyId: string; issuerId: string; pem: string }
export interface ReviewContact { firstName: string; lastName: string; phone: string; email: string }
@ -201,7 +201,7 @@ export function parseServiceAccount(text: string): ServiceAccount {
const j = JSON.parse(text) as Partial<ServiceAccount>;
if (typeof j.client_email !== 'string' || typeof j.private_key !== 'string' || !j.private_key.includes('PRIVATE KEY'))
throw new Error('not a Google service account JSON key (client_email / private_key missing)');
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri };
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri, ...(typeof j.project_id === 'string' ? { project_id: j.project_id } : {}) };
}
export function playServiceAccount(root: string): ServiceAccount {

View file

@ -65,6 +65,8 @@ export interface GamesConfig {
gameCenter?: boolean;
/** Play Games Services: the games project id (the digits of games_APP_ID / com.google.android.gms.games.APP_ID). */
playGames?: { appId?: string };
/** Game Center only: config language id (or its apple locale) -> the locale the live Game Center uses, e.g. { en: 'en-GB' } when the app's English is en-GB there but the language says en-US. `storebound apple game-center --locale-map en=en-GB` overrides. */
localeMap?: Record<string, string>;
leaderboards?: GameLeaderboard[];
achievements?: GameAchievement[];
}
@ -78,6 +80,10 @@ export function validateGames(g: GamesConfig | undefined, languages: Language[]
if (g.gameCenter !== undefined && typeof g.gameCenter !== 'boolean') e.push('games.gameCenter: true | false');
if (g.playGames !== undefined && (typeof g.playGames !== 'object' || (g.playGames.appId !== undefined && !/^\d{6,20}$/.test(String(g.playGames.appId)))))
e.push('games.playGames.appId: the digits of the Play Games Services project id (games_APP_ID)');
if (g.localeMap !== undefined) {
if (!g.localeMap || typeof g.localeMap !== 'object' || Array.isArray(g.localeMap)) e.push('games.localeMap: an object { en: "en-GB" } (config language id or locale -> Game Center locale)');
else for (const [k, v] of Object.entries(g.localeMap)) if (typeof v !== 'string' || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(v)) e.push(`games.localeMap.${k}: "${String(v)}" is not a locale like en-GB`);
}
const langs = new Set(languages.map(l => l.id));
const seen = new Set<string>();
const checkId = (kind: string, id: unknown) => {
@ -133,7 +139,41 @@ export function changedFields(have: Record<string, unknown> | undefined, want: R
return Object.keys(want).filter(k => JSON.stringify(have?.[k] ?? null) !== JSON.stringify(want[k] ?? null));
}
const appleLocale = (languages: Language[], id: string): string | undefined => languages.find(l => l.id === id)?.apple;
const appleLocale = (languages: Language[], id: string, map: Record<string, string> = {}): string | undefined => {
const apple = languages.find(l => l.id === id)?.apple;
return map[id] ?? (apple ? map[apple] : undefined) ?? apple;
};
/** The effective Game Center locale map: games.localeMap, then --locale-map en=en-GB,tr=tr (flag entries win). */
export function gcLocaleMap(g: GamesConfig | undefined, flag: string[] = []): Record<string, string> {
const out: Record<string, string> = { ...(g?.localeMap ?? {}) };
for (const item of flag.flatMap(x => x.split(',')).map(x => x.trim()).filter(Boolean)) {
const m = /^([A-Za-z0-9_-]+)=([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)$/.exec(item);
if (!m) throw new Error(`--locale-map "${item}": use <config language id or locale>=<Game Center locale>, e.g. en=en-GB (several: en=en-GB,pt=pt-PT)`);
out[m[1]!] = m[2]!;
}
return out;
}
/** A suggestion per config language whose Game Center locale is not on the live Game Center while exactly one other
* variant of the same language is (en-US configured, only en-GB live): the map entry that stops the spurious creates. */
export function suggestLocaleMap(languages: Language[], liveLocales: string[], map: Record<string, string> = {}): { id: string; from: string; to: string }[] {
const live = [...new Set(liveLocales)];
if (!live.length) return [];
const out: { id: string; from: string; to: string }[] = [];
for (const l of languages) {
const from = appleLocale(languages, l.id, map);
if (!from || live.includes(from) || map[l.id] || map[l.apple ?? '']) continue;
const base = from.split('-')[0]!.toLowerCase();
const same = live.filter(x => x.split('-')[0]!.toLowerCase() === base);
if (same.length === 1) out.push({ id: l.id, from, to: same[0]! });
}
return out;
}
/** The live locales of a Game Center read (every leaderboard and achievement localization). */
export const liveGcLocales = (have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): string[] =>
[...have.leaderboards, ...have.achievements].flatMap(x => x.localizations.map(l => String(l.attributes.locale ?? '')).filter(Boolean));
export function gcLeaderboardAttrs(l: GameLeaderboard): Record<string, unknown> {
return {
@ -151,7 +191,7 @@ export interface GcExisting { id: string; attributes: Record<string, unknown>; l
/** Game Center plan: matched by vendorIdentifier; create what is missing, PATCH what differs, keep the rest. Archived
* items on the store are left alone (an archived vendor id cannot be reused; the plan names it). */
export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): ItemPlan[] {
export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }, localeMap: Record<string, string> = {}): ItemPlan[] {
const out: ItemPlan[] = [];
const one = (kind: ItemPlan['kind'], id: string, attrs: Record<string, unknown>, locs: Record<string, Record<string, unknown>>, list: GcExisting[]) => {
const cur = list.find(x => x.attributes.vendorIdentifier === id);
@ -166,7 +206,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le
for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'apple'))) {
const locs: Record<string, Record<string, unknown>> = {};
for (const [k, v] of Object.entries(l.locales)) {
const loc = appleLocale(languages, k);
const loc = appleLocale(languages, k, localeMap);
if (loc) locs[loc] = { name: v.name, ...(v.suffix ? { formatterSuffix: v.suffix } : {}) };
}
one('leaderboard', l.id, gcLeaderboardAttrs(l), locs, have.leaderboards);
@ -174,7 +214,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le
for (const a of (g.achievements ?? []).filter(x => onStore(x, 'apple'))) {
const locs: Record<string, Record<string, unknown>> = {};
for (const [k, v] of Object.entries(a.locales)) {
const loc = appleLocale(languages, k);
const loc = appleLocale(languages, k, localeMap);
if (loc) locs[loc] = { name: v.name, beforeEarnedDescription: v.description, afterEarnedDescription: v.earned ?? v.description };
}
one('achievement', a.id, gcAchievementAttrs(a), locs, have.achievements);

View file

@ -19,6 +19,7 @@ export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users-
const TITLES: Record<string, string> = {
'playdeveloperreporting.googleapis.com': 'Google Play Developer Reporting API',
'androidpublisher.googleapis.com': 'Google Play Android Developer API',
'gamesconfiguration.googleapis.com': 'Google Play Game Services Publishing API',
'storage.googleapis.com': 'Cloud Storage API',
'storage-api.googleapis.com': 'Cloud Storage API',
};
@ -26,6 +27,7 @@ const TITLES: Record<string, string> = {
const NEEDS: Record<string, string> = {
'playdeveloperreporting.googleapis.com': 'Crash and ANR data',
'androidpublisher.googleapis.com': 'Reading and updating the app on Google Play',
'gamesconfiguration.googleapis.com': 'Play Games Services leaderboards and achievements (storebound play games)',
'storage.googleapis.com': 'Play statistics (store listing visitors, installs)',
'storage-api.googleapis.com': 'Play statistics (store listing visitors, installs)',
};

72
cli/lib/pre-check.ts Normal file
View file

@ -0,0 +1,72 @@
// The pre-upload check of `apple upload` and `play upload`: a shell command the project owns (a store-readiness gate,
// a policy scan of the binary) runs before any byte is sent.
// --pre-check "<cmd with {file}>" (config: upload.preCheck) {file} is replaced by the quoted IPA / AAB path
// --evidence <path.json> (config: upload.evidence) what ran, written also when the check fails
// A nonzero exit refuses the upload: ShipStepError PRE_CHECK_FAILED (exit 1 under the ship-step contract), nothing
// is sent. The evidence ({file, sha256, size, command, exitCode, stdoutTail, timestamp, storeboundVersion}) goes to
// --evidence and into the --json / --result-file result as `preCheck`.
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname } from 'node:path';
import type { Args } from './args';
import type { ShipConfig } from './config';
import { log } from './log';
import { pump } from './runner';
import * as rt from './rt';
import { SHIP_EXIT, ShipStepError, shipExtra, shipPhase } from './ship-step';
export interface PreCheckEvidence {
file: string; sha256: string; size: number; command: string; exitCode: number;
stdoutTail: string[]; timestamp: string; storeboundVersion: string;
}
/** The command with {file} replaced by the POSIX-quoted path (sh -c); a command without {file} gets none. */
export function preCheckCommand(template: string, file: string): string {
const q = `'${file.replace(/\\/g, '/').replace(/'/g, `'\\''`)}'`;
return template.split('{file}').join(q);
}
function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); }
/**
* Runs the pre-check for `file` when --pre-check / upload.preCheck is set (else returns null). Throws PRE_CHECK_FAILED
* on a nonzero exit, after writing the evidence. `shown` is the file name for the evidence (not the absolute path).
*/
export async function runPreCheck(o: { args: Args; config: ShipConfig; root: string; resolve: (p: string) => string; file: string; shown: string; tag: string }): Promise<PreCheckEvidence | null> {
const flag = o.args.get('pre-check');
if (flag === 'true') throw new Error('--pre-check "<command with {file}>" needs a command');
const template = (flag ?? o.config.upload?.preCheck)?.trim();
const evFlag = o.args.get('evidence');
if (evFlag === 'true') throw new Error('--evidence <path.json> needs a path');
const evPath = evFlag ?? o.config.upload?.evidence;
if (!template) {
if (evFlag) throw new Error('--evidence needs --pre-check (or upload.preCheck in the config)');
return null;
}
const command = preCheckCommand(template, o.file);
shipPhase('pre-check', command);
log(o.tag, `pre-check: $ ${command}`);
const p = rt.spawn(['sh', '-c', command], { cwd: o.root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
const tail: string[] = [];
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
if (kind === 'stdout') { tail.push(line); if (tail.length > 20) tail.shift(); }
console.error(` ${line}`);
};
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const exitCode = Number(await p.exited);
const version = ((await import('../../package.json')) as { version: string }).version;
const ev: PreCheckEvidence = {
file: o.shown.split('\\').join('/'), sha256: sha256File(o.file), size: statSync(o.file).size, command, exitCode,
stdoutTail: tail, timestamp: new Date().toISOString(), storeboundVersion: version,
};
if (evPath) {
const abs = o.resolve(evPath);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, JSON.stringify(ev, null, 2) + '\n');
log(o.tag, `pre-check evidence: ${basename(abs)}`);
}
shipExtra({ preCheck: ev });
if (exitCode !== 0) throw new ShipStepError(`pre-check failed (exit ${exitCode}), nothing was uploaded: ${command}${tail.length ? `\n${tail.slice(-5).join('\n')}` : ''}`, SHIP_EXIT.failure, 'PRE_CHECK_FAILED');
log(o.tag, 'pre-check: ok');
return ev;
}

View file

@ -59,7 +59,7 @@ type Fields = Partial<Pick<ShipResult, 'track' | 'version' | 'build' | 'state' |
interface Run {
key: string; step: string; store: 'apple' | 'play';
progress: boolean;
fields: Fields; noop: boolean; warnings: string[]; detail?: unknown; declined?: string;
fields: Fields; extra?: Record<string, unknown>; noop: boolean; warnings: string[]; detail?: unknown; declined?: string;
}
const RUN = Symbol.for('storebound.shipRun');
const g = globalThis as unknown as Record<symbol, Run | null | undefined>;
@ -69,6 +69,8 @@ export const shipRun = (): Run | null => g[RUN] ?? null;
/** What the command did: fields of the final result (later calls overwrite earlier ones). */
export function shipReport(f: Fields): void { const r = shipRun(); if (r) Object.assign(r.fields, Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined))); }
/** Extra keys of the final result (e.g. the preCheck evidence), kept also when the step fails. */
export function shipExtra(x: Record<string, unknown>): void { const r = shipRun(); if (r) r.extra = { ...r.extra, ...x }; }
/** Nothing to do (the build is on the store already, the release is there): exit 7 with what is there. */
export function shipNoop(f: Fields = {}): void { const r = shipRun(); if (r) { r.noop = true; shipReport(f); } }
/** A warning for the result's warnings[] (the log line is the command's own). */
@ -178,7 +180,7 @@ export function buildResult(run: Run, exit: ShipExitCode, error?: ShipErrorInfo)
...(f.artifact ? { artifact: f.artifact } : {}),
...(error ? { error } : {}),
};
return { ...out, ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record<string, unknown>;
return { ...out, ...(run.extra ?? {}), ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record<string, unknown>;
}
const CONTRACT_KEYS = new Set(['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings', 'artifact', 'error']);

View file

@ -0,0 +1,75 @@
// Screenshot copy translation (`storebound shots copy translate`): fills the languages of shots.copy (store/shots/
// copy.json: language id -> scene id -> {eyebrow, title [line1, line2], sub, badges}) from the source language's copy.
// Pure. Like the listing (lib/listing-translate.ts) the CLI never calls a translation service: the agent translates the
// sections marked "translate", or `--translate "<command>"` runs a command of your own once per caption field. A
// language written by this command is machine-translated until a human read it: the marker lives next to the copy file
// (copy.translations.json), because copy.json itself has no place for comments:
// { "de": { "from": "en", "status": "translate" | "draft" } }
// translate: the source text was copied in, still to translate draft: machine-translated, review then `shots copy reviewed`
// Caption limits (characters, per line / field), so a translation still fits the template without the overflow
// `shots check` would flag: eyebrow 30, each of the two title lines 28, sub 90, a badge 18. A translation over its limit
// is not kept (the source text stays and the language stays "translate").
import type { SceneCopy } from '../shots/lib';
export type CopyStatus = 'translate' | 'draft';
export type CopyMarks = Record<string, { from: string; status: CopyStatus }>;
export const CAPTION_LIMITS = { eyebrow: 30, title: 28, sub: 90, badge: 18 } as const;
export const marksPath = (copyPath: string): string => copyPath.replace(/\.json$/i, '') + '.translations.json';
export interface CopyField { key: string; text: string; limit: number }
/** The translatable strings of a scene, in a fixed order (key: eyebrow, title.0, title.1, sub, badge.<n>.<0|1>). */
export function sceneFields(sc: SceneCopy): CopyField[] {
const out: CopyField[] = [];
if (sc.eyebrow) out.push({ key: 'eyebrow', text: sc.eyebrow, limit: CAPTION_LIMITS.eyebrow });
sc.title.forEach((t, i) => { if (t) out.push({ key: `title.${i}`, text: t, limit: CAPTION_LIMITS.title }); });
if (sc.sub) out.push({ key: 'sub', text: sc.sub, limit: CAPTION_LIMITS.sub });
(sc.badges ?? []).forEach((b, i) => b.forEach((t, j) => { if (t) out.push({ key: `badge.${i}.${j}`, text: t, limit: CAPTION_LIMITS.badge }); }));
return out;
}
/** The scene with the given field texts put in (keys of sceneFields; a missing key keeps the source text). */
export function applyFields(src: SceneCopy, texts: Record<string, string>): SceneCopy {
const t = (key: string, d: string): string => texts[key] ?? d;
const out: SceneCopy = { title: [t('title.0', src.title[0]), t('title.1', src.title[1])] };
if (src.eyebrow !== undefined) out.eyebrow = t('eyebrow', src.eyebrow);
if (src.sub !== undefined) out.sub = t('sub', src.sub);
if (src.badges) out.badges = src.badges.map((b, i) => b.map((x, j) => (x === undefined ? x : t(`badge.${i}.${j}`, x))) as [string, string?]);
return out;
}
/** Characters the way a person counts them (code points). */
export const chars = (s: string): number => [...s].length;
export interface SceneCheck { scene: string; field: string; chars: number; limit: number }
/** Fields of a language's copy over their caption limit. */
export function overLimit(copy: Record<string, SceneCopy> | undefined): SceneCheck[] {
const out: SceneCheck[] = [];
for (const [scene, sc] of Object.entries(copy ?? {})) for (const f of sceneFields(sc)) if (chars(f.text) > f.limit) out.push({ scene, field: f.key, chars: chars(f.text), limit: f.limit });
return out;
}
/** Parses the marks file; anything unreadable counts as no marks. */
export function parseMarks(text: string | null): CopyMarks {
if (!text) return {};
try {
const j = JSON.parse(text) as Record<string, { from?: unknown; status?: unknown }>;
return Object.fromEntries(Object.entries(j).filter(([, v]) => v && (v.status === 'translate' || v.status === 'draft') && typeof v.from === 'string')
.map(([k, v]) => [k, { from: v.from as string, status: v.status as CopyStatus }]));
} catch { return {}; }
}
/** One line per marked language for `storebound check` / `shots copy check`; empty when nothing is marked. */
export function marksNotes(marks: CopyMarks, where: string): string[] {
const todo = Object.entries(marks).filter(([, m]) => m.status === 'translate').map(([k]) => k);
const drafts = Object.entries(marks).filter(([, m]) => m.status === 'draft').map(([k]) => k);
return [
...(todo.length ? [`${where}: not translated yet (status=translate): ${todo.join(', ')} (storebound shots copy translate --to ${todo.join(',')})`] : []),
...(drafts.length ? [`${where}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound shots copy reviewed --lang <ids> after reading them)`] : []),
];
}
/** The fix to name in "no copy" problems of validateCopy. */
export const copyFix = (lang: string): string => `storebound shots copy translate --to ${lang}`;

View file

@ -14,16 +14,30 @@ import { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry';
import { hasAndroidProject, type ItemPlan, pgsFindings, type PlayExisting, planPlayGames, planRows, readPgsProject, usesPlayGames } from '../lib/game-services';
import { PLAY_SCOPE, tokenSource } from '../lib/google-auth';
import { enableUrl, findingText, parseGoogleError } from '../lib/google-errors';
import { errorBody } from '../lib/http';
import { done, log, warn } from '../lib/log';
import * as rt from '../lib/rt';
import { AmbiguousWriteError, createOnce, retrySafe } from '../lib/upsert';
const TAG = 'play-games';
export const GAMES_SERVICE = 'gamesconfiguration.googleapis.com';
export const GAMES_API_TITLE = 'Google Play Game Services Publishing API';
export const GAMES_CONFIG_API = 'https://gamesconfiguration.googleapis.com/games/v1configuration';
/** The Games Configuration API is off in the service account's Cloud project (403 SERVICE_DISABLED / accessNotConfigured): the exact API name, where to turn it on and the console plan. Null for any other error. */
export function gamesApiDisabledText(errText: string, fallbackProject: string | null): string | null {
const f = parseGoogleError(errText);
if (f?.kind !== 'api-disabled') return null;
const projectId = f.projectId ?? fallbackProject;
const line = findingText({ ...f, service: GAMES_SERVICE, serviceTitle: GAMES_API_TITLE, projectId, activationUrl: enableUrl(GAMES_SERVICE, projectId) }, { withUrl: true });
return `${line}
API: ${GAMES_API_TITLE} (${GAMES_SERVICE}). Enable it, wait a minute, then run storebound play games again. Plan: storebound console plan play-games-services`;
}
export function gamesConfigClient(root: string) {
const token = tokenSource(playServiceAccount(root), PLAY_SCOPE);
const account = playServiceAccount(root);
const token = tokenSource(account, PLAY_SCOPE);
async function call<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
const url = GAMES_CONFIG_API + path;
const safe = method !== 'POST' && retrySafe(method, url);
@ -37,7 +51,11 @@ export function gamesConfigClient(root: string) {
throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 300)}`);
}
const t = await r.text();
if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 800)}`);
if (!r.ok) {
const msg = `${method} ${path} -> ${r.status} ${errorBody(t, 800)}`;
const off = r.status === 403 ? gamesApiDisabledText(msg, account.project_id ?? null) : null;
throw new Error(off ? `${msg.split(' -> ')[0]} -> 403: ${off}` : msg);
}
return (t ? JSON.parse(t) : {}) as T;
}
}

View file

@ -1,5 +1,5 @@
// Upload an Android App Bundle to a Google Play testing track.
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
// Default AAB: the newest in build/android (storebound play build), or with --version-code N the one with that
// versionCode. Package and versionCode are read from the bundle; the upload is refused when Play already has that
// versionCode (exit 1), and under the ship-step contract (--json / --result-file, docs/ship-step.md) it is exit 7
@ -18,6 +18,7 @@ import { nextVersionCode } from '../lib/release';
import { playClient } from './client';
import { readPlayCopy, refusePlayNotes } from './listing';
import { usedVersionCodes } from './next-version-code';
import { runPreCheck } from '../lib/pre-check';
import { shipNoop, shipPhase, shipReport, shipRun, shipWarn } from '../lib/ship-step';
import { consoleUrl, playTrackPage } from '../lib/console-links';
@ -53,6 +54,7 @@ export async function run(ctx: Ctx): Promise<void> {
const sdkWhy = playTargetSdkProblem(info.targetSdk, { where: targetSdkFile(root) });
if (sdkWhy) throw new Error(`${relative(root, aab)}: not uploaded, ${sdkWhy}`);
assertAabSigned(aab, relative(root, aab));
const pre = await runPreCheck({ args: a, config, root, resolve, file: aab, shown: relative(root, aab), tag: TAG });
const size = statSync(aab).size;
const dry = a.has('dry');
const json = a.has('json');
@ -82,7 +84,7 @@ export async function run(ctx: Ctx): Promise<void> {
}
if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`);
const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`) };
track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`), ...(pre ? { preCheck: pre } : {}) };
if (dry) {
if (json) { printJson(summary); return; }
log(TAG, `${summary.aab}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}), ${summary.sizeMB} MB, versionCode free`);

125
cli/shots/copy.ts Normal file
View file

@ -0,0 +1,125 @@
// Screenshot caption copy per language (shots.copy, store/shots/copy.json).
// storebound shots copy translate --to de,es [--from en] [--translate "<command>"] [--force] [--dry] [--json]
// fills the missing languages (and the missing scenes of a language) from the source language: without --translate
// the source text is copied in and marked status=translate for your agent to translate in place (listing-localization
// skill); with --translate your command runs once per caption field with the source text on stdin and
// STOREBOUND_SOURCE_LANG, STOREBOUND_TARGET_LANG, STOREBOUND_TARGET_LANG_ID, STOREBOUND_FIELD, STOREBOUND_LIMIT in the
// environment; its stdout is the translation (status=draft). A translation over the caption limit is not kept.
// Scenes already there are kept unless --force. The marker lives in copy.translations.json next to the copy file.
// storebound shots copy check [--json] marked languages, caption limits; exit 1 on a problem
// storebound shots copy reviewed --lang de[,es] | --all [--dry] a human read the copy: remove its marker
// --to / --from / --lang take config language ids, App Store locales or Play codes. Local files only.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { playCodes } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import * as rt from '../lib/rt';
import { applyFields, type CopyMarks, chars, marksNotes, marksPath, overLimit, parseMarks, sceneFields } from '../lib/shots-copy-translate';
import { resolveLanguages } from '../lib/whats-new';
import type { CopyFile } from './lib';
const TAG = 'shots-copy';
function readCopy(path: string): CopyFile {
if (!existsSync(path)) throw new Error(`${path}: no copy file (storebound init creates store/shots/copy.json)`);
return JSON.parse(readFileSync(path, 'utf8')) as CopyFile;
}
const readMarks = (path: string): CopyMarks => parseMarks(existsSync(marksPath(path)) ? readFileSync(marksPath(path), 'utf8') : null);
const write = (path: string, v: unknown): void => writeFileSync(path, JSON.stringify(v, null, 2) + '\n');
async function translate(ctx: Ctx): Promise<void> {
const { config, resolve } = ctx.loaded;
const a = ctx.args;
const shots = need(config.shots, 'shots');
const path = resolve(shots.copy);
const targets = resolveLanguages(config.languages, a.list('to'));
if (!targets.length) throw new Error('--to <languages>: config language ids, App Store locales or Play codes (add a language to languages[] in the config first)');
const primary = config.apple?.primaryLocale ?? config.play?.defaultLanguage;
const want = a.get('from');
const src = want ? resolveLanguages(config.languages, [want])[0]! : config.languages.find(l => l.apple === primary || playCodes(l).includes(primary ?? '')) ?? config.languages[0]!;
const copy = readCopy(path);
const from = copy[src.id];
if (!from) throw new Error(`${path}: no copy for the source language ${src.id} (--from <language>)`);
const hook = a.get('translate');
const dry = a.has('dry');
const marks = readMarks(path);
const rows: { lang: string; scenes: number; kept: number; status: string; problems: string[] }[] = [];
const runHook = (text: string, l: (typeof targets)[number], field: string, limit: number): string | null => {
const r = rt.spawnSync(['sh', '-c', hook!], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
env: { ...process.env, STOREBOUND_SOURCE_LANG: src.apple ?? src.id, STOREBOUND_TARGET_LANG: l.apple ?? playCodes(l)[0] ?? l.id, STOREBOUND_TARGET_LANG_ID: l.id, STOREBOUND_FIELD: field, STOREBOUND_LIMIT: String(limit) } });
const out = r.stdout.toString().trim();
if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id} ${field}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); return null; }
return out;
};
for (const l of targets) {
if (l === src) { warn(TAG, `${l.id} is the source language: skipped`); continue; }
const cur = { ...(copy[l.id] ?? {}) };
let written = 0, kept = 0, failed = false;
const problems: string[] = [];
for (const scene of shots.scenes) {
const sc = from[scene.id];
if (!sc) continue;
if (cur[scene.id] && !a.has('force')) { kept++; continue; }
const texts: Record<string, string> = {};
if (hook && !dry) for (const f of sceneFields(sc)) {
const t = runHook(f.text, l, `${scene.id}.${f.key}`, f.limit);
if (t === null) { failed = true; continue; }
const one = t.replace(/\s*\n\s*/g, ' ');
if (chars(one) > f.limit) { failed = true; problems.push(`${scene.id}.${f.key}: ${chars(one)} > ${f.limit} characters (source text kept)`); continue; }
texts[f.key] = one;
}
cur[scene.id] = applyFields(sc, texts);
written++;
}
const status = hook && !dry && !failed ? 'draft' : 'translate';
rows.push({ lang: l.id, scenes: written, kept, status: written ? status : 'kept', problems });
if (!written || dry) continue;
copy[l.id] = cur;
marks[l.id] = { from: src.id, status };
}
if (!dry && rows.some(r => r.scenes)) { write(path, copy); write(marksPath(path), marks); }
if (a.has('json')) { printJson({ copy: path, from: src.id, dry, languages: rows }); return; }
for (const r of rows) log(TAG, `${r.lang}: ${r.scenes ? `${r.scenes} scene(s) ${dry ? 'would be ' : ''}written (${r.status})` : 'nothing to write'}${r.kept ? `, ${r.kept} kept (--force replaces)` : ''}${r.problems.length ? `; ${r.problems.join('; ')}` : ''}`);
const todo = rows.filter(r => r.scenes && r.status === 'translate').length;
done(TAG, dry ? 'translation planned (dry run, nothing written)' : todo ? `${todo} language(s) to translate in place (edit copy.json, keep the marker in copy.translations.json), then storebound shots copy check` : 'drafts written: read them, storebound shots copy check, then storebound shots copy reviewed --lang <ids>');
}
async function check(ctx: Ctx): Promise<void> {
const { config, resolve } = ctx.loaded;
const shots = need(config.shots, 'shots');
const path = resolve(shots.copy);
const copy = readCopy(path);
const marks = readMarks(path);
const over = Object.fromEntries(Object.keys(copy).map(l => [l, overLimit(copy[l])] as const).filter(([, v]) => v.length));
const missing = config.languages.map(l => l.id).filter(id => !copy[id]);
if (ctx.args.has('json')) printJson({ copy: path, marks, overLimit: over, missing });
else {
for (const n of marksNotes(marks, path)) warn(TAG, n);
for (const [l, v] of Object.entries(over)) for (const o of v) warn(TAG, `${l} ${o.scene}.${o.field}: ${o.chars} > ${o.limit} characters`);
if (missing.length) warn(TAG, `no copy for ${missing.join(', ')}: storebound shots copy translate --to ${missing.join(',')}`);
}
if (Object.keys(over).length || missing.length) process.exitCode = 1;
else if (!ctx.args.has('json')) done(TAG, 'shots copy ok');
}
async function reviewed(ctx: Ctx): Promise<void> {
const { config, resolve } = ctx.loaded;
const path = resolve(need(config.shots, 'shots').copy);
const marks = readMarks(path);
const ids = ctx.args.has('all') ? Object.keys(marks) : resolveLanguages(config.languages, ctx.args.list('lang')).map(l => l.id);
if (!ids.length) throw new Error('--lang <languages> | --all');
const cleared = ids.filter(id => marks[id]);
const refused = cleared.filter(id => marks[id]!.status === 'translate');
if (refused.length) throw new Error(`${refused.join(', ')}: still status=translate (the source text is in place): translate it first (storebound shots copy translate --translate "<command>", or edit ${path})`);
if (!ctx.args.has('dry')) { for (const id of cleared) delete marks[id]; write(marksPath(path), marks); }
done(TAG, `${cleared.length ? cleared.join(', ') : 'nothing'} marked reviewed${ctx.args.has('dry') ? ' (dry run)' : ''}`);
}
export async function run(ctx: Ctx): Promise<void> {
const sub = ctx.args._[0];
if (sub === 'translate') return translate(ctx);
if (sub === 'check') return check(ctx);
if (sub === 'reviewed') return reviewed(ctx);
throw new Error('usage: storebound shots copy translate --to de,es [--from en] [--translate "<command>"] [--force] [--dry] | check | reviewed --lang de | --all');
}

View file

@ -145,7 +145,7 @@ export function validateCopy(copy: CopyFile, shots: ShotsConfig, langs: string[]
const e: string[] = [];
for (const lang of langs) {
const c = copy[lang];
if (!c) { e.push(`${lang}: no copy`); continue; }
if (!c) { e.push(`${lang}: no copy (storebound shots copy translate --to ${lang})`); continue; }
for (const s of shots.scenes) {
const sc = c[s.id];
if (!sc) { e.push(`${lang} scene ${s.id}: missing`); continue; }

View file

@ -128,3 +128,10 @@ storebound play build --version-from version.json --env-file /run/secrets/ship.e
storebound play upload --track production --version-from version.json --env-file /run/secrets/ship.env --json --result-file out/upload.json --yes
case $? in 0|7) ;; 3) echo "owner: $(jq -r .error.consoleUrl out/upload.json)"; exit 1 ;; *) exit 1 ;; esac
```
## `--pre-check` and `--evidence` (upload steps)
`apple upload` and `play upload` take `--pre-check "<command with {file}>"` (config `upload.preCheck`): the command runs
(sh -c, project root) before any byte is sent. A nonzero exit is exit 1 with error code `PRE_CHECK_FAILED`. `--evidence
<path.json>` (config `upload.evidence`) writes `{file, sha256, size, command, exitCode, stdoutTail, timestamp,
storeboundVersion}`, also on failure; the same object is the result's `preCheck` key.

View file

@ -1376,3 +1376,42 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `cli/lib/play-publish.ts` (ready list from tracks plus the public store page, the only signal the API
gives), the plan, the verify, the hint in `play status` / `status` / the desktop Releases tab. Non-track changes are
not visible to the API.
||||||| parent of abe0821 (upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint)
## 115. A project's own pre-upload gate ran as a separate step and could be skipped
- **What happens:** a store-readiness scan of the binary lived in the pipeline next to `apple upload` / `play upload`, so
a re-run, a manual upload or a reordered job sent the file without it, and nothing proved afterwards what had run.
- **Do:** pass the gate to the upload: `storebound apple upload app.ipa --pre-check "./scan.sh {file}" --evidence
build/pre-check.json` (same for `play upload`; or `upload.preCheck` / `upload.evidence` in the config). A nonzero exit
refuses the upload before any network call: ship-step exit 1, error code `PRE_CHECK_FAILED`. The evidence (file,
sha256, size, command, exit code, stdout tail, time, storebound version) is written also on failure and is in the
`--json` / `--result-file` result as `preCheck`.
- **Guard:** `cli/lib/pre-check.ts`, `tests/ship-step.test.ts` (failing check: no store write, evidence + error code).
## 116. `storebound play games` died with a bare 403 when the Games Configuration API was off
- **What happens:** the Play Games Services Publishing API (`gamesconfiguration.googleapis.com`) is a separate API in the
service account's Cloud project; while it is off every call is 403 SERVICE_DISABLED / accessNotConfigured.
- **Do:** the error now names the API, the project and the enable URL
(`https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=<id>`); press Enable, wait a
minute, run it again. The plan: `storebound console plan play-games-services` (step 8 has the case).
- **Guard:** `gamesApiDisabledText` in `cli/play/games.ts`, `tests/game-services.test.ts`.
## 117. Game Center creates `en-US` next to a live `en-GB` (spurious creates)
- **What happens:** the config language `en` is `en-US`, the live Game Center has only `en-GB`; the plan wants to create
an `en-US` localization for every item and leaves the `en-GB` ones unmanaged.
- **Do:** map it: `storebound apple game-center --locale-map en=en-GB` or `games: { localeMap: { en: 'en-GB' } }`
(key: config language id or its App Store locale). The dry run suggests the exact flag when the live Game Center has
exactly one variant of a language the config spells differently.
- **Guard:** `gcLocaleMap` / `suggestLocaleMap` in `cli/lib/game-services.ts`, `tests/game-services.test.ts`.
## 118. Screenshot copy missing for a locale, translated by hand into copy.json
- **What happens:** `storebound check` / `shots render` stopped with "<lang>: no copy" and the only fix was typing every
caption.
- **Do:** `storebound shots copy translate --to de,es` (agent translates the copied source text, or `--translate "<cmd>"`
runs your command per caption field). Languages stay marked machine-translated in `copy.translations.json` until
`storebound shots copy reviewed --lang de`. Captions over the limit (eyebrow 30, title line 28, sub 90, badge 18) are
not kept.
- **Guard:** `cli/shots/copy.ts`, `cli/lib/shots-copy-translate.ts`, `tests/shots-copy-translate.test.ts`.

View file

@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr
import type { ShipConfig } from '../cli/lib/config';
import { validateConfig } from '../cli/lib/config';
import { capabilitySpecs, planCapabilities } from '../cli/lib/signing';
import { gamesPrivacyFindings, gcFindings, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services';
import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services';
import { consolePlan } from '../cli/lib/console-plan';
import { verifyText } from '../cli/lib/console-verify';
import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status';
@ -116,6 +116,42 @@ describe('apple game-center (fake App Store Connect)', () => {
});
});
describe('Game Center locale map', () => {
const live = { leaderboards: [{ id: 'lb-1', attributes: { vendorIdentifier: 'com.example.game.highscore', referenceName: 'com.example.game.highscore', defaultFormatter: 'INTEGER', submissionType: 'BEST_SCORE', scoreSortType: 'DESC' },
localizations: [{ id: 'l1', attributes: { locale: 'en-GB', name: 'High score', formatterSuffix: ' points' } }, { id: 'l2', attributes: { locale: 'tr', name: 'En yüksek skor' } }] }], achievements: [] };
const only = { ...GAME.games!, achievements: [] };
const sig = (p: ReturnType<typeof planGameCenter>) => p[0]!.locales.map(l => l.locale + ':' + l.action);
test('without a map the config en-US is created next to the live en-GB, and the suggestion names the map', () => {
expect(sig(planGameCenter(only, GAME.languages, live))).toEqual(['en-US:create', 'tr:keep']);
expect(suggestLocaleMap(GAME.languages, liveGcLocales(live))).toEqual([{ id: 'en', from: 'en-US', to: 'en-GB' }]);
});
test('--locale-map en=en-GB (or games.localeMap) leaves nothing to create or update, and no suggestion remains', () => {
const map = gcLocaleMap(only, ['en=en-GB']);
expect(map).toEqual({ en: 'en-GB' });
const plan = planGameCenter(only, GAME.languages, live, map);
expect(sig(plan)).toEqual(['en-GB:keep', 'tr:keep']);
expect(plan[0]!.action).toBe('keep');
expect(suggestLocaleMap(GAME.languages, liveGcLocales(live), map)).toEqual([]);
expect(gcLocaleMap({ ...only, localeMap: { en: 'en-AU' } }, ['en=en-GB,tr=tr'])).toEqual({ en: 'en-GB', tr: 'tr' });
expect(planGameCenter(only, GAME.languages, live, { 'en-US': 'en-GB' })[0]!.locales[0]!.locale).toBe('en-GB');
});
test('two English variants live: no guess; a bad map entry is refused', () => {
expect(suggestLocaleMap(GAME.languages, ['en-GB', 'en-AU', 'tr'])).toEqual([]);
expect(() => gcLocaleMap(only, ['en'])).toThrow('--locale-map');
expect(validateGames({ ...only, localeMap: { en: 'english' } }, GAME.languages).join()).toContain('games.localeMap.en');
});
test('apple game-center against the fake store: the dry run warns with the exact flag; with the flag nothing is written', async () => {
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
const base = await run2(() => gameCenter(project(GAME).ctx(['--yes'])));
expect(base.writes.length).toBeGreaterThan(0);
for (const l of [...s.asc.all('gameCenterLeaderboardLocalizations'), ...s.asc.all('gameCenterAchievementLocalizations')]) if (l.attributes.locale === 'en-US') l.attributes.locale = 'en-GB';
const dry = await run2(() => gameCenter(project(GAME).ctx(['--dry'])));
expect(dry.out + dry.err).toContain('--locale-map en=en-GB');
const mapped = await run2(() => gameCenter(project(GAME).ctx(['--yes', '--locale-map', 'en=en-GB'])));
expect(mapped.writes).toEqual([]);
});
});
describe('play games (fake Games Configuration API)', () => {
test('create, no-op re-run, update by name match; playId wins', async () => {
const p = project(GAME);
@ -141,6 +177,21 @@ describe('play games (fake Games Configuration API)', () => {
});
});
describe('play games with the Games Configuration API disabled', () => {
test('a 403 SERVICE_DISABLED names the API, the enable URL for the project and the console plan', async () => {
s.faults.push({ method: 'GET', match: /leaderboards/, when: 'before', status: 403, times: 5, code: 'SERVICE_DISABLED', detail: 'Google Play Game Services Publishing API has not been used in project 987654321 before or it is disabled. SERVICE_DISABLED accessNotConfigured' });
const err = await run2(() => playGames(project(GAME).ctx(['--dry']))).then(() => null, (e: Error) => e);
expect(err?.message).toContain('Google Play Game Services Publishing API');
expect(err?.message).toContain('gamesconfiguration.googleapis.com');
expect(err?.message).toContain('https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=987654321');
expect(err?.message).toContain('storebound console plan play-games-services');
});
test('the console plan has the enable step', () => {
const plan = consolePlan('play-games-services', GAME);
expect(JSON.stringify(plan)).toContain('gamesconfiguration.googleapis.com');
});
});
describe('project checks', () => {
const ENT = '<?xml version="1.0"?><plist><dict><key>com.apple.developer.game-center</key><true/></dict></plist>';
test('Game Center entitlement: missing, unreferenced, present, present without config', () => {

View file

@ -287,6 +287,34 @@ describe('idempotent re-upload (fake stores)', () => {
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'upload', store: 'apple', version: '1.2.0', build: '7', state: 'VALID' });
expect(writes(s.calls).filter(c => /buildUploads/.test(c.path))).toEqual([]);
});
test('play upload --pre-check: a passing command writes the evidence and joins the result; a failing one refuses with PRE_CHECK_FAILED before any write', async () => {
const d = tmp();
const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') });
const ev = join(d, 'ev', 'evidence.json');
const bad = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}; exit 3', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a }));
expect(bad.code).toBe(1);
const jb = JSON.parse(bad.out);
expect(jb).toMatchObject({ ok: false, error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 3, size: expect.any(Number) } });
expect(JSON.parse(readFileSync(ev, 'utf8')).exitCode).toBe(3);
expect(writes(s.calls)).toEqual([]);
const ok = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a }));
expect(ok.code).toBe(0);
const e = JSON.parse(readFileSync(ev, 'utf8'));
expect(e).toMatchObject({ file: 'build/android/app.aab', exitCode: 0, storeboundVersion: expect.any(String) });
expect(e.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(e.stdoutTail.join(' ')).toContain('scanning');
expect(JSON.parse(ok.out).preCheck.sha256).toBe(e.sha256);
});
test('apple upload --pre-check: a failing command stops before any App Store Connect call', async () => {
const plist = '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>com.example.notes</string><key>CFBundleShortVersionString</key><string>1.2.0</string><key>CFBundleVersion</key><string>9</string></dict></plist>';
const p = project(BASE, { 'build/ios/export/Example.ipa': makeIpa(plist) });
const n = s.calls.length;
const r = await ship('apple upload', ['--json', '--yes', '--pre-check', 'exit 1'], a => appleUpload({ loaded: p.loaded, args: a }));
expect(r.code).toBe(1);
expect(JSON.parse(r.out)).toMatchObject({ ok: false, step: 'upload', store: 'apple', error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 1 } });
expect(s.calls.length).toBe(n);
});
});
describe('status', () => {

View file

@ -0,0 +1,52 @@
// `storebound shots copy translate | check | reviewed`: fills copy.json per language with a marker file, caption limits.
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { validateCopy } from '../cli/shots/lib';
import { applyFields, marksNotes, overLimit, parseMarks, sceneFields } from '../cli/lib/shots-copy-translate';
import { run } from '../cli/shots/copy';
import { BASE, capture, project } from './helpers/store-mock';
const CFG: ShipConfig = {
...BASE,
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }],
shots: { copy: 'store/shots/copy.json', scenes: [{ id: 'home', palette: 'gold', frames: [] }] } as never,
};
const COPY = JSON.stringify({ en: { home: { eyebrow: 'Hello', title: ['Everything', 'in order'], sub: 'Lists and notes.' } } });
const go = async (p: ReturnType<typeof project>, argv: string[]) => { const r = await capture(() => run(p.ctx(argv))); return r; };
describe('shots copy translate', () => {
test('without a hook the source is copied in as status=translate; the missing-locale problem names the command', async () => {
const p = project(CFG, { 'store/shots/copy.json': COPY });
expect(validateCopy(JSON.parse(COPY), CFG.shots!, ['en', 'de'])[0]).toContain('storebound shots copy translate --to de');
await go(p, ['translate', '--to', 'de']);
const copy = JSON.parse(readFileSync(join(p.root, 'store/shots/copy.json'), 'utf8'));
expect(copy.de.home.title).toEqual(['Everything', 'in order']);
const marks = parseMarks(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8'));
expect(marks.de).toEqual({ from: 'en', status: 'translate' });
expect(marksNotes(marks, 'copy')[0]).toContain('not translated yet');
expect(validateCopy(copy, CFG.shots!, ['en', 'de'])).toEqual([]);
});
test('with a hook: draft; an over-limit field keeps the source and the language stays translate; reviewed refuses translate', async () => {
const p = project(CFG, { 'store/shots/copy.json': COPY });
await go(p, ['translate', '--to', 'de', '--translate', 'tr a-z A-Z']);
let copy = JSON.parse(readFileSync(join(p.root, 'store/shots/copy.json'), 'utf8'));
expect(copy.de.home.eyebrow).toBe('HELLO');
expect(parseMarks(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8')).de!.status).toBe('draft');
const r = await go(p, ['reviewed', '--lang', 'de']);
expect(r.exit).toBeFalsy();
expect(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8').trim()).toBe('{}');
const q = project(CFG, { 'store/shots/copy.json': COPY });
await go(q, ['translate', '--to', 'de', '--translate', 'echo xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx']);
copy = JSON.parse(readFileSync(join(q.root, 'store/shots/copy.json'), 'utf8'));
expect(copy.de.home.title[0]).toBe('Everything');
expect(parseMarks(readFileSync(join(q.root, 'store/shots/copy.translations.json'), 'utf8')).de!.status).toBe('translate');
});
test('helpers: fields, limits', () => {
const sc = JSON.parse(COPY).en.home;
expect(sceneFields(sc).map(f => f.key)).toEqual(['eyebrow', 'title.0', 'title.1', 'sub']);
expect(applyFields(sc, { sub: 'x' }).sub).toBe('x');
expect(overLimit({ home: { title: ['a'.repeat(40), 'b'] } })[0]).toMatchObject({ field: 'title.0', limit: 28 });
});
});