diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f69d8..043b6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ""` (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 ` (`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 ""`), 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 `-dev.` (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 `///.png` (feature: `/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. diff --git a/cli/apple/game-center.ts b/cli/apple/game-center.ts index e122b2d..ba757e0 100644 --- a/cli/apple/game-center.ts +++ b/cli/apple/game-center.ts @@ -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 { 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 { } // 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')})`); } diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 6868d25..966019f 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -1,5 +1,5 @@ // Upload an IPA to App Store Connect and wait until Apple has processed the build. -// storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json] +// storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] // Default IPA: the newest in /export (and its -/ 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 { // 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 { ? [describeReq(reqs.upload), describeReq(reqs.file('')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('', '')), 'GET /v1/buildUploads/ 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`); diff --git a/cli/check.ts b/cli/check.ts index ed1f5ab..89ddd21 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -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 { 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; diff --git a/cli/commands.ts b/cli/commands.ts index ddce923..e4df348 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -103,6 +103,7 @@ export const COMMANDS: Record Promise, 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 '], '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 ""]: 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'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 5fc96bf..9707cf8 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]" + "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--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 [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]" + "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check \"\"] [--evidence ] [--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 \"\"] [--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)" ], diff --git a/cli/games-status.ts b/cli/games-status.ts index a1a2048..5a06489 100644 --- a/cli/games-status.ts +++ b/cli/games-status.ts @@ -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) { diff --git a/cli/lib/config.ts b/cli/lib/config.ts index e6e87cc..f300e23 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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 diff --git a/cli/lib/console-games-plan.ts b/cli/lib/console-games-plan.ts index 48bb8cc..c3c9b4a 100644 --- a/cli/lib/console-games-plan.ts +++ b/cli/lib/console-games-plan.ts @@ -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= (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 -\`.` }, ], diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 709be38..186a8fe 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -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; 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 { diff --git a/cli/lib/game-services.ts b/cli/lib/game-services.ts index 9de1f7f..47fd17c 100644 --- a/cli/lib/game-services.ts +++ b/cli/lib/game-services.ts @@ -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; 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(); const checkId = (kind: string, id: unknown) => { @@ -133,7 +139,41 @@ export function changedFields(have: Record | 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 | 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 { + const out: Record = { ...(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 =, 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 = {}): { 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 { return { @@ -151,7 +191,7 @@ export interface GcExisting { id: string; attributes: Record; 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 = {}): ItemPlan[] { const out: ItemPlan[] = []; const one = (kind: ItemPlan['kind'], id: string, attrs: Record, locs: Record>, 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> = {}; 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> = {}; 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); diff --git a/cli/lib/google-errors.ts b/cli/lib/google-errors.ts index 91c0349..606307e 100644 --- a/cli/lib/google-errors.ts +++ b/cli/lib/google-errors.ts @@ -19,6 +19,7 @@ export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users- const TITLES: Record = { '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 = { const NEEDS: Record = { '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)', }; diff --git a/cli/lib/pre-check.ts b/cli/lib/pre-check.ts new file mode 100644 index 0000000..f85bb15 --- /dev/null +++ b/cli/lib/pre-check.ts @@ -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 "" (config: upload.preCheck) {file} is replaced by the quoted IPA / AAB path +// --evidence (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 { + const flag = o.args.get('pre-check'); + if (flag === 'true') throw new Error('--pre-check "" needs a command'); + const template = (flag ?? o.config.upload?.preCheck)?.trim(); + const evFlag = o.args.get('evidence'); + if (evFlag === 'true') throw new Error('--evidence 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; +} diff --git a/cli/lib/ship-step.ts b/cli/lib/ship-step.ts index 796ce18..af8e3da 100644 --- a/cli/lib/ship-step.ts +++ b/cli/lib/ship-step.ts @@ -59,7 +59,7 @@ type Fields = Partial; noop: boolean; warnings: string[]; detail?: unknown; declined?: string; } const RUN = Symbol.for('storebound.shipRun'); const g = globalThis as unknown as Record; @@ -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): 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; + return { ...out, ...(run.extra ?? {}), ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record; } const CONTRACT_KEYS = new Set(['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings', 'artifact', 'error']); diff --git a/cli/lib/shots-copy-translate.ts b/cli/lib/shots-copy-translate.ts new file mode 100644 index 0000000..2145bc4 --- /dev/null +++ b/cli/lib/shots-copy-translate.ts @@ -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 ""` 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; + +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..<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): 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 | 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; + 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 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}`; diff --git a/cli/play/games.ts b/cli/play/games.ts index 3cebf46..009683f 100644 --- a/cli/play/games.ts +++ b/cli/play/games.ts @@ -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(method: string, path: string, body?: unknown): Promise { 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; } } diff --git a/cli/play/upload.ts b/cli/play/upload.ts index d7c93a1..0ccad8d 100644 --- a/cli/play/upload.ts +++ b/cli/play/upload.ts @@ -1,5 +1,5 @@ // Upload an Android App Bundle to a Google Play testing track. -// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json] +// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check ""] [--evidence ] [--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 { 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 { } 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`); diff --git a/cli/shots/copy.ts b/cli/shots/copy.ts new file mode 100644 index 0000000..96ca84e --- /dev/null +++ b/cli/shots/copy.ts @@ -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 ""] [--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 { + 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 : 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 )`); + 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 = {}; + 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 '); +} + +async function check(ctx: Ctx): Promise { + 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 { + 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 | --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 "", 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 { + 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 ""] [--force] [--dry] | check | reviewed --lang de | --all'); +} diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 57b2b59..5b882ab 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -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; } diff --git a/docs/ship-step.md b/docs/ship-step.md index dc9aa02..cb44b15 100644 --- a/docs/ship-step.md +++ b/docs/ship-step.md @@ -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 ""` (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 +` (config `upload.evidence`) writes `{file, sha256, size, command, exitCode, stdoutTail, timestamp, +storeboundVersion}`, also on failure; the same object is the result's `preCheck` key. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index c9fa7d2..b0c0381 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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=`); 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 ": 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 ""` + 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`. diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts index f47c27e..636bcd8 100644 --- a/tests/game-services.test.ts +++ b/tests/game-services.test.ts @@ -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) => 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 = 'com.apple.developer.game-center'; test('Game Center entitlement: missing, unreferenced, present, present without config', () => { diff --git a/tests/ship-step.test.ts b/tests/ship-step.test.ts index 3da79de..133b817 100644 --- a/tests/ship-step.test.ts +++ b/tests/ship-step.test.ts @@ -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 = 'CFBundleIdentifiercom.example.notesCFBundleShortVersionString1.2.0CFBundleVersion9'; + 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', () => { diff --git a/tests/shots-copy-translate.test.ts b/tests/shots-copy-translate.test.ts new file mode 100644 index 0000000..85bf9d5 --- /dev/null +++ b/tests/shots-copy-translate.test.ts @@ -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, 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 }); + }); +});