diff --git a/.cursor/rules/storebound.mdc b/.cursor/rules/storebound.mdc index c32b14b..2c41da6 100644 --- a/.cursor/rules/storebound.mdc +++ b/.cursor/rules/storebound.mdc @@ -46,6 +46,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t - `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game - `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text - `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly +- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` - `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game - `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) - `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 18caa45..ecfa9e7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -48,6 +48,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t | `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game | | `aso-basics` | App store optimization basics for a mobile app's or game's text | | `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly | +| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` | | `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game | | `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) | | `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game | diff --git a/AGENTS.md b/AGENTS.md index c958f29..19cf225 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t - `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game - `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text - `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly +- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` - `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game - `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) - `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game diff --git a/CHANGELOG.md b/CHANGELOG.md index 6989820..426a69e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- **`storebound import fastlane`** (`--dir`, `--out`, `--profile`, `--dry`; MCP `import_fastlane`, dry run by default): + reads Appfile / Deliverfile (plain `key("value")` lines only; Ruby logic is reported, never run), deliver and supply + metadata and screenshots, and writes `storebound.config.ts`, `store/appstore.md`, `store/listing.md`, the Play icon + and feature graphics, and screenshot frames with a `folder` capture source. App Store and Play language codes are + paired into one `languages[]` entry (`cli/lib/store-locales.ts`). Existing files are never overwritten (a + `.imported.` copy plus a diff hint). Keys, team ids, the App Review contact and demo account are only + named in the report; submission flags, `price_tier`, the age rating config, lanes and signing are listed with their + storebound counterpart. New skill `migrate-from-fastlane`. + - Fixes from a read-only run against live store accounts: `apps check` says `api-disabled` (not `permission`) when the Play Developer Reporting API is off, and `apps list` / serve `apps.list` fail with that reason and the fix instead of a JSON fragment (API error bodies are kept on one line everywhere); the newest App Store version is the newest by diff --git a/GEMINI.md b/GEMINI.md index bf1cbeb..dd0810f 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -47,6 +47,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t | `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game | | `aso-basics` | App store optimization basics for a mobile app's or game's text | | `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly | +| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` | | `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game | | `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) | | `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game | diff --git a/README.md b/README.md index a632148..0974cb1 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,32 @@ catalog", "the reviewer could not sign in", "make store screenshots in 5 languag update rejected", "put build 42 on TestFlight", "how are our crash rates", "answer the new reviews", "did the new screenshots help". The matching skill takes over. +### Coming from fastlane + +`storebound import fastlane` turns an existing fastlane setup into storebound files. It only reads (no Ruby is run), +never overwrites anything and prints what it could not map: + +```sh +bunx storebound import fastlane --dry # the plan + report; default --dir: fastlane/, ios/fastlane/, android/fastlane/ +bunx storebound import fastlane # writes the files below; an existing file gets a .imported. copy +bunx storebound init # adds the remaining templates, keeps the imported files +bunx storebound check +``` + +| fastlane | storebound | +|---|---| +| Appfile `app_identifier`, `package_name` | `apple.bundleId`, `play.packageName` | +| deliver `metadata//*.txt` (+ `default/`) | `store/appstore.md` per App Store locale; URLs, copyright, categories and review notes in the config | +| supply `metadata/android//*.txt`, newest `changelogs/.txt` | `store/listing.md` per Play language | +| deliver and supply language folders | `languages[]` with both store codes (`tr` + `tr-TR`, `zh-Hans` + `zh-CN`, `he` + `iw-IL`) | +| `screenshots//`, `images/phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames///NN.png` with a `folder` capture source | +| supply `icon`, `featureGraphic` | `play.icon`, `store/renders//feature/1024x500.*` | + +Keys and accounts are never copied: `json_key_file`, the API key, team ids, the App Review contact and demo account are +named in the report with where they go (`.storebound/credentials.json`). Submission and release flags, `price_tier`, +the age rating config, lanes and signing (`match`) are listed with their storebound counterpart. The +`migrate-from-fastlane` skill walks an agent through it. + ### Credentials (never in the repo) Read from environment variables, or from `.storebound/credentials.json` in your project (gitignored by `init`): @@ -173,6 +199,7 @@ run in the background with a log (`storebound shots render > .storebound/render. | command | kind | what | |---|---|---| | `init [--profile app\|game\|subscription-app]` | local | copy the templates of a profile (default app), gitignore `.storebound/` | +| `import fastlane [--dir fastlane] [--profile ...]` | local | config, `store/appstore.md`, `store/listing.md` and screenshot frames from a fastlane setup; never overwrites, reports what is not mapped | | `install --agent ` | local | skills + MCP entry for an AI tool | | `mcp` | local | stdio MCP server wrapping the commands | | `serve --stdio` | local | JSON-RPC session for the storebound desktop app (see [Desktop machine interface](#desktop-machine-interface)) | @@ -331,6 +358,7 @@ navigation. | skill | for | |---|---| +| `migrate-from-fastlane` | `storebound import fastlane`: ids, listings, languages and screenshots from fastlane; keys and accounts stay out | | `play-first-release` | Google Play from zero to "In review" | | `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key | | `testflight` | beta groups, testers from a CSV that never enters git, What to Test, Test Information, Beta App Review | @@ -365,7 +393,7 @@ per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same proc - One tool per command. Read-only: `check`, `check_aso`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`, `analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`, - `play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`. Local writes: `whats_new`, `icons`, + `play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`. Local writes: `import_fastlane`, `whats_new`, `icons`, `health_report`, `reviews_draft`, `prices_export`, `analytics_snapshot`, `shots_capture`, `shots_render`, `shots_gallery`, `shots_record`, `shots_video`. Store writes: `play_listing`, `play_images`, `play_products`, `play_data_safety`, `play_subscriptions`, `play_release_testing`, `apple_bundle`, `apple_iap`, `apple_iap_review`, diff --git a/cli/import.ts b/cli/import.ts new file mode 100644 index 0000000..464562d --- /dev/null +++ b/cli/import.ts @@ -0,0 +1,73 @@ +// Migration from other tools. Only reads the source; writes storebound files next to it without overwriting anything. +// storebound import fastlane [--dir [,]] [--out ] [--profile app|game|subscription-app] [--dry] +// fastlane: Appfile / Deliverfile ids and options, deliver metadata (fastlane/metadata//*.txt, default/, +// review_information/notes.txt), supply metadata (fastlane/metadata/android//*, changelogs, images/) and the +// screenshots become storebound.config.ts, store/appstore.md, store/listing.md and, with screenshots, a `folder` capture +// source (store/shots/frames///NN.png + store/shots/copy.json). Default --dir: fastlane/, +// ios/fastlane/ and android/fastlane/ under --out (default: the current folder), every one that exists. +// A file that already exists is kept: the import goes to .imported. next to it (images are skipped), with a +// diff hint. Keys, team ids, the App Review contact and demo account are never copied: the report says where they go. +import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import type { Args } from './lib/args'; +import { type AppProfile, PROFILES } from './lib/config'; +import { findFastlaneDirs, type ImportPlan, importedPath, planImport, scanFastlane } from './lib/fastlane'; +import { log, warn } from './lib/log'; + +export interface Applied { created: string[]; imported: { target: string; written: string }[]; kept: string[] } + +/** Writes a plan under `root` (nothing with dry). Existing text files get a .imported copy; existing images are kept. */ +export function applyPlan(plan: ImportPlan, root: string, dry: boolean): Applied { + const out: Applied = { created: [], imported: [], kept: [] }; + for (const f of plan.files) { + const dest = join(root, f.path); + if (relative(root, dest).startsWith('..')) throw new Error(`import target outside the project: ${f.path}`); + let target = dest; + if (existsSync(dest)) { + if (f.text === undefined) { out.kept.push(f.path); log('import', `keep ${f.path} (exists)`); continue; } + target = importedPath(dest); + out.imported.push({ target: f.path, written: relative(root, target) }); + log('import', `${dry ? 'would write' : 'write '} ${relative(root, target)} (${f.path} exists; diff: git diff --no-index ${f.path} ${relative(root, target)})`); + } else { + out.created.push(f.path); + log('import', `${dry ? 'would create' : 'create'} ${f.path}${f.from ? ` <- ${f.src ?? relative(root, f.from)}` : ''}`); + } + if (dry) continue; + mkdirSync(dirname(target), { recursive: true }); + if (f.text !== undefined) writeFileSync(target, f.text); else copyFileSync(f.from!, target); + } + return out; +} + +/** The report: what went where, what did not, secrets named (never copied), placeholders, warnings. */ +export function report(plan: ImportPlan): string { + const lines: string[] = []; + const sec = (title: string, rows: string[]) => { if (rows.length) lines.push('', `${title}:`, ...rows.map(r => ` - ${r}`)); }; + lines.push(`fastlane import: "${plan.appName}", profile ${plan.profile}, ${plan.languages.length} language(s)` + + `${plan.bundleId ? `, iOS ${plan.bundleId}` : ''}${plan.packageName ? `, Android ${plan.packageName}` : ''}`); + sec('mapped', plan.mapped.map(m => `${m.from} -> ${m.to}`)); + sec('not mapped (do these by hand)', plan.notMapped.map(n => `${n.what}: ${n.where}`)); + sec('keys, accounts and personal data (named only, nothing copied)', plan.secrets.map(n => `${n.what}: ${n.where}`)); + sec('placeholders to replace in storebound.config.ts', plan.placeholders.map(n => `${n.what}: ${n.where}`)); + sec('warnings', plan.warnings); + return lines.join('\n'); +} + +export async function run(args: Args): Promise { + const what = args._[0]; + if (what !== 'fastlane') throw new Error('usage: storebound import fastlane [--dir ] [--out ] [--profile app|game|subscription-app] [--dry]'); + const root = resolve(args.str('out', '.')); + const profile = args.get('profile') as AppProfile | undefined; + if (profile !== undefined && !PROFILES.includes(profile)) throw new Error(`--profile ${profile}: one of ${PROFILES.join(', ')}`); + const dirs = args.list('dir').length ? args.list('dir').map(d => resolve(d)) : findFastlaneDirs(root); + if (!dirs.length) throw new Error(`no fastlane folder in ${root} (looked for fastlane/, ios/fastlane/, android/fastlane/); pass --dir`); + for (const d of dirs) if (!existsSync(d)) throw new Error(`--dir ${d}: not found`); + const dry = args.has('dry'); + log('import', `fastlane: ${dirs.map(d => relative(root, d) || '.').join(', ')} -> ${root}${dry ? ' (dry run: nothing is written)' : ''}`); + const plan = planImport(scanFastlane(dirs), { root, profile }); + const applied = applyPlan(plan, root, dry); + console.log(report(plan)); + if (applied.imported.length) warn('import', `${applied.imported.length} file(s) existed and were kept: compare the .imported copies (git diff --no-index ) and merge what you want`); + log('import', dry ? 'dry run: run again without --dry to write the files' + : 'next: `storebound init` (adds the remaining templates, keeps these files), replace the placeholders, then `storebound check`'); +} diff --git a/cli/index.ts b/cli/index.ts index afef85e..58aaba9 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -10,6 +10,7 @@ type Plain = { run(args: ReturnType): Promise }; /** Commands that work without a storebound.config.ts. */ const PLAIN: Record Promise, string]> = { 'init': [() => import('./init'), 'copy the templates (config, listing, copy, declarations, privacy policy) into this project; --profile app|game|subscription-app (default app)'], + 'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'], 'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'], 'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'], 'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'], diff --git a/cli/lib/fastlane.ts b/cli/lib/fastlane.ts new file mode 100644 index 0000000..e532d8e --- /dev/null +++ b/cli/lib/fastlane.ts @@ -0,0 +1,704 @@ +// Reads an existing fastlane setup (Appfile, Deliverfile, Fastfile / Matchfile hints, deliver and supply metadata, +// screenshots) and plans the storebound files for it: storebound.config.ts, store/listing.md, store/appstore.md and, +// with screenshots, a `folder` capture source (store/shots/frames + store/shots/copy.json). Only reads: Ruby is never +// run, only plain `key("value")` lines are taken, everything else is reported as "set by hand". +// Secrets and personal data never enter the plan's files: key files are named as references to move into .storebound/, +// App Review contact and demo account files are reported (the owner copies them), team / app ids become placeholders. +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { imageInfo } from '../shots/spec'; +import { APP_PALETTES, PALETTES } from '../shots/lib'; +import type { AppProfile, Language } from './config'; +import { APPLE_LIMITS, len, PLAY_LIMITS, parseAppStoreListing, parsePlayListing } from './copy'; +import { appleLocale, languageName, pairLanguages, playLocale } from './store-locales'; + +// --------------------------------------------------------------------------------------------------------------- +// Ruby option files (Appfile, Deliverfile, Matchfile): plain `key("value")` / `key "value"` lines only. + +export type RubyValue = string | boolean | number | string[]; +export interface RubyOptions { + /** Top-level options with a literal value. */ + values: Record; + /** Options set in a way storebound will not guess: inside a block (for_platform / for_lane / if), from ENV, a + * method call or string interpolation. Only the key, never the value. */ + byHand: { key: string; line: number; why: string }[]; +} + +const STR = /^(["'])((?:\\.|(?!\1).)*)\1$/; + +function literal(raw: string): RubyValue | undefined { + const v = raw.trim(); + const s = STR.exec(v); + if (s) return s[1] === '"' && /#\{/.test(s[2]!) ? undefined : s[2]!.replace(/\\(["'\\])/g, '$1'); + if (v === 'true' || v === 'false') return v === 'true'; + if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v); + const arr = /^\[([\s\S]*)\]$/.exec(v); + if (arr) { + const items = arr[1]!.split(',').map(x => x.trim()).filter(Boolean); + const out: string[] = []; + for (const it of items) { const m = STR.exec(it); if (!m || /#\{/.test(m[2]!)) return undefined; out.push(m[2]!); } + return out; + } + return undefined; +} + +/** Parses the simple option lines of an Appfile / Deliverfile / Matchfile. Pure. */ +export function parseRubyOptions(text: string): RubyOptions { + const values: Record = {}; + const byHand: RubyOptions['byHand'] = []; + let depth = 0; + text.split(/\r?\n/).forEach((raw, i) => { + const line = raw.replace(/\s+#(?![{]).*$/, '').trim(); + if (!line || line.startsWith('#')) return; + if (/^end\b/.test(line)) { depth = Math.max(0, depth - 1); return; } + const opens = /\bdo(\s*\|[^|]*\|)?$/.test(line) || /^(if|unless|case|begin|while|until)\b/.test(line); + const m = /^([a-z_][a-z0-9_]*)(?:\s*\(\s*([\s\S]*?)\s*\)|\s+([\s\S]+?))?$/.exec(line); + if (opens) { if (m && depth === 0 && !/^(if|unless|case|begin|while|until|for_platform|for_lane|platform|lane)$/.test(m[1]!)) byHand.push({ key: m[1]!, line: i + 1, why: 'opens a block' }); depth++; return; } + if (!m) { const k = /^([a-z_][a-z0-9_]*)\s*[("']/.exec(line); if (k) byHand.push({ key: k[1]!, line: i + 1, why: depth > 0 ? 'inside a block (for_platform / for_lane / if)' : 'not a plain value' }); return; } + const key = m[1]!; + const arg = m[2] ?? m[3]; + if (arg === undefined) return; + if (depth > 0) { byHand.push({ key, line: i + 1, why: 'inside a block (for_platform / for_lane / if)' }); return; } + const v = literal(arg); + if (v === undefined) { byHand.push({ key, line: i + 1, why: /ENV\[/.test(arg) ? 'from an environment variable' : 'not a plain value' }); return; } + if (key in values) { byHand.push({ key, line: i + 1, why: 'set twice (the first one is used)' }); return; } + values[key] = v; + }); + return { values, byHand }; +} + +// --------------------------------------------------------------------------------------------------------------- +// What a fastlane folder holds. + +export interface DeliverLocale { + /** Folder name as found. */ + folder: string; + /** App Store locale it maps to. */ + locale: string; + known: boolean; + fields: Record; + /** Fields that came from metadata/default. */ + fromDefault: string[]; + /** .txt files deliver knows that storebound does not map, and unknown ones. */ + extra: string[]; +} + +export interface SupplyLocale { + folder: string; + /** Play language code it maps to. */ + code: string; + known: boolean; + fields: Record; + /** Changelog file used for "What's new" (relative to the locale folder). */ + changelog?: string; + images: Record; + shots: Record<'phone' | 'seven' | 'ten' | 'tv' | 'wear', string[]>; + extra: string[]; +} + +export interface FastlaneScan { + dirs: string[]; + appfile: RubyOptions; + deliverfile: RubyOptions; + matchfile: RubyOptions | null; + /** Fastfile hints: lane names and the actions storebound has its own answer for. */ + fastfile: { lanes: string[]; actions: string[] } | null; + deliver: { metadata: string | null; locales: DeliverLocale[]; root: Record; review: string[]; default: boolean; screenshots: string | null; shotLocales: Record; skippedDirs: string[] }; + supply: { metadata: string | null; locales: SupplyLocale[] }; +} + +const DELIVER_FIELDS: Record = { + 'name.txt': 'name', 'subtitle.txt': 'subtitle', 'description.txt': 'description', 'keywords.txt': 'keywords', + 'promotional_text.txt': 'promotionalText', 'release_notes.txt': 'whatsNew', 'privacy_url.txt': 'privacyUrl', + 'support_url.txt': 'supportUrl', 'marketing_url.txt': 'marketingUrl', +}; +const FILE_OF: Record = Object.fromEntries(Object.entries(DELIVER_FIELDS).map(([f, k]) => [k, f])); +const DELIVER_ROOT = ['copyright.txt', 'primary_category.txt', 'secondary_category.txt', 'primary_first_sub_category.txt', + 'primary_second_sub_category.txt', 'secondary_first_sub_category.txt', 'secondary_second_sub_category.txt']; +const DELIVER_SPECIAL = new Set(['default', 'review_information', 'trade_representative_contact_information', 'android']); +const SUPPLY_FIELDS: Record = { 'title.txt': 'title', 'short_description.txt': 'short', 'full_description.txt': 'full', 'video.txt': 'video' }; +const IMG = /\.(png|jpe?g)$/i; + +const readText = (p: string): string => readFileSync(p, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').trim(); +const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } }; +const isFile = (p: string): boolean => { try { return statSync(p).isFile(); } catch { return false; } }; +const ls = (p: string): string[] => (isDir(p) ? readdirSync(p).filter(n => !n.startsWith('.')).sort() : []); +const readOpts = (p: string): RubyOptions => (isFile(p) ? parseRubyOptions(readFileSync(p, 'utf8')) : { values: {}, byHand: [] }); + +/** Actions in a Fastfile that storebound has a counterpart for (reported, never run). */ +const FASTFILE_ACTIONS = ['app_store_connect_api_key', 'upload_to_app_store', 'deliver', 'upload_to_play_store', 'supply', 'upload_to_testflight', 'pilot', + 'match', 'sync_code_signing', 'snapshot', 'capture_screenshots', 'capture_ios_screenshots', 'screengrab', 'capture_android_screenshots', 'frameit', + 'frame_screenshots', 'increment_build_number', 'increment_version_code', 'latest_testflight_build_number', 'google_play_track_version_codes', + 'precheck', 'check_app_store_metadata', 'gym', 'build_app', 'build_ios_app', 'gradle', 'build_android_app']; + +function scanFastfile(p: string): FastlaneScan['fastfile'] { + if (!isFile(p)) return null; + const text = readFileSync(p, 'utf8'); + const lanes = [...text.matchAll(/^\s*(?:private_)?lane\s+:([a-z0-9_]+)/gm)].map(m => m[1]!); + const actions = FASTFILE_ACTIONS.filter(a => new RegExp(`(^|[^a-z0-9_.:])${a}\\b\\s*(\\(|$|\\s)`, 'm').test(text)); + return { lanes, actions }; +} + +/** Reads one or more fastlane folders (ios/fastlane + android/fastlane, or one fastlane/). Read-only. */ +export function scanFastlane(dirs: string[]): FastlaneScan { + const scan: FastlaneScan = { + dirs, appfile: { values: {}, byHand: [] }, deliverfile: { values: {}, byHand: [] }, matchfile: null, fastfile: null, + deliver: { metadata: null, locales: [], root: {}, review: [], default: false, screenshots: null, shotLocales: {}, skippedDirs: [] }, + supply: { metadata: null, locales: [] }, + }; + const merge = (into: RubyOptions, from: RubyOptions, file: string) => { + for (const [k, v] of Object.entries(from.values)) if (!(k in into.values)) into.values[k] = v; else if (JSON.stringify(into.values[k]) !== JSON.stringify(v)) into.byHand.push({ key: k, line: 0, why: `also set differently in ${file}` }); + into.byHand.push(...from.byHand); + }; + for (const dir of dirs) { + merge(scan.appfile, readOpts(join(dir, 'Appfile')), join(dir, 'Appfile')); + merge(scan.deliverfile, readOpts(join(dir, 'Deliverfile')), join(dir, 'Deliverfile')); + if (isFile(join(dir, 'Matchfile'))) { scan.matchfile ??= { values: {}, byHand: [] }; merge(scan.matchfile, readOpts(join(dir, 'Matchfile')), join(dir, 'Matchfile')); } + const ff = scanFastfile(join(dir, 'Fastfile')); + if (ff) scan.fastfile = { lanes: [...(scan.fastfile?.lanes ?? []), ...ff.lanes], actions: [...new Set([...(scan.fastfile?.actions ?? []), ...ff.actions])] }; + // fastlane runs from the folder that contains fastlane/, so Deliverfile paths are relative to it + const base = dirname(dir); + const dv = readOpts(join(dir, 'Deliverfile')).values; + const meta = typeof dv.metadata_path === 'string' ? resolve(base, dv.metadata_path) : join(dir, 'metadata'); + const shots = typeof dv.screenshots_path === 'string' ? resolve(base, dv.screenshots_path) : join(dir, 'screenshots'); + if (!scan.deliver.metadata) readDeliver(scan, meta); + if (!scan.deliver.screenshots) readDeliverShots(scan, shots); + if (!scan.supply.metadata) readSupply(scan, join(dir, 'metadata', 'android')); + } + return scan; +} + +function readDeliver(scan: FastlaneScan, meta: string): void { + const folders = ls(meta).filter(n => isDir(join(meta, n))); + const locales = folders.filter(n => !DELIVER_SPECIAL.has(n)); + const hasDefault = folders.includes('default'); + const rootFiles = DELIVER_ROOT.filter(f => isFile(join(meta, f))); + if (!locales.length && !hasDefault && !rootFiles.length) return; + scan.deliver.metadata = meta; + scan.deliver.default = hasDefault; + for (const f of rootFiles) scan.deliver.root[f.replace(/\.txt$/, '')] = readText(join(meta, f)); + const rev = join(meta, 'review_information'); + scan.deliver.review = ls(rev).filter(f => /\.txt$/.test(f) && readText(join(rev, f)) !== ''); + if (scan.deliver.review.includes('notes.txt')) scan.deliver.root.review_notes = readText(join(rev, 'notes.txt')); + const def: Record = {}; + if (hasDefault) for (const [f, k] of Object.entries(DELIVER_FIELDS)) if (isFile(join(meta, 'default', f))) def[k] = readText(join(meta, 'default', f)); + // only a default folder: the Deliverfile languages, else en-US + const dl = scan.deliverfile.values.languages; + const names = locales.length ? locales : Array.isArray(dl) && dl.length ? dl : hasDefault ? ['en-US'] : []; + for (const folder of names) { + const d = join(meta, folder); + const n = appleLocale(folder); + const fields: Record = {}; + const extra: string[] = []; + for (const f of ls(d)) { + if (!isFile(join(d, f))) continue; + const k = DELIVER_FIELDS[f]; + if (k) { const t = readText(join(d, f)); if (t) fields[k] = t; } else extra.push(f); + } + const fromDefault: string[] = []; + for (const [k, v] of Object.entries(def)) if (!(k in fields) && v) { fields[k] = v; fromDefault.push(k); } + scan.deliver.locales.push({ folder, locale: n.code, known: n.known, fields, fromDefault, extra }); + } +} + +function readDeliverShots(scan: FastlaneScan, dir: string): void { + const out: Record = {}; + for (const folder of ls(dir)) { + const d = join(dir, folder); + if (!isDir(d)) continue; + const files = ls(d).filter(f => IMG.test(f) && isFile(join(d, f))).map(f => join(d, f)); + if (!files.length) continue; + const n = appleLocale(folder); + if (!n.known && folder === 'default') { scan.deliver.skippedDirs.push(folder); continue; } + out[n.code] = files; + } + if (Object.keys(out).length) { scan.deliver.screenshots = dir; scan.deliver.shotLocales = out; } +} + +function readSupply(scan: FastlaneScan, dir: string): void { + const folders = ls(dir).filter(n => isDir(join(dir, n))); + if (!folders.length) return; + scan.supply.metadata = dir; + for (const folder of folders) { + const d = join(dir, folder); + const n = playLocale(folder); + const fields: Record = {}; + const extra: string[] = []; + for (const f of ls(d)) { + if (!isFile(join(d, f))) continue; + const k = SUPPLY_FIELDS[f]; + if (k) { const t = readText(join(d, f)); if (t) fields[k] = t; } else extra.push(f); + } + let changelog: string | undefined; + const cl = ls(join(d, 'changelogs')).filter(f => /\.txt$/.test(f)); + const numbered = cl.filter(f => /^\d+\.txt$/.test(f)).sort((a, b) => parseInt(b) - parseInt(a)); + const pick = numbered[0] ?? (cl.includes('default.txt') ? 'default.txt' : undefined); + if (pick) { const t = readText(join(d, 'changelogs', pick)); if (t) { fields.whatsNew = t; changelog = `changelogs/${pick}`; } } + const img = join(d, 'images'); + const images: Record = {}; + for (const f of ls(img)) if (IMG.test(f) && isFile(join(img, f))) images[f.replace(IMG, '')] = join(img, f); + const list = (sub: string) => ls(join(img, sub)).filter(f => IMG.test(f)).map(f => join(img, sub, f)); + const shots = { phone: list('phoneScreenshots'), seven: list('sevenInchScreenshots'), ten: list('tenInchScreenshots'), tv: list('tvScreenshots'), wear: list('wearScreenshots') }; + scan.supply.locales.push({ folder, code: n.code, known: n.known, fields, changelog, images, shots, extra }); + } +} + +// --------------------------------------------------------------------------------------------------------------- +// The plan. + +export interface PlanFile { + /** Path relative to the project root. */ + path: string; + /** Text content, or a file to copy. */ + text?: string; + from?: string; + /** `from` for the log, relative to the folder fastlane runs from. */ + src?: string; + kind: 'config' | 'listing' | 'appstore' | 'copy' | 'frame' | 'icon' | 'feature'; +} + +export interface Note { what: string; where: string } + +export interface ImportPlan { + profile: AppProfile; + appName: string; + bundleId?: string; + packageName?: string; + languages: Language[]; + files: PlanFile[]; + /** source -> storebound target, for the report. */ + mapped: { from: string; to: string }[]; + /** What was found but not imported, and where it goes by hand. */ + notMapped: Note[]; + /** Keys, accounts and personal data: named, never copied. */ + secrets: Note[]; + /** Values written as placeholders the owner must replace. */ + placeholders: Note[]; + warnings: string[]; +} + +export interface PlanOptions { + /** Project root the paths are relative to (for the report). */ + root: string; + /** --profile; default from the App Store category (GAMES -> game), else app. */ + profile?: AppProfile; +} + +/** App Store Connect category ids (appCategories). deliver takes "Games", "MZGenre.Games" or the id itself. */ +const CATEGORIES = ['BOOKS', 'BUSINESS', 'DEVELOPER_TOOLS', 'EDUCATION', 'ENTERTAINMENT', 'FINANCE', 'FOOD_AND_DRINK', 'GAMES', 'GRAPHICS_AND_DESIGN', + 'HEALTH_AND_FITNESS', 'LIFESTYLE', 'MAGAZINES_AND_NEWSPAPERS', 'MEDICAL', 'MUSIC', 'NAVIGATION', 'NEWS', 'PHOTO_AND_VIDEO', 'PRODUCTIVITY', + 'REFERENCE', 'SHOPPING', 'SOCIAL_NETWORKING', 'SPORTS', 'STICKERS', 'TRAVEL', 'UTILITIES', 'WEATHER']; +const GAME_SUBS = ['ACTION', 'ADVENTURE', 'BOARD', 'CARD', 'CASINO', 'CASUAL', 'FAMILY', 'MUSIC', 'PUZZLE', 'RACING', 'ROLE_PLAYING', 'SIMULATION', 'SPORTS', 'STRATEGY', 'TRIVIA', 'WORD']; + +/** "Games", "MZGenre.Games", "Photo & Video", "GAMES" -> "GAMES" (null when unknown). Game subcategories: "GAMES_PUZZLE". */ +export function categoryId(raw: string, sub = false): string | null { + const s = raw.trim().replace(/^MZGenre\./i, '').replace(/^Apps\./i, '').replace(/&/g, 'AND').replace(/[\s.-]+/g, '_').replace(/[^A-Za-z_]/g, '').toUpperCase(); + if (!s) return null; + if (sub) { const g = s.replace(/^GAMES_/, ''); return GAME_SUBS.includes(g) ? `GAMES_${g}` : null; } + return CATEGORIES.includes(s) ? s : s === 'PHOTOGRAPHY' ? 'PHOTO_AND_VIDEO' : null; +} + +/** Single-quoted TypeScript string. */ +export function tsString(s: string): string { + return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`; +} + +const fence = (t: string) => '```\n' + t + '\n```'; +const https = (u: string | undefined): string | undefined => (u && /^https:\/\/\S+$/.test(u.trim()) ? u.trim() : undefined); +const YOUTUBE = /^https:\/\/(www\.youtube\.com\/watch\?v=[\w-]{6,}|youtu\.be\/[\w-]{6,})$/; + +/** Keywords "a, b ,c" -> "a,b,c" (spaces after commas count against the 100 bytes on the App Store). */ +export const normalizeKeywords = (k: string): string => k.split(/[,\n]/).map(x => x.trim()).filter(Boolean).join(','); + +/** Image size, null when the header is not PNG / JPEG. */ +function dims(p: string): { w: number; h: number } | null { + try { const i = imageInfo(readFileSync(p)); return i.format !== 'unknown' && i.w && i.h ? { w: i.w, h: i.h } : null; } catch { return null; } +} + +type Device = 'phone' | 'tablet'; +/** deliver screenshots of one locale -> phone / tablet sets. A folder often holds several iPhone sizes (snapshot): the + * largest one wins per class. Watch, Apple TV, Mac and iMessage shots are left out. */ +export function classifyAppleShots(files: string[]): { phone: string[]; tablet: string[]; skipped: string[] } { + const out = { phone: [] as string[], tablet: [] as string[], skipped: [] as string[] }; + const by: Record = { phone: [], tablet: [] }; + for (const f of files) { + const name = basename(f); + if (/watch|apple ?tv|appletv|\bmac\b|desktop|imessage|messages/i.test(name)) { out.skipped.push(f); continue; } + const d = dims(f); + const tablet = /ipad/i.test(name) || (!!d && Math.min(d.w, d.h) / Math.max(d.w, d.h) > 0.7); + by[tablet ? 'tablet' : 'phone'].push({ f, area: d ? d.w * d.h : 0 }); + } + for (const k of ['phone', 'tablet'] as Device[]) { + const max = Math.max(0, ...by[k].map(x => x.area)); + for (const x of by[k]) (x.area === max ? out[k] : out.skipped).push(x.f); + } + return out; +} + +const MAX_SCENES = 10; + +export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan { + // sources are shown relative to the folder fastlane runs from (the parent of fastlane/) + const bases = scan.dirs.map(d => dirname(d)); + const rel = (p: string) => { const b = bases.find(x => p.startsWith(x + sep)); return b ? relative(b, p) : relative(o.root, p) || '.'; }; + const mapped: ImportPlan['mapped'] = []; + const notMapped: Note[] = []; + const secrets: Note[] = []; + const placeholders: Note[] = []; + const warnings: string[] = []; + const files: PlanFile[] = []; + const A = scan.appfile.values, D = scan.deliverfile.values; + + // --- ids + const str = (v: RubyValue | undefined) => (typeof v === 'string' && v ? v : undefined); + const bundleId = str(A.app_identifier) ?? str(D.app_identifier) ?? (Array.isArray(A.app_identifier) ? A.app_identifier[0] : undefined); + const packageName = str(A.package_name); + if (bundleId) mapped.push({ from: 'Appfile app_identifier', to: 'apple.bundleId' }); + if (packageName) mapped.push({ from: 'Appfile package_name', to: 'play.packageName' }); + for (const k of ['team_id', 'itc_team_id', 'team_name', 'itc_team_name']) + if (k in A || k in D) secrets.push({ what: `${k} (Appfile / Deliverfile)`, where: 'not written: storebound finds the team from the App Store Connect API key (ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json)' }); + if ('apple_id' in A || 'username' in A || 'username' in D) secrets.push({ what: 'apple_id / username (the Apple Account that signs in)', where: 'not written: storebound signs in with an API key, never an Apple Account' }); + for (const k of ['json_key_file', 'json_key']) { + const v = A[k] ?? D[k]; + if (v !== undefined) secrets.push({ what: `${k}${typeof v === 'string' ? ` (${v})` : ''}`, where: 'move the Play service account key into .storebound/ (gitignored) and set PLAY_SERVICE_ACCOUNT to its path in .storebound/credentials.json; if it was ever committed, rotate the key' }); + } + if ('json_key_data_raw' in A) secrets.push({ what: 'json_key_data_raw', where: 'PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json), never the config' }); + const appleAppId = D.app ?? D.apple_id ?? A.apple_app_id; + const appIdFound = appleAppId !== undefined && /^\d+$/.test(String(appleAppId)); + if (appIdFound) placeholders.push({ what: 'apple.appId', where: 'commented out in storebound.config.ts: the numeric Apple ID was found in the Deliverfile; uncomment and fill it in if you want it pinned (storebound otherwise looks it up by bundle id)' }); + for (const b of [...scan.appfile.byHand, ...scan.deliverfile.byHand]) + notMapped.push({ what: `${b.key}${b.line ? ` (line ${b.line})` : ''}: ${b.why}`, where: 'set by hand in storebound.config.ts if it matters (Ruby logic is never run)' }); + + // --- deliver (App Store) + const dl = [...scan.deliver.locales]; + const sl = [...scan.supply.locales]; + const hasApple = !!bundleId || dl.length > 0 || Object.keys(scan.deliver.shotLocales).length > 0; + const hasPlay = !!packageName || sl.length > 0; + if (!hasApple && !hasPlay) throw new Error(`nothing to import in ${scan.dirs.map(rel).join(', ')}: no Appfile ids, no metadata// and no metadata/android//`); + for (const l of dl) if (!l.known) warnings.push(`App Store locale "${l.folder}" (metadata/${l.folder}) is not an App Store Connect locale: imported as is; rename it in store/appstore.md and languages[] (e.g. en-US, de-DE, ja, zh-Hans)`); + for (const l of dl) if (l.locale !== l.folder) mapped.push({ from: `metadata/${l.folder}`, to: `App Store locale ${l.locale}` }); + for (const l of sl) if (!l.known) warnings.push(`Play language "${l.folder}" (metadata/android/${l.folder}) is not a Google Play language code: imported as is; rename it in store/listing.md and languages[] (e.g. en-US, tr-TR, iw-IL, zh-CN)`); + for (const l of sl) if (l.code !== l.folder) mapped.push({ from: `metadata/android/${l.folder}`, to: `Play language ${l.code}` }); + + const appleCodes = [...new Set([...dl.map(l => l.locale), ...(dl.length ? [] : Object.keys(scan.deliver.shotLocales))])]; + const playCodes = [...new Set(sl.map(l => l.code))]; + const languages = pairLanguages(hasApple ? appleCodes : [], hasPlay ? playCodes : []); + if (!languages.length) { languages.push({ id: 'en', ...(hasPlay ? { play: 'en-US' } : {}), ...(hasApple ? { apple: 'en-US' } : {}) }); placeholders.push({ what: 'languages', where: 'no metadata locales were found: en-US was assumed' }); } + for (const l of languages) mapped.push({ from: [l.apple && `deliver ${l.apple}`, l.play && `supply ${l.play}`].filter(Boolean).join(' + '), to: `languages: { id: '${l.id}'${l.play ? `, play: '${l.play}'` : ''}${l.apple ? `, apple: '${l.apple}'` : ''} }` }); + const idOfApple = (c: string) => languages.find(l => l.apple === c)?.id; + const idOfPlay = (c: string) => languages.find(l => l.play === c)?.id; + const order = (x: { apple?: string; play?: string }) => languages.findIndex(l => (x.apple !== undefined && l.apple === x.apple) || (x.play !== undefined && l.play === x.play)); + dl.sort((a, b) => order({ apple: a.locale }) - order({ apple: b.locale })); + sl.sort((a, b) => order({ play: a.code }) - order({ play: b.code })); + const primaryApple = dl.find(l => l.locale === languages[0]?.apple) ?? dl[0]; + const primaryPlay = sl.find(l => l.code === languages[0]?.play) ?? sl.find(l => l.code === 'en-US') ?? sl[0]; + + // --- app + const appName = primaryApple?.fields.name ?? primaryPlay?.fields.title ?? (bundleId ?? packageName ?? 'My App').split('.').pop()!; + if (primaryApple?.fields.name) mapped.push({ from: `metadata/${primaryApple.folder}/name.txt`, to: 'app.name' }); + else if (primaryPlay?.fields.title) mapped.push({ from: `metadata/android/${primaryPlay.folder}/title.txt`, to: 'app.name' }); + else placeholders.push({ what: 'app.name', where: `no name.txt / title.txt: "${appName}" was taken from the app id` }); + const urlOf = (k: 'privacyUrl' | 'supportUrl' | 'marketingUrl') => { + const vals = [...new Set(dl.map(l => l.fields[k]).filter((x): x is string => !!x))]; + const pick = primaryApple?.fields[k] ?? vals[0]; + const from = primaryApple && primaryApple.fields[k] ? `metadata/${primaryApple.fromDefault.includes(k) ? 'default' : primaryApple.folder}/${FILE_OF[k]}` : `metadata//${FILE_OF[k]}`; + if (pick && https(pick)) mapped.push({ from, to: k === 'privacyUrl' ? 'app.privacyPolicyUrl' : k === 'marketingUrl' ? 'app.marketingUrl + app.website' : `app.${k}` }); + if (vals.length > 1) notMapped.push({ what: `${k.replace('Url', '_url')}.txt differs per locale (${vals.length} values)`, where: `storebound has one ${k === 'privacyUrl' ? 'app.privacyPolicyUrl' : `app.${k}`} for every language: the ${primaryApple?.locale ?? 'first'} one was used` }); + if (pick && !https(pick)) warnings.push(`${k.replace('Url', '_url')}.txt is not an https URL ("${pick}"): a placeholder was written instead`); + return https(pick); + }; + const privacy = urlOf('privacyUrl'), support = urlOf('supportUrl'), marketing = urlOf('marketingUrl'); + if (!privacy) placeholders.push({ what: 'app.privacyPolicyUrl', where: 'https://example.com/privacy: set your privacy policy page (privacy-policy skill)' }); + if (!support) placeholders.push({ what: 'app.supportUrl', where: 'https://example.com/support: set your support page' }); + if (!marketing) placeholders.push({ what: 'app.website', where: 'https://example.com: your developer website (Play shows it; app-ads.txt lives there)' }); + placeholders.push({ what: 'app.contactEmail', where: 'support@example.com: the public support email (Play shows it). The App Review contact is not used for this' }); + const copyright = scan.deliver.root.copyright; + if (copyright) mapped.push({ from: 'metadata/copyright.txt', to: 'app.copyright' }); + + // --- categories and profile + const cats: { primary?: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string } = {}; + const catFile: [string, keyof typeof cats, boolean][] = [['primary_category', 'primary', false], ['primary_first_sub_category', 'primarySubOne', true], ['primary_second_sub_category', 'primarySubTwo', true], ['secondary_category', 'secondary', false]]; + for (const [f, k, sub] of catFile) { + const raw = scan.deliver.root[f] ?? str(D[f]); + if (!raw) continue; + const id = categoryId(raw, sub); + if (id) { cats[k] = id; mapped.push({ from: `${f}${scan.deliver.root[f] ? '.txt' : ' (Deliverfile)'}`, to: `apple.info.categories.${k} = ${id}` }); } + else notMapped.push({ what: `${f}: "${raw}"`, where: 'apple.info.categories in storebound.config.ts (App Store Connect ids such as GAMES, PRODUCTIVITY, GAMES_PUZZLE)' }); + } + for (const f of ['secondary_first_sub_category', 'secondary_second_sub_category']) + if (scan.deliver.root[f]) notMapped.push({ what: `${f}.txt`, where: 'set in App Store Connect > App Information (storebound sets primary sub-categories only)' }); + const profile: AppProfile = o.profile ?? (cats.primary === 'GAMES' ? 'game' : 'app'); + const style: 'game' | 'app' = profile === 'game' ? 'game' : 'app'; + + // --- App Review information + const review = scan.deliver.review; + const reviewNotes = scan.deliver.root.review_notes; + if (reviewNotes) mapped.push({ from: 'metadata/review_information/notes.txt', to: 'apple.info.reviewNotes' }); + const contactFiles = review.filter(f => ['first_name.txt', 'last_name.txt', 'phone_number.txt', 'email_address.txt'].includes(f)); + if (contactFiles.length) secrets.push({ what: `review_information/${contactFiles.join(', ')} (App Review contact)`, where: 'personal data: not copied. Put it in .storebound/credentials.json "reviewContact": { firstName, lastName, phone, email } yourself' }); + const demo = review.filter(f => f === 'demo_user.txt' || f === 'demo_password.txt'); + if (demo.length) secrets.push({ what: `review_information/${demo.join(', ')} (App Review demo account)`, where: 'not copied: the owner puts it in .storebound/credentials.json "reviewDemoAccount": { username, password } (review-access skill); reviewAccess.signInRequired was set to true' }); + const signIn = demo.includes('demo_user.txt'); + if (scan.deliver.metadata && isDir(join(scan.deliver.metadata, 'trade_representative_contact_information'))) + notMapped.push({ what: 'trade_representative_contact_information/ (Korea)', where: 'App Store Connect > App Information (personal data, not imported)' }); + + // --- App Store listing + const appleSections: string[] = []; + for (const l of dl) { + const f = l.fields; + const parts: string[] = [`## ${l.locale} · ${languageName(l.locale)}`, '']; + const add = (k: string, v: string | undefined) => { if (v) parts.push(`### ${k}`, fence(v), ''); }; + if (f.name && f.name !== appName) add('name', f.name); + add('subtitle', f.subtitle); + add('promotionalText', f.promotionalText); + if (f.keywords) { + const k = normalizeKeywords(f.keywords); + if (k !== f.keywords) warnings.push(`${l.locale}: keywords normalized (spaces after commas removed; they count against 100 bytes)`); + add('keywords', k); + } + add('description', f.description); + add('whatsNew', f.whatsNew); + const missing = ['subtitle', 'promotionalText', 'keywords', 'description'].filter(k => !f[k]); + if (missing.length) warnings.push(`${l.locale}: no ${missing.join(', ')} in fastlane: add ${missing.length > 1 ? 'them' : 'it'} to store/appstore.md before \`storebound apple listing\``); + for (const [k, lim] of Object.entries(APPLE_LIMITS)) if (f[k] && len(k === 'keywords' ? normalizeKeywords(f[k]!) : f[k]!) > lim) warnings.push(`${l.locale}: ${k} is ${len(f[k]!)} characters > ${lim}`); + const listed = l.fromDefault.filter(k => !/Url$/.test(k)).map(k => FILE_OF[k]); + if (listed.length) mapped.push({ from: `metadata/default/{${listed.join(', ')}}`, to: `store/appstore.md ${l.locale} (no file of its own)` }); + for (const x of l.extra) notMapped.push({ what: `metadata/${l.folder}/${x}`, where: /apple_tv_privacy_policy/.test(x) ? 'Apple TV privacy policy: App Store Connect' : 'not a deliver text storebound maps' }); + appleSections.push(parts.join('\n').trimEnd()); + } + if (dl.length) { + mapped.push({ from: 'metadata//{name, subtitle, promotional_text, keywords, description, release_notes}.txt', to: 'store/appstore.md (name, subtitle, promotionalText, keywords, description, whatsNew)' }); + const md = `# ${appName} · App Store listing copy + +Imported from fastlane deliver (\`storebound import fastlane\`). Source for \`storebound apple listing\`. One +\`## \` section per locale, one fenced block per \`### \`: \`name\` (optional, defaults to \`app.name\`), +\`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 bytes, comma separated, no spaces after commas), +\`description\` (4000), \`whatsNew\` (optional). Privacy policy, support and marketing URL come from +\`storebound.config.ts\`. + +--- + +${appleSections.join('\n\n')} +`; + if (md.includes('\n```\n```')) warnings.push('a text contains a ``` line: check store/appstore.md by hand'); + try { parseAppStoreListing(md); } catch (e) { warnings.push(`store/appstore.md: ${(e as Error).message.replace(/^appstore listing: /, '')} (\`storebound check\` reports it too)`); } + files.push({ path: 'store/appstore.md', text: md, kind: 'appstore' }); + } + + // --- Play listing + if (sl.length) { + const secs: string[] = []; + for (const l of sl) { + const f = l.fields; + const parts = [`## ${languageName(l.code)} (${l.code})`, ``, '']; + const add = (h: string, v: string | undefined) => { if (v) parts.push(`### ${h}`, fence(v), ''); }; + add('Title (max 30)', f.title); + add('Play short description (max 80)', f.short); + add('Play full description (max 4000)', f.full); + add("What's new (max 500)", f.whatsNew); + const missing = [['short', 'short_description.txt'], ['full', 'full_description.txt']].filter(([k]) => !f[k!]).map(([, n]) => n); + if (missing.length) warnings.push(`${l.code}: no ${missing.join(', ')} in fastlane: add it to store/listing.md before \`storebound play listing\``); + const lim: Record = { title: PLAY_LIMITS.title, short: PLAY_LIMITS.short, full: PLAY_LIMITS.full, whatsNew: PLAY_LIMITS.whatsNew }; + for (const [k, n] of Object.entries(lim)) if (f[k] && len(f[k]!) > n) warnings.push(`${l.code}: ${k} is ${len(f[k]!)} characters > ${n}`); + if (l.changelog) mapped.push({ from: `metadata/android/${l.folder}/${l.changelog}`, to: `store/listing.md ${l.code} What's new` }); + for (const x of l.extra) notMapped.push({ what: `metadata/android/${l.folder}/${x}`, where: 'not a supply text storebound maps' }); + secs.push(parts.join('\n').trimEnd()); + } + mapped.push({ from: 'metadata/android//{title, short_description, full_description}.txt', to: 'store/listing.md (title, short, full)' }); + const md = `# ${appName} · Google Play listing copy + +Imported from fastlane supply (\`storebound import fastlane\`). Source for \`storebound play listing\` (title, short + +full description) and \`storebound play release\` (release notes). Every \`## ...\` section with a +\`\` line is a listing; \`### ...\` headings that contain **title**, **short**, **full** and +**what's new**, each followed by one fenced block. Limits: title 30, short 80, full 4000, what's new 500 characters. + +--- + +${secs.join('\n\n')} +`; + try { parsePlayListing(md, appName); } catch (e) { warnings.push(`store/listing.md: ${(e as Error).message.replace(/^listing: /, '')} (\`storebound check\` reports it too)`); } + files.push({ path: 'store/listing.md', text: md, kind: 'listing' }); + } + const video = [...new Set(sl.map(l => l.fields.video).filter((x): x is string => !!x))]; + const playVideo = video.find(v => YOUTUBE.test(v)); + if (playVideo) mapped.push({ from: 'metadata/android//video.txt', to: 'play.video' }); + for (const v of video) if (v !== playVideo) notMapped.push({ what: `video.txt "${v}"`, where: YOUTUBE.test(v) ? 'play.video holds one URL for every language: set the others in Play Console' : 'play.video needs a plain YouTube watch URL' }); + + // --- art: icon, feature graphic, screenshots + let playIcon: string | undefined; + const iconSrc = (primaryPlay?.images.icon ? primaryPlay : sl.find(l => l.images.icon))?.images.icon; + if (iconSrc) { + if (/\.png$/i.test(iconSrc)) { playIcon = 'store/icons/play-512.png'; files.push({ path: playIcon, from: iconSrc, src: rel(iconSrc), kind: 'icon' }); mapped.push({ from: rel(iconSrc), to: `${playIcon} (play.icon)` }); } + else notMapped.push({ what: rel(iconSrc), where: 'play.icon must be a 512 x 512 PNG: convert it and set play.icon' }); + } + for (const l of sl) { + const id = idOfPlay(l.code)!; + const fg = l.images.featureGraphic; + if (fg) { const p = `store/renders/${id}/feature/1024x500${extname(fg).toLowerCase().replace('.jpeg', '.jpg')}`; files.push({ path: p, from: fg, src: rel(fg), kind: 'feature' }); mapped.push({ from: rel(fg), to: p }); } + for (const k of ['promoGraphic', 'tvBanner']) if (l.images[k]) notMapped.push({ what: rel(l.images[k]!), where: 'Play Console > Store listing (storebound uploads icon, feature graphic and phone / tablet screenshots)' }); + for (const k of ['tv', 'wear'] as const) if (l.shots[k].length) notMapped.push({ what: `metadata/android/${l.folder}/images/${k}Screenshots (${l.shots[k].length})`, where: `Play Console > Store listing (${k === 'tv' ? 'Android TV' : 'Wear OS'} screenshots)` }); + } + + // screenshots -> frames: ///NN.png; iOS shots win over Play shots for the same language + const sets = new Map>(); + const why = new Map>(); + let ipad = false; + for (const [loc, list] of Object.entries(scan.deliver.shotLocales)) { + const id = idOfApple(loc); + if (!id) { notMapped.push({ what: `screenshots/${loc}`, where: 'no matching language in the import (no metadata for it)' }); continue; } + const c = classifyAppleShots(list); + if (c.tablet.length) ipad = true; + sets.set(id, { phone: c.phone, tablet: c.tablet }); + why.set(id, { phone: `screenshots/${loc}`, tablet: `screenshots/${loc} (iPad)` }); + if (c.skipped.length) notMapped.push({ what: `screenshots/${loc}: ${c.skipped.length} file(s) (other device sizes, Watch, TV or Mac)`, where: 'storebound renders one phone and one tablet set per language and scales them to every store size' }); + } + for (const l of sl) { + const id = idOfPlay(l.code)!; + const s = sets.get(id) ?? { phone: [], tablet: [] }; + const w = why.get(id) ?? { phone: '', tablet: '' }; + if (!s.phone.length && l.shots.phone.length) { s.phone = l.shots.phone; w.phone = `metadata/android/${l.folder}/images/phoneScreenshots`; } + const tab = l.shots.ten.length ? ['ten', l.shots.ten] as const : l.shots.seven.length ? ['seven', l.shots.seven] as const : null; + if (!s.tablet.length && tab) { s.tablet = [...tab[1]]; w.tablet = `metadata/android/${l.folder}/images/${tab[0]}InchScreenshots`; } + if (s.phone.length || s.tablet.length) { sets.set(id, s); why.set(id, w); } + } + let scenes = 0; + const copyJson: Record> = {}; + for (const [id, s] of sets) { + for (const device of ['phone', 'tablet'] as Device[]) { + const list = s[device]; + if (!list.length) continue; + const png = list.filter(f => /\.png$/i.test(f)); + if (png.length < list.length) notMapped.push({ what: `${why.get(id)![device]}: ${list.length - png.length} JPEG file(s)`, where: `frames must be PNG: convert them into store/shots/frames/${id}/${device}/` }); + const take = png.slice(0, MAX_SCENES); + if (png.length > MAX_SCENES) notMapped.push({ what: `${why.get(id)![device]}: ${png.length - MAX_SCENES} screenshot(s) over ${MAX_SCENES}`, where: 'the App Store takes 10 per set, Google Play 8' }); + take.forEach((f, i) => files.push({ path: `store/shots/frames/${id}/${device}/${String(i + 1).padStart(2, '0')}.png`, from: f, src: rel(f), kind: 'frame' })); + if (take.length) mapped.push({ from: `${why.get(id)![device]} (${take.length})`, to: `store/shots/frames/${id}/${device}/${take.length > 1 ? `01..${String(take.length).padStart(2, '0')}` : '01'}.png` }); + if (device === 'phone') scenes = Math.max(scenes, take.length); + } + } + if (!scenes && [...sets.values()].some(s => s.tablet.length)) scenes = Math.max(...[...sets.values()].map(s => Math.min(MAX_SCENES, s.tablet.filter(f => /\.png$/i.test(f)).length))); + const sceneIds = Array.from({ length: scenes }, (_, i) => `shot-${String(i + 1).padStart(2, '0')}`); + if (scenes) { + for (const l of languages) copyJson[l.id] = Object.fromEntries(sceneIds.map(s => [s, { title: ['', ''] as [string, string], sub: '' }])); + files.push({ path: 'store/shots/copy.json', text: JSON.stringify(copyJson, null, 2) + '\n', kind: 'copy' }); + notMapped.push({ what: 'screenshot captions', where: 'fastlane screenshots are often finished art: write the headlines per scene in store/shots/copy.json and render (`storebound shots render`), or keep uploading the finished screenshots' }); + notMapped.push({ what: 'frame sizes', where: '`storebound shots capture --source folder` checks every frame (missing languages / tablet frames, aspect ratio: full-screen screenshots need the status bar cropped)' }); + } + + // --- deliver / supply options with a storebound home elsewhere + const OPT: Record = { + submit_for_review: 'never automated: the owner presses Submit for review in App Store Connect (AGENTS.md rule 1)', + automatic_release: 'release-management skill (`storebound apple release`, the owner decides)', + phased_release: 'release-management skill (`storebound apple release --phased on`, asks first)', + price_tier: 'apple.info.free / the products and prices CSV (`storebound prices`)', + app_rating_config_path: 'apple.info.ageRating, answered with the owner (store-declarations skill)', + submission_information: 'export compliance: apple.usesNonExemptEncryption (export-compliance skill); content rights: apple.info.contentRights', + reject_if_possible: 'never automated: cancel a submission in App Store Connect', + run_precheck_before_submit: '`storebound check --aso` and `storebound validate`', + skip_screenshots: 'run only the commands you need (`apple listing` vs `apple screenshots`)', skip_metadata: 'run only the commands you need', + skip_binary_upload: 'storebound uploads no iOS binary (Xcode / xcodebuild / Transporter do)', force: 'every storebound write shows a dry run first', + overwrite_screenshots: '`storebound apple screenshots` replaces the sets it uploads', languages: 'languages[] in storebound.config.ts', + ipa: 'storebound uploads no iOS binary', pkg: 'storebound uploads no binary', app_version: '`storebound apple listing --version x`', + build_number: '`storebound apple listing --build N` (explicit number)', platform: 'apple.ipad / screenshotTypes', + copyright: 'app.copyright', primary_category: 'apple.info.categories', secondary_category: 'apple.info.categories', + }; + for (const [k, v] of Object.entries(D)) { + if (['app_identifier', 'metadata_path', 'screenshots_path', 'username', 'team_id', 'team_name', 'itc_team_id', 'itc_team_name', 'app', 'apple_id'].includes(k)) continue; + if (k === 'copyright') continue; + if (k === 'primary_category' || k === 'secondary_category' || k.endsWith('_sub_category')) continue; + notMapped.push({ what: `Deliverfile ${k}${typeof v === 'boolean' ? ` = ${v}` : ''}`, where: OPT[k] ?? 'no storebound equivalent; set in App Store Connect if you need it' }); + } + if (typeof D.copyright === 'string' && !copyright) mapped.push({ from: 'Deliverfile copyright', to: 'app.copyright' }); + const appCopyright = copyright ?? (typeof D.copyright === 'string' ? D.copyright : undefined); + + // --- Fastfile / Matchfile hints + if (scan.matchfile) notMapped.push({ what: `Matchfile (${Object.keys(scan.matchfile.values).filter(k => !/url|password|token|key/i.test(k)).join(', ') || 'options'})`, where: 'code signing stays with match / Xcode: storebound does not sign builds (keep the Matchfile; its git_url and passphrase stay out of storebound)' }); + const ACT: Record = { + app_store_connect_api_key: 'ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json (the .p8 path, never its contents)', + upload_to_app_store: '`storebound apple listing`, `apple screenshots`, `apple previews` (dry run first; submitting stays with the owner)', deliver: '`storebound apple listing` / `apple screenshots`', + upload_to_play_store: '`storebound play listing`, `play images`, `play release ` (testing tracks; production is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`', + upload_to_testflight: '`storebound apple testflight` (testflight skill)', pilot: '`storebound apple testflight` (testflight skill)', + match: 'keep match for signing', sync_code_signing: 'keep match for signing', + snapshot: '`storebound shots capture --source ios-sim`', capture_screenshots: '`storebound shots capture --source ios-sim`', capture_ios_screenshots: '`storebound shots capture --source ios-sim`', + screengrab: '`storebound shots capture --source android-emu`', capture_android_screenshots: '`storebound shots capture --source android-emu`', + frameit: '`storebound shots render` (copy in store/shots/copy.json)', frame_screenshots: '`storebound shots render`', + increment_build_number: '`storebound apple next-build`', latest_testflight_build_number: '`storebound apple next-build`', + increment_version_code: '`storebound play next-version-code`', google_play_track_version_codes: '`storebound play next-version-code`', + precheck: '`storebound check --aso` + `storebound validate`', check_app_store_metadata: '`storebound check --aso` + `storebound validate`', + gym: 'keep your build step (storebound does not build)', build_app: 'keep your build step', build_ios_app: 'keep your build step', gradle: 'keep your build step', build_android_app: 'keep your build step', + }; + if (scan.fastfile) { + if (scan.fastfile.lanes.length) notMapped.push({ what: `Fastfile lanes: ${scan.fastfile.lanes.join(', ')}`, where: 'not converted: the storebound commands below replace the store steps; keep fastlane for building and signing if you like' }); + for (const a of scan.fastfile.actions) (a === 'app_store_connect_api_key' ? secrets : notMapped).push({ what: `Fastfile ${a}`, where: ACT[a] ?? 'no storebound equivalent' }); + } + + // --- config + const q = tsString; + const L: string[] = []; + L.push(`// storebound config imported from fastlane (\`storebound import fastlane\`, profile "${profile}"). Check every value, then`, + '// run `storebound init` (adds the declarations, App Privacy, privacy policy and credentials templates; keeps these files)', + '// and `storebound check`. Credentials NEVER go here (env vars or .storebound/credentials.json, gitignored).', + "// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).", + "import type { ShipConfig } from 'storebound';", '', 'const config: ShipConfig = {', ' app: {', + ` name: ${q(appName)},`, ` profile: '${profile}',`); + if (appCopyright) L.push(` copyright: ${q(appCopyright)},`); + L.push(` privacyPolicyUrl: ${q(privacy ?? 'https://example.com/privacy')},${privacy ? '' : ' // placeholder: your privacy policy page'}`); + if (profile === 'subscription-app') L.push(" termsUrl: 'https://example.com/terms', // placeholder: Terms of Use, required with subscriptions (Apple 3.1.2)"); + L.push(` supportUrl: ${q(support ?? 'https://example.com/support')},${support ? '' : ' // placeholder'}`); + if (marketing) L.push(` marketingUrl: ${q(marketing)},`); + L.push(` website: ${q(marketing ?? 'https://example.com')},${marketing ? '' : ' // placeholder: developer website'}`, + " contactEmail: 'support@example.com', // placeholder: the public support email", ' },', ''); + if (signIn) L.push(' // fastlane had an App Review demo account: put it in .storebound/credentials.json ("reviewDemoAccount"), never here.', + ' reviewAccess: {', ' signInRequired: true,', ' },', ''); + L.push(' // id = folder / copy key; play = Google Play language code; apple = App Store Connect locale', ' languages: ['); + for (const l of languages) L.push(` { id: ${q(l.id)}${l.play ? `, play: ${q(String(l.play))}` : ''}${l.apple ? `, apple: ${q(l.apple)}` : ''} },`); + L.push(' ],', ''); + if (hasPlay) { + L.push(' play: {', ` packageName: ${q(packageName ?? 'com.example.app')},${packageName ? '' : ' // placeholder: no package_name in the Appfile'}`); + const def = languages.find(l => l.play === 'en-US')?.play ?? languages.find(l => l.play)?.play; + if (def) L.push(` defaultLanguage: ${q(String(def))},`); + L.push(" listing: 'store/listing.md',"); + if (playIcon) L.push(` icon: ${q(playIcon)},`); + if (playVideo) L.push(` video: ${q(playVideo)},`); + L.push(' },', ''); + if (!packageName) placeholders.push({ what: 'play.packageName', where: 'com.example.app: no package_name in the Appfile' }); + } + if (hasApple) { + L.push(' apple: {', ` bundleId: ${q(bundleId ?? 'com.example.app')},${bundleId ? '' : ' // placeholder: no app_identifier in the Appfile'}`); + if (appIdFound) L.push(" // appId: '', // found in the Deliverfile; optional"); + L.push(" listing: 'store/appstore.md',", + ' // Export compliance (export-compliance skill): decide it with the owner, then uncomment. false ONLY if the app uses', + ' // no encryption beyond the OS (HTTPS through the system, standard sign-in).', + ' // usesNonExemptEncryption: false,'); + if (ipad) L.push(' ipad: true, // fastlane had iPad screenshots'); + const info: string[] = []; + if (Object.keys(cats).length) info.push(` categories: { ${Object.entries(cats).map(([k, v]) => `${k}: ${q(v!)}`).join(', ')} },`); + if (reviewNotes) info.push(` reviewNotes: ${q(reviewNotes)},`); + if (info.length) L.push(' info: {', ...info, ' },'); + L.push(' },', ''); + if (!bundleId) placeholders.push({ what: 'apple.bundleId', where: 'com.example.app: no app_identifier in the Appfile' }); + } + L.push(" renders: 'store/renders',"); + if (scenes) { + const pal = Object.keys(style === 'app' ? APP_PALETTES : PALETTES); + L.push('', ' // Frames imported from the fastlane screenshots: store/shots/frames///NN.png', + ' shots: {', ` style: '${style}',`, " copy: 'store/shots/copy.json',", " frames: 'store/shots/frames',", " out: 'store/renders',", ' scenes: ['); + sceneIds.forEach((s, i) => L.push(` { id: '${s}', palette: '${pal[i % pal.length]}', frames: [{ src: '${String(i + 1).padStart(2, '0')}.png' }] },`)); + L.push(' ],', ` feature: { frame: '01.png', palette: '${pal[0]}' },`, " capture: { source: 'folder', folder: 'store/shots/frames', shots: {} },", ' },'); + } + L.push('};', '', 'export default config;', ''); + files.unshift({ path: 'storebound.config.ts', text: L.join('\n'), kind: 'config' }); + + return { profile, appName, bundleId, packageName, languages, files, mapped, notMapped, secrets, placeholders, warnings }; +} + +/** Where a text file goes when its target exists: name.imported.ext next to it. */ +export const importedPath = (p: string): string => { + const ext = extname(p); + return `${p.slice(0, p.length - ext.length)}.imported${ext}`; +}; + +/** Default fastlane folders under a project: fastlane/, ios/fastlane/, android/fastlane/ (the ones that exist). */ +export function findFastlaneDirs(root: string): string[] { + return ['fastlane', 'ios/fastlane', 'android/fastlane'].map(d => join(root, d)).filter(isDir); +} diff --git a/cli/lib/store-locales.ts b/cli/lib/store-locales.ts new file mode 100644 index 0000000..b770a4a --- /dev/null +++ b/cli/lib/store-locales.ts @@ -0,0 +1,111 @@ +// Store language codes on both stores, and how one maps onto the other. Pure. +// App Store Connect locales (what fastlane deliver names its metadata folders after) and Google Play language codes +// (what fastlane supply names its folders after) differ for the same language: "tr" vs "tr-TR", "zh-Hans" vs "zh-CN", +// "he" vs "iw-IL". storebound keeps one `languages[]` entry per language with both codes, so an importer needs the pairs. +import type { Language } from './config'; + +/** App Store Connect localizations (App Store Connect Help, "App Store localizations"). The Indian languages, Bangla, + * Urdu and Slovenian were added in 2025. */ +export const APPLE_LOCALES: readonly string[] = [ + 'ar-SA', 'bn-BD', 'ca', 'cs', 'da', 'de-DE', 'el', 'en-AU', 'en-CA', 'en-GB', 'en-US', 'es-ES', 'es-MX', 'fi', 'fr-CA', + 'fr-FR', 'gu-IN', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'ja', 'kn-IN', 'ko', 'ml-IN', 'mr-IN', 'ms', 'nl-NL', 'no', + 'or-IN', 'pa-IN', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl-SI', 'sv', 'ta-IN', 'te-IN', 'th', 'tr', 'uk', 'ur-PK', + 'vi', 'zh-Hans', 'zh-Hant', +]; + +/** Google Play store listing languages (Play Console > Store presence > Translations; the API uses the same codes). */ +export const PLAY_LOCALES: readonly string[] = [ + 'af', 'am', 'ar', 'az-AZ', 'be', 'bg', 'bn-BD', 'ca', 'cs-CZ', 'da-DK', 'de-DE', 'el-GR', 'en-AU', 'en-CA', 'en-GB', + 'en-IN', 'en-SG', 'en-US', 'en-ZA', 'es-419', 'es-ES', 'es-US', 'et', 'eu-ES', 'fa', 'fa-AE', 'fa-AF', 'fa-IR', 'fi-FI', + 'fil', 'fr-CA', 'fr-FR', 'gl-ES', 'gu', 'hi-IN', 'hr', 'hu-HU', 'hy-AM', 'id', 'is-IS', 'it-IT', 'iw-IL', 'ja-JP', + 'ka-GE', 'kk', 'km-KH', 'kn-IN', 'ko-KR', 'ky-KG', 'lo-LA', 'lt', 'lv', 'mk-MK', 'ml-IN', 'mn-MN', 'mr-IN', 'ms', 'ms-MY', + 'my-MM', 'ne-NP', 'nl-NL', 'no-NO', 'pa', 'pl-PL', 'pt-BR', 'pt-PT', 'rm', 'ro', 'ru-RU', 'si-LK', 'sk', 'sl', 'sq', + 'sr', 'sv-SE', 'sw', 'ta-IN', 'te-IN', 'th', 'tr-TR', 'uk', 'ur', 'vi', 'zh-CN', 'zh-HK', 'zh-TW', 'zu', +]; + +/** App Store locale -> the Play code of the same language, where the two differ (the rest are spelled the same). */ +export const APPLE_TO_PLAY: Readonly> = { + 'ar-SA': 'ar', 'cs': 'cs-CZ', 'da': 'da-DK', 'el': 'el-GR', 'es-MX': 'es-419', 'fi': 'fi-FI', 'gu-IN': 'gu', 'he': 'iw-IL', + 'hi': 'hi-IN', 'hu': 'hu-HU', 'it': 'it-IT', 'ja': 'ja-JP', 'ko': 'ko-KR', 'no': 'no-NO', 'pa-IN': 'pa', 'pl': 'pl-PL', + 'ru': 'ru-RU', 'sl-SI': 'sl', 'sv': 'sv-SE', 'tr': 'tr-TR', 'ur-PK': 'ur', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-TW', +}; + +/** Play code -> App Store locale (the inverse of APPLE_TO_PLAY plus Play codes that share one App Store locale). */ +export const PLAY_TO_APPLE: Readonly> = { + ...Object.fromEntries(Object.entries(APPLE_TO_PLAY).map(([a, p]) => [p, a])), + 'ms-MY': 'ms', +}; + +/** Spellings seen in fastlane folders and older tools that are not the store's own code. */ +const APPLE_ALIASES: Readonly> = { + 'en': 'en-US', 'de': 'de-DE', 'fr': 'fr-FR', 'es': 'es-ES', 'nl': 'nl-NL', 'ar': 'ar-SA', 'iw': 'he', 'he-IL': 'he', + 'nb': 'no', 'nb-NO': 'no', 'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant', 'ja-JP': 'ja', 'ko-KR': 'ko', 'it-IT': 'it', + 'ru-RU': 'ru', 'tr-TR': 'tr', 'sv-SE': 'sv', 'da-DK': 'da', 'fi-FI': 'fi', 'no-NO': 'no', 'pl-PL': 'pl', 'cs-CZ': 'cs', + 'el-GR': 'el', 'hu-HU': 'hu', 'hi-IN': 'hi', 'uk-UA': 'uk', 'th-TH': 'th', 'vi-VN': 'vi', 'id-ID': 'id', 'ms-MY': 'ms', + 'ro-RO': 'ro', 'sk-SK': 'sk', 'hr-HR': 'hr', 'ca-ES': 'ca', +}; +const PLAY_ALIASES: Readonly> = { + 'he-IL': 'iw-IL', 'he': 'iw-IL', 'iw': 'iw-IL', 'id-ID': 'id', 'in-ID': 'id', 'nb-NO': 'no-NO', 'nb': 'no-NO', + 'fil-PH': 'fil', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-TW', 'en': 'en-US', 'de': 'de-DE', 'fr': 'fr-FR', 'es': 'es-ES', + 'tr': 'tr-TR', 'ja': 'ja-JP', 'ko': 'ko-KR', 'it': 'it-IT', 'ru': 'ru-RU', 'pt': 'pt-PT', 'nl': 'nl-NL', 'pl': 'pl-PL', +}; + +export interface Normalized { code: string; known: boolean; alias?: string } + +/** A fastlane deliver folder name -> App Store locale. Unknown codes are kept as they are (known: false). */ +export function appleLocale(folder: string): Normalized { + if (APPLE_LOCALES.includes(folder)) return { code: folder, known: true }; + const hit = APPLE_ALIASES[folder] ?? APPLE_LOCALES.find(l => l.toLowerCase() === folder.toLowerCase()); + return hit ? { code: hit, known: true, alias: folder } : { code: folder, known: false }; +} + +/** A fastlane supply folder name -> Play language code. Unknown codes are kept as they are (known: false). */ +export function playLocale(folder: string): Normalized { + if (PLAY_LOCALES.includes(folder)) return { code: folder, known: true }; + const hit = PLAY_ALIASES[folder] ?? PLAY_LOCALES.find(l => l.toLowerCase() === folder.toLowerCase()); + return hit ? { code: hit, known: true, alias: folder } : { code: folder, known: false }; +} + +/** Play code of the same language as an App Store locale, if Play has it. */ +export const appleToPlay = (apple: string): string | undefined => + APPLE_TO_PLAY[apple] ?? (PLAY_LOCALES.includes(apple) ? apple : undefined); + +/** App Store locale of the same language as a Play code, if the App Store has it. */ +export const playToApple = (play: string): string | undefined => + PLAY_TO_APPLE[play] ?? (APPLE_LOCALES.includes(play) ? play : undefined); + +/** + * storebound `languages[]` from the App Store locales and Play codes a project has: one entry per language with both + * codes when both stores have it, else one store's code. Ids are the base language ("en", "tr", "zh"), or the whole + * code in lower case when the base is taken ("en-gb", "pt-pt"). en-US (else the first) comes first. + */ +export function pairLanguages(apple: string[], play: string[], primary?: string): Language[] { + const pairs: { apple?: string; play?: string }[] = []; + const playLeft = new Set(play); + for (const a of [...new Set(apple)]) { + const p = appleToPlay(a); + if (p && playLeft.has(p)) { pairs.push({ apple: a, play: p }); playLeft.delete(p); continue; } + // a Play code that maps back onto this locale (ms-MY -> ms) + const back = [...playLeft].find(x => playToApple(x) === a); + if (back) { pairs.push({ apple: a, play: back }); playLeft.delete(back); continue; } + pairs.push({ apple: a }); + } + for (const p of playLeft) pairs.push({ play: p }); + const tag = (x: { apple?: string; play?: string }) => x.apple ?? playToApple(x.play!) ?? x.play!; + const first = (x: { apple?: string; play?: string }) => (primary ? x.apple === primary || x.play === primary : x.apple === 'en-US' || x.play === 'en-US'); + pairs.sort((x, y) => Number(first(y)) - Number(first(x)) || tag(x).localeCompare(tag(y))); + const used = new Set(); + return pairs.map(x => { + const t = tag(x); + let id = t.split('-')[0]!.toLowerCase(); + if (used.has(id)) id = t.toLowerCase(); + for (let n = 2; used.has(id); n++) id = `${t.toLowerCase()}-${n}`; + used.add(id); + return { id, ...(x.play ? { play: x.play } : {}), ...(x.apple ? { apple: x.apple } : {}) }; + }); +} + +/** English name of a language code for headings ("tr-TR" -> "Turkish"), else the code. */ +export function languageName(code: string): string { + try { return new Intl.DisplayNames(['en'], { type: 'language' }).of(code.replace(/^iw\b/, 'he')) ?? code; } catch { return code; } +} diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 2230dd2..7e45c4c 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -206,6 +206,11 @@ export const TOOLS: ToolDef[] = [ required: ['variant'], args: i => [...opt('variant', i.variant), ...opt('experiment', i.experiment), ...opt('only', i.only)] }, { name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true, description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' }, + { name: 'import_fastlane', title: 'Import a fastlane setup', command: ['import', 'fastlane'], write: true, network: false, idempotent: true, + description: 'Read the project\'s fastlane folder(s) (Appfile, Deliverfile, deliver + supply metadata, screenshots) and plan storebound.config.ts, store/appstore.md, store/listing.md and screenshot frames (folder capture source). Local files only; existing files are never overwritten (a .imported. copy goes next to them). Keys, team ids, the App Review contact and demo account are only named, never copied. dry_run prints the plan and the report of what is not mapped.', + props: { dir: { type: 'string', pattern: '^(?!/)(?!.*\\.\\.)(?!.*,/)[A-Za-z0-9._ /,-]{1,400}$', description: 'fastlane folder(s) relative to the project, comma-separated (default: fastlane, ios/fastlane, android/fastlane)' }, + profile: { type: 'string', enum: ['app', 'game', 'subscription-app'], description: 'Default: game for the GAMES category, else app' } }, + args: i => [...opt('dir', i.dir), ...opt('profile', i.profile)] }, ]; /** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */ diff --git a/cli/serve.ts b/cli/serve.ts index b4548b1..a31f377 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -32,8 +32,9 @@ export type Runner = (argv: string[], o: RunOptions) => Running; const MAX_OUTPUT = 60_000; -/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes). */ -export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release']); +/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes; the fastlane import + * needs a folder without a config, which the desktop first-run guide does not offer yet). */ +export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release', 'import_fastlane']); /** Words no desktop method name may contain. */ export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i; diff --git a/skills/migrate-from-fastlane/SKILL.md b/skills/migrate-from-fastlane/SKILL.md new file mode 100644 index 0000000..3046756 --- /dev/null +++ b/skills/migrate-from-fastlane/SKILL.md @@ -0,0 +1,61 @@ +--- +name: migrate-from-fastlane +description: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` - store ids, App Store and Google Play listing text in every language, categories, review notes, Play icon and feature graphic, screenshots as frames - without copying keys, team ids or the App Review demo account. Use when a project has a fastlane/ (or ios/fastlane, android/fastlane) folder, when the user says "we use fastlane", "migrate from fastlane", or before running `storebound init` in such a project. +--- + +# Migrate from fastlane + +CLI: `storebound import fastlane [--dir fastlane] [--out .] [--profile app|game|subscription-app] [--dry]` (MCP: +`import_fastlane`, `dry_run` defaults to true). It only reads fastlane's files and never runs Ruby. Default `--dir`: +every one of `fastlane/`, `ios/fastlane/`, `android/fastlane/` that exists; pass several with commas. + +## Steps +1. `storebound import fastlane --dry`. Show the owner the plan and the report. The report has five parts: + **mapped** (source -> target), **not mapped** (with where each item goes by hand), **keys, accounts and personal + data** (named only), **placeholders** and **warnings** (missing fields, store limits, unknown locale codes). +2. With the owner's yes, run it without `--dry`. Nothing that exists is overwritten: + - a text file that exists gets a `.imported.` copy next to it. Compare with + `git diff --no-index store/listing.md store/listing.imported.md` and merge what the owner wants; + - an image that exists is kept. +3. `storebound init --profile `: it adds the remaining templates (declarations, App Privacy, Data + safety CSV, privacy policy, `.storebound/credentials.json`) and keeps the imported files. +4. Secrets, done by the owner (never in chat, never in the config; AGENTS.md rules 4 and 9): + - Play `json_key_file`: move the key into `.storebound/`, set `PLAY_SERVICE_ACCOUNT` to its path. If the key was + ever committed, rotate it in Google Cloud. + - `app_store_connect_api_key` in the Fastfile: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_P8` (the .p8 path). + - `review_information/` contact and demo account: `reviewContact` and `reviewDemoAccount` in + `.storebound/credentials.json`. The import sets `reviewAccess.signInRequired: true` when fastlane had a demo user. +5. Replace the placeholders the report lists (`app.contactEmail` always: the App Review contact is personal data and + is not used for it). +6. `storebound check`, then `storebound shots capture --source folder` to check the imported frames. + +## What maps where +| fastlane | storebound | +|---|---| +| Appfile `app_identifier` / `package_name` | `apple.bundleId` / `play.packageName` | +| deliver locale folders + supply language folders | `languages[]`, one entry per language with both codes (`tr` + `tr-TR`, `zh-Hans` + `zh-CN`, `he` + `iw-IL`, `es-MX` + `es-419`) | +| `metadata//name, subtitle, promotional_text, keywords, description, release_notes` (+ `default/`) | `store/appstore.md` (`name`, `subtitle`, `promotionalText`, `keywords` without spaces after commas, `description`, `whatsNew`) | +| `privacy_url`, `support_url`, `marketing_url` | `app.privacyPolicyUrl`, `app.supportUrl`, `app.marketingUrl` + `app.website` (one for all languages) | +| `copyright.txt`, `primary_category.txt` ... | `app.copyright`, `apple.info.categories` (`GAMES` picks the game profile) | +| `review_information/notes.txt` | `apple.info.reviewNotes` | +| `metadata/android//title, short_description, full_description` | `store/listing.md` | +| newest `changelogs/.txt` (else `default.txt`) | Play "What's new" | +| `video.txt` (YouTube) | `play.video` | +| `images/icon.png`, `images/featureGraphic.*` | `store/icons/play-512.png` (`play.icon`), `store/renders//feature/1024x500.*` | +| `screenshots//` (largest iPhone set, iPad), else `phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames///NN.png`, scenes `shot-01`..., `shots.capture.source: 'folder'`, empty copy in `store/shots/copy.json` | + +## Not imported (on purpose) +- `submit_for_review`, `automatic_release`, `reject_if_possible`: the owner submits and releases (AGENTS.md rule 1); + phased release is `release-management`. +- `price_tier`: products and the prices CSV (`store-iap-setup`). Age rating config: `store-declarations`. + Export compliance: `export-compliance` (`apple.usesNonExemptEncryption` stays commented out). +- Lanes, `match`, `gym` / `gradle`: building and signing stay where they are; storebound has no lanes. +- Ruby logic (`for_platform`, `for_lane`, `ENV[...]`, interpolation): reported by key and line, set by hand. +- Promo graphic, TV banner, TV / Wear screenshots, Apple TV privacy policy, Korean trade representative contact: + consoles. + +## Screenshots +fastlane screenshots are often finished store art (framed, captioned). The import puts them in the frames folder so +`shots render` can rebuild them from one source in every size; full-screen captures usually need the status bar cut +(`shots capture --source folder` reports the aspect ratio per frame). If the owner prefers to keep the finished art, +leave the copy empty and upload as before. JPEG screenshots are listed, not copied (frames are PNG). diff --git a/tests/fastlane.test.ts b/tests/fastlane.test.ts new file mode 100644 index 0000000..e0d26e4 --- /dev/null +++ b/tests/fastlane.test.ts @@ -0,0 +1,348 @@ +// storebound import fastlane: locale mapping, Ruby option lines, the plan from tests/fixtures/fastlane-sample and the +// no-overwrite writer. Fixture folders are copied into temp dirs so each case can drop parts of the setup. +import { afterAll, describe, expect, test } from 'bun:test'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { applyPlan, report } from '../cli/import'; +import { validateConfig } from '../cli/lib/config'; +import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; +import { categoryId, classifyAppleShots, findFastlaneDirs, importedPath, normalizeKeywords, parseRubyOptions, planImport, scanFastlane, tsString } from '../cli/lib/fastlane'; +import { APPLE_LOCALES, APPLE_TO_PLAY, appleLocale, appleToPlay, pairLanguages, PLAY_LOCALES, playLocale, playToApple } from '../cli/lib/store-locales'; +import { TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; +import { png } from './fixtures'; + +const SAMPLE = join(import.meta.dir, 'fixtures', 'fastlane-sample', 'fastlane'); +const temps: string[] = []; +const temp = () => { const d = mkdtempSync(join(tmpdir(), 'sb-fastlane-')); temps.push(d); return d; }; +afterAll(() => { for (const d of temps) rmSync(d, { recursive: true, force: true }); }); + +/** A project folder with a copy of the sample fastlane folder, minus the given paths (relative to fastlane/). */ +function project(drop: string[] = [], sub = 'fastlane'): { root: string; fl: string } { + const root = temp(); + const fl = join(root, sub); + cpSync(SAMPLE, fl, { recursive: true }); + for (const p of drop) rmSync(join(fl, p), { recursive: true, force: true }); + return { root, fl }; +} + +const plan = (root: string, dirs: string[], profile?: 'app' | 'game' | 'subscription-app') => planImport(scanFastlane(dirs), { root, profile }); +const fileText = (p: ReturnType, path: string) => p.files.find(f => f.path === path)?.text; + +async function loadPlannedConfig(p: ReturnType): Promise { + const d = temp(); + const f = join(d, 'storebound.config.ts'); + writeFileSync(f, fileText(p, 'storebound.config.ts')!); + return (await import(f)).default; +} + +describe('store locales', () => { + test('every pair names a code the other store has', () => { + for (const [a, p] of Object.entries(APPLE_TO_PLAY)) { + expect(APPLE_LOCALES).toContain(a); + expect(PLAY_LOCALES).toContain(p); + expect(playToApple(p)).toBe(a); + } + }); + test('fastlane deliver folders -> App Store locales', () => { + expect(appleLocale('en-US')).toEqual({ code: 'en-US', known: true }); + expect(appleLocale('zh-Hans')).toEqual({ code: 'zh-Hans', known: true }); + expect(appleLocale('tr-TR')).toEqual({ code: 'tr', known: true, alias: 'tr-TR' }); + expect(appleLocale('en')).toEqual({ code: 'en-US', known: true, alias: 'en' }); + expect(appleLocale('zh-CN').code).toBe('zh-Hans'); + expect(appleLocale('he-IL').code).toBe('he'); + expect(appleLocale('EN-gb')).toEqual({ code: 'en-GB', known: true, alias: 'EN-gb' }); + expect(appleLocale('xx-YY')).toEqual({ code: 'xx-YY', known: false }); + }); + test('fastlane supply folders -> Play codes', () => { + expect(playLocale('tr-TR')).toEqual({ code: 'tr-TR', known: true }); + expect(playLocale('iw-IL').known).toBe(true); + expect(playLocale('he-IL')).toEqual({ code: 'iw-IL', known: true, alias: 'he-IL' }); + expect(playLocale('nb-NO').code).toBe('no-NO'); + expect(playLocale('zh-Hans').code).toBe('zh-CN'); + expect(playLocale('qq')).toEqual({ code: 'qq', known: false }); + }); + test('cross-store pairs', () => { + expect(appleToPlay('tr')).toBe('tr-TR'); + expect(appleToPlay('ja')).toBe('ja-JP'); + expect(appleToPlay('zh-Hant')).toBe('zh-TW'); + expect(appleToPlay('he')).toBe('iw-IL'); + expect(appleToPlay('es-MX')).toBe('es-419'); + expect(appleToPlay('de-DE')).toBe('de-DE'); + expect(appleToPlay('or-IN')).toBeUndefined(); + expect(playToApple('ar')).toBe('ar-SA'); + expect(playToApple('ms-MY')).toBe('ms'); + expect(playToApple('en-IN')).toBeUndefined(); + }); + test('pairLanguages: one entry per language, en-US first, unique ids', () => { + const l = pairLanguages(['tr', 'en-US', 'en-GB', 'zh-Hans', 'ja'], ['en-US', 'tr-TR', 'zh-CN', 'es-419', 'iw-IL']); + expect(l).toEqual([ + { id: 'en', play: 'en-US', apple: 'en-US' }, + { id: 'en-gb', apple: 'en-GB' }, + { id: 'es', play: 'es-419' }, + { id: 'he', play: 'iw-IL' }, + { id: 'ja', apple: 'ja' }, + { id: 'tr', play: 'tr-TR', apple: 'tr' }, + { id: 'zh', play: 'zh-CN', apple: 'zh-Hans' }, + ]); + expect(pairLanguages(['ms'], ['ms-MY'])).toEqual([{ id: 'ms', play: 'ms-MY', apple: 'ms' }]); + expect(pairLanguages(['pt-BR', 'pt-PT'], []).map(x => x.id)).toEqual(['pt', 'pt-pt']); + }); +}); + +describe('fastlane option files', () => { + test('plain lines are read, Ruby logic is reported by key only', () => { + const o = parseRubyOptions([ + 'app_identifier("com.example.app") # comment', + "package_name 'com.example.app'", + 'languages(["en-US", "de-DE"])', + 'submit_for_review(false)', + 'phased_release true', + 'price_tier(0)', + 'username(ENV["FASTLANE_USER"])', + 'team_id("#{prefix}X")', + 'for_platform :ios do', + ' app_identifier "com.example.app.ios"', + 'end', + 'app_identifier("com.example.other")', + ].join('\n')); + expect(o.values).toEqual({ app_identifier: 'com.example.app', package_name: 'com.example.app', languages: ['en-US', 'de-DE'], submit_for_review: false, phased_release: true, price_tier: 0 }); + expect(o.byHand.map(b => `${b.key}:${b.line}`)).toEqual(['username:7', 'team_id:8', 'app_identifier:10', 'app_identifier:12']); + expect(o.byHand[0]!.why).toMatch(/environment/); + expect(JSON.stringify(o.byHand)).not.toContain('FASTLANE_USER'); + }); + test('categories, keywords, TS strings', () => { + expect(categoryId('MZGenre.Productivity')).toBe('PRODUCTIVITY'); + expect(categoryId('Photo & Video')).toBe('PHOTO_AND_VIDEO'); + expect(categoryId('GAMES')).toBe('GAMES'); + expect(categoryId('Puzzle', true)).toBe('GAMES_PUZZLE'); + expect(categoryId('MZGenre.Games.Puzzle'.replace('Games.', 'Games_'), true)).toBe('GAMES_PUZZLE'); + expect(categoryId('Knitting')).toBeNull(); + expect(normalizeKeywords('a, b ,c\nd')).toBe('a,b,c,d'); + expect(tsString("it's\n\\")).toBe("'it\\'s\\n\\\\'"); + }); + test('deliver screenshots: largest iPhone set, iPad as tablet, Watch left out', () => { + const d = temp(); + const f = (n: string, w: number, h: number) => { const p = join(d, n); writeFileSync(p, png(w, h)); return p; }; + const files = [f('iPhone 16 Pro Max-01.png', 1320, 2868), f('iPhone 8 Plus-01.png', 1242, 2208), f('iPad Pro-01.png', 2048, 2732), f('Apple Watch-01.png', 416, 496), f('01_tablet_by_size.png', 1536, 2048)]; + const c = classifyAppleShots(files); + expect(c.phone).toEqual([files[0]!]); + expect(c.tablet).toEqual([files[2]!]); + expect(c.skipped.sort()).toEqual([files[1]!, files[3]!, files[4]!].sort()); + }); +}); + +describe('import plan: iOS + Android (tests/fixtures/fastlane-sample)', () => { + const { root, fl } = project(); + const p = plan(root, [fl]); + + test('ids, languages, profile', () => { + expect(p.bundleId).toBe('com.example.notes'); + expect(p.packageName).toBe('com.example.notes'); + expect(p.profile).toBe('app'); + expect(p.appName).toBe('Example Notes'); + expect(p.languages).toEqual([ + { id: 'en', play: 'en-US', apple: 'en-US' }, + { id: 'de', play: 'de-DE', apple: 'de-DE' }, + { id: 'tr', play: 'tr-TR', apple: 'tr' }, + ]); + }); + + test('the config is valid and holds what fastlane had', async () => { + const c = await loadPlannedConfig(p) as Record; + expect(validateConfig(c)).toEqual([]); + expect(c.app).toMatchObject({ name: 'Example Notes', copyright: '2026 Example Studio', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com' }); + expect(c.reviewAccess).toEqual({ signInRequired: true }); + expect(c.play).toMatchObject({ packageName: 'com.example.notes', defaultLanguage: 'en-US', listing: 'store/listing.md', icon: 'store/icons/play-512.png', video: 'https://www.youtube.com/watch?v=EXAMPLE0001' }); + expect(c.apple).toMatchObject({ bundleId: 'com.example.notes', listing: 'store/appstore.md', ipad: true, info: { categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' }, reviewNotes: 'Sign in with the demo account. Sample lists are already there.' } }); + expect(c.apple.usesNonExemptEncryption).toBeUndefined(); // a compliance answer is never guessed + expect(c.shots.capture).toEqual({ source: 'folder', folder: 'store/shots/frames', shots: {} }); + expect(c.shots.scenes.map((s: { id: string }) => s.id)).toEqual(['shot-01', 'shot-02', 'shot-03']); + }); + + test('listing files parse with the existing parsers', () => { + const play = parsePlayListing(fileText(p, 'store/listing.md')!, p.appName); + expect(play.map(x => x.lang)).toEqual(['en-US', 'de-DE', 'tr-TR']); + expect(play[0]).toMatchObject({ title: 'Example Notes', short: 'Lists and notes that stay in sync on every device', whatsNew: 'Shared lists.' }); // newest changelog + const md = fileText(p, 'store/appstore.md')!; + expect(() => parseAppStoreListing(md)).toThrow(/tr: missing promotionalText/); + const fixed = md.replace('## tr · Turkish\n', '## tr · Turkish\n\n### promotionalText\n```\nYeni\n```\n'); + const apple = parseAppStoreListing(fixed); + expect(Object.keys(apple)).toEqual(['en-US', 'de-DE', 'tr']); + expect(apple['en-US']).toMatchObject({ keywords: 'todo,checklist,groceries,planner,shared,family,tasks', whatsNew: 'Shared lists and a calmer home screen.' }); + expect(apple['en-US']!.name).toBeUndefined(); // same as app.name + expect(p.warnings.join('\n')).toMatch(/tr: no promotionalText/); + }); + + test('screenshots become folder frames; iOS wins over Play for the same language', () => { + const frames = p.files.filter(f => f.kind === 'frame').map(f => `${f.path} <- ${f.src}`); + expect(frames).toContain('store/shots/frames/en/phone/01.png <- fastlane/screenshots/en-US/iPhone 16 Pro Max-01_home.png'); + expect(frames).toContain('store/shots/frames/en/tablet/01.png <- fastlane/screenshots/en-US/iPad Pro (12.9-inch) (6th generation)-01_home.png'); + expect(frames).toContain('store/shots/frames/tr/phone/02.png <- fastlane/metadata/android/tr-TR/images/phoneScreenshots/2_tr-TR.png'); + expect(frames.some(f => /iPhone 8 Plus|Watch/.test(f))).toBe(false); + expect(frames.filter(f => f.startsWith('store/shots/frames/en/phone/'))).toHaveLength(3); + const copy = JSON.parse(fileText(p, 'store/shots/copy.json')!); + expect(Object.keys(copy)).toEqual(['en', 'de', 'tr']); + expect(copy.en['shot-01']).toEqual({ title: ['', ''], sub: '' }); + expect(p.files.find(f => f.kind === 'feature' && f.path === 'store/renders/tr/feature/1024x500.png')).toBeTruthy(); + }); + + test('keys, team ids, the review contact and the demo account never reach a file', () => { + const all = p.files.filter(f => f.text !== undefined).map(f => f.text).join('\n'); + for (const secret of ['example-not-a-password', 'demo@example.com', 'review-contact@example.com', 'developer@example.com', 'TEAMID1234', '000000000', 'play-service-account.json', 'AuthKey', 'Alex', '555 0100', 'git.example.com']) + expect(all).not.toContain(secret); + const r = report(p); + expect(r).not.toContain('example-not-a-password'); + expect(r).not.toContain('review-contact@example.com'); + expect(r).toContain('json_key_file (fastlane/play-service-account.json): move the Play service account key into .storebound/'); + expect(r).toMatch(/demo_password\.txt, demo_user\.txt \(App Review demo account\): not copied/); + }); + + test('the report lists what is not mapped and where it goes', () => { + const r = report(p); + for (const s of ['Deliverfile submit_for_review = false: never automated', 'Deliverfile price_tier:', 'Deliverfile app_rating_config_path: apple.info.ageRating', + 'Fastfile lanes: beta, screenshots, release, deploy', 'Fastfile capture_screenshots: `storebound shots capture --source ios-sim`', 'Matchfile', + 'apple_tv_privacy_policy.txt', 'promoGraphic.png', 'wearScreenshots', 'app_identifier (line 13): inside a block', 'username (line 11): from an environment variable', + 'app.contactEmail']) + expect(r).toContain(s); + }); + + test('a GAMES category picks the game profile unless --profile says otherwise', () => { + const g = project(); + writeFileSync(join(g.fl, 'metadata', 'primary_category.txt'), 'Games\n'); + writeFileSync(join(g.fl, 'metadata', 'primary_first_sub_category.txt'), 'Puzzle\n'); + const gp = plan(g.root, [g.fl]); + expect(gp.profile).toBe('game'); + expect(fileText(gp, 'storebound.config.ts')).toContain("categories: { primary: 'GAMES', primarySubOne: 'GAMES_PUZZLE', secondary: 'UTILITIES' }"); + expect(fileText(gp, 'storebound.config.ts')).toContain("style: 'game'"); + expect(plan(g.root, [g.fl], 'subscription-app').profile).toBe('subscription-app'); + }); +}); + +describe('import plan: one store, unknown locales, missing files', () => { + test('iOS only: no play section, no store/listing.md', async () => { + const { root, fl } = project(['metadata/android']); + writeFileSync(join(fl, 'Appfile'), 'app_identifier("com.example.notes")\n'); + const p = plan(root, [fl]); + expect(p.packageName).toBeUndefined(); + expect(p.files.map(f => f.path)).not.toContain('store/listing.md'); + expect(p.languages).toEqual([{ id: 'en', apple: 'en-US' }, { id: 'de', apple: 'de-DE' }, { id: 'tr', apple: 'tr' }]); + const c = await loadPlannedConfig(p) as Record; + expect(validateConfig(c)).toEqual([]); + expect(c.play).toBeUndefined(); + expect(c.apple.bundleId).toBe('com.example.notes'); + }); + + test('Android only: no apple section, no store/appstore.md, title from supply', async () => { + const { root, fl } = project(['metadata/en-US', 'metadata/de-DE', 'metadata/tr', 'metadata/default', 'metadata/review_information', 'metadata/copyright.txt', + 'metadata/primary_category.txt', 'metadata/secondary_category.txt', 'screenshots', 'Deliverfile', 'Matchfile']); + writeFileSync(join(fl, 'Appfile'), 'json_key_file("keys/play.json")\npackage_name("com.example.notes")\n'); + const p = plan(root, [fl]); + expect(p.bundleId).toBeUndefined(); + expect(p.appName).toBe('Example Notes'); + expect(p.files.map(f => f.path)).not.toContain('store/appstore.md'); + expect(p.languages).toEqual([{ id: 'en', play: 'en-US' }, { id: 'de', play: 'de-DE' }, { id: 'tr', play: 'tr-TR' }]); + const c = await loadPlannedConfig(p) as Record; + expect(validateConfig(c)).toEqual([]); + expect(c.apple).toBeUndefined(); + expect(c.reviewAccess).toBeUndefined(); + expect(p.placeholders.map(x => x.what)).toEqual(expect.arrayContaining(['app.privacyPolicyUrl', 'app.supportUrl', 'app.website', 'app.contactEmail'])); + // Play phone screenshots become the phone frames, the 10" set the tablet frames + expect(p.files.filter(f => f.kind === 'frame').map(f => f.path)).toEqual(expect.arrayContaining(['store/shots/frames/en/phone/01.png', 'store/shots/frames/en/tablet/01.png'])); + expect(p.files.find(f => f.path === 'store/shots/frames/en/tablet/01.png')!.src).toBe('fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png'); + }); + + test('both stores from ios/fastlane + android/fastlane', () => { + const root = temp(); + cpSync(SAMPLE, join(root, 'ios', 'fastlane'), { recursive: true }); + rmSync(join(root, 'ios', 'fastlane', 'metadata', 'android'), { recursive: true }); + writeFileSync(join(root, 'ios', 'fastlane', 'Appfile'), 'app_identifier("com.example.notes")\n'); + mkdirSync(join(root, 'android', 'fastlane', 'metadata'), { recursive: true }); + cpSync(join(SAMPLE, 'metadata', 'android'), join(root, 'android', 'fastlane', 'metadata', 'android'), { recursive: true }); + writeFileSync(join(root, 'android', 'fastlane', 'Appfile'), 'package_name("com.example.notes.android")\n'); + const dirs = findFastlaneDirs(root); + expect(dirs.map(d => d.slice(root.length + 1))).toEqual(['ios/fastlane', 'android/fastlane']); + const p = plan(root, dirs); + expect(p.bundleId).toBe('com.example.notes'); + expect(p.packageName).toBe('com.example.notes.android'); + expect(p.languages).toHaveLength(3); + expect(p.languages.every(l => l.apple && l.play)).toBe(true); + expect(p.files.map(f => f.path)).toEqual(expect.arrayContaining(['store/appstore.md', 'store/listing.md'])); + }); + + test('unknown locales are imported as they are, with a warning', async () => { + const { root, fl } = project(); + cpSync(join(fl, 'metadata', 'tr'), join(fl, 'metadata', 'xx-YY'), { recursive: true }); + cpSync(join(fl, 'metadata', 'android', 'tr-TR'), join(fl, 'metadata', 'android', 'qq-QQ'), { recursive: true }); + cpSync(join(fl, 'metadata', 'de-DE'), join(fl, 'metadata', 'ja-JP'), { recursive: true }); + const p = plan(root, [fl]); + expect(p.warnings.join('\n')).toContain('App Store locale "xx-YY"'); + expect(p.warnings.join('\n')).toContain('Play language "qq-QQ"'); + expect(p.languages).toEqual(expect.arrayContaining([{ id: 'xx', apple: 'xx-YY' }, { id: 'qq', play: 'qq-QQ' }, { id: 'ja', apple: 'ja' }])); + expect(p.mapped).toContainEqual({ from: 'metadata/ja-JP', to: 'App Store locale ja' }); + expect(fileText(p, 'store/appstore.md')).toContain('## ja · Japanese'); + expect(validateConfig(await loadPlannedConfig(p))).toEqual([]); + }); + + test('missing files: empty folder fails, missing fields and ids become warnings and placeholders', async () => { + const empty = temp(); + mkdirSync(join(empty, 'fastlane')); + expect(() => plan(empty, [join(empty, 'fastlane')])).toThrow(/nothing to import/); + + const { root, fl } = project(['Appfile', 'Deliverfile', 'Fastfile', 'Matchfile', 'screenshots', 'metadata/default', 'metadata/de-DE/description.txt', 'metadata/android/de-DE/short_description.txt', 'metadata/android/en-US/images']); + const p = plan(root, [fl]); + expect(p.warnings.join('\n')).toMatch(/de-DE: no description in fastlane/); + expect(p.warnings.join('\n')).toMatch(/de-DE: no short_description\.txt in fastlane/); + expect(p.placeholders.map(x => x.what)).toEqual(expect.arrayContaining(['apple.bundleId', 'play.packageName', 'app.privacyPolicyUrl'])); + expect(p.files.some(f => f.kind === 'frame' && f.path.startsWith('store/shots/frames/en/'))).toBe(false); + const c = await loadPlannedConfig(p) as Record; + expect(validateConfig(c)).toEqual([]); + expect(c.apple.bundleId).toBe('com.example.app'); + expect(c.play.icon).toBeUndefined(); + }); +}); + +describe('writing the plan', () => { + test('dry run writes nothing; a real run creates the files', () => { + const { root, fl } = project(); + const p = plan(root, [fl]); + const dry = applyPlan(p, root, true); + expect(dry.created).toContain('storebound.config.ts'); + expect(existsSync(join(root, 'storebound.config.ts'))).toBe(false); + expect(existsSync(join(root, 'store'))).toBe(false); + const real = applyPlan(p, root, false); + expect(real.created).toEqual(dry.created); + expect(readFileSync(join(root, 'store', 'listing.md'), 'utf8')).toBe(fileText(p, 'store/listing.md')!); + expect(readFileSync(join(root, 'store/shots/frames/en/phone/01.png'))).toEqual(readFileSync(join(fl, 'screenshots/en-US/iPhone 16 Pro Max-01_home.png'))); + }); + + test('existing files are never overwritten: text goes to .imported, images are kept', () => { + const { root, fl } = project(); + mkdirSync(join(root, 'store', 'icons'), { recursive: true }); + writeFileSync(join(root, 'storebound.config.ts'), '// mine\n'); + writeFileSync(join(root, 'store', 'icons', 'play-512.png'), 'mine'); + const p = plan(root, [fl]); + const r = applyPlan(p, root, false); + expect(readFileSync(join(root, 'storebound.config.ts'), 'utf8')).toBe('// mine\n'); + expect(readFileSync(join(root, 'storebound.config.imported.ts'), 'utf8')).toBe(fileText(p, 'storebound.config.ts')!); + expect(readFileSync(join(root, 'store', 'icons', 'play-512.png'), 'utf8')).toBe('mine'); + expect(r.imported).toEqual([{ target: 'storebound.config.ts', written: 'storebound.config.imported.ts' }]); + expect(r.kept).toEqual(['store/icons/play-512.png']); + expect(importedPath('store/shots/copy.json')).toBe('store/shots/copy.imported.json'); + }); +}); + +describe('MCP tool', () => { + const t = TOOLS.find(x => x.name === 'import_fastlane')!; + test('local write, dry run by default', () => { + expect(t).toMatchObject({ write: true, network: false, command: ['import', 'fastlane'] }); + expect(toArgv(t, {}).argv).toEqual(['import', 'fastlane', '--dry']); + expect(toArgv(t, { dir: 'ios/fastlane,android/fastlane', profile: 'game', dry_run: false }).argv).toEqual(['import', 'fastlane', '--dir', 'ios/fastlane,android/fastlane', '--profile', 'game']); + }); + test('dir stays inside the project', () => { + expect(validateInput(t, { dir: 'fastlane' })).toEqual([]); + expect(validateInput(t, { dir: '../other/fastlane' }).length).toBe(1); + expect(validateInput(t, { dir: '/etc' }).length).toBe(1); + expect(validateInput(t, { dir: 'fastlane,/etc' }).length).toBe(1); + }); +}); diff --git a/tests/fixtures/fastlane-sample/fastlane/Appfile b/tests/fixtures/fastlane-sample/fastlane/Appfile new file mode 100644 index 0000000..2235d72 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/Appfile @@ -0,0 +1,15 @@ +# Appfile: shared by deliver, supply, match and pilot +app_identifier("com.example.notes") # The bundle identifier of your app +apple_id("developer@example.com") # Your Apple Developer Portal username + +itc_team_id("000000000") # App Store Connect Team ID +team_id("TEAMID1234") # Developer Portal Team ID + +json_key_file("fastlane/play-service-account.json") # Path to the json secret file +package_name("com.example.notes") # e.g. com.example.app + +for_platform :ios do + for_lane :beta do + app_identifier "com.example.notes.beta" + end +end diff --git a/tests/fixtures/fastlane-sample/fastlane/Deliverfile b/tests/fixtures/fastlane-sample/fastlane/Deliverfile new file mode 100644 index 0000000..aa24c6f --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/Deliverfile @@ -0,0 +1,11 @@ +# The Deliverfile allows you to store various App Store Connect metadata +# https://docs.fastlane.tools/actions/deliver/ + +languages(["en-US", "de-DE", "tr"]) +submit_for_review(false) +automatic_release(true) +phased_release true +price_tier(0) +app_rating_config_path("./fastlane/rating_config.json") +precheck_include_in_app_purchases(false) +username(ENV["FASTLANE_USER"]) diff --git a/tests/fixtures/fastlane-sample/fastlane/Fastfile b/tests/fixtures/fastlane-sample/fastlane/Fastfile new file mode 100644 index 0000000..4471003 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/Fastfile @@ -0,0 +1,28 @@ +default_platform(:ios) + +platform :ios do + desc "Push a new beta build to TestFlight" + lane :beta do + app_store_connect_api_key(key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_filepath: "./fastlane/AuthKey.p8") + increment_build_number(xcodeproj: "Notes.xcodeproj") + match(type: "appstore") + build_app(scheme: "Notes") + upload_to_testflight + end + + lane :screenshots do + capture_screenshots + frameit(white: true) + end + + lane :release do + deliver(submit_for_review: false, force: true) + end +end + +platform :android do + lane :deploy do + gradle(task: "bundle", build_type: "Release") + upload_to_play_store(track: "internal") + end +end diff --git a/tests/fixtures/fastlane-sample/fastlane/Matchfile b/tests/fixtures/fastlane-sample/fastlane/Matchfile new file mode 100644 index 0000000..48510be --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/Matchfile @@ -0,0 +1,4 @@ +git_url("https://git.example.com/ios-certificates.git") +storage_mode("git") +type("appstore") +app_identifier(["com.example.notes"]) diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/41.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/41.txt new file mode 100644 index 0000000..a99e769 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/41.txt @@ -0,0 +1 @@ +Bug fixes. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/42.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/42.txt new file mode 100644 index 0000000..8e1967a --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/42.txt @@ -0,0 +1 @@ +Geteilte Listen. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/default.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/default.txt new file mode 100644 index 0000000..43057e9 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/default.txt @@ -0,0 +1 @@ +Bug fixes and improvements. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/full_description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/full_description.txt new file mode 100644 index 0000000..76ef1d3 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/full_description.txt @@ -0,0 +1,4 @@ +Example Notes: Listen und Notizen, auf allen Geräten synchron. + +• Share a list +• Export your data diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/featureGraphic.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/featureGraphic.png new file mode 100644 index 0000000..1d5b18a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/featureGraphic.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/1_de-DE.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/1_de-DE.png new file mode 100644 index 0000000..c908ca1 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/1_de-DE.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/2_de-DE.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/2_de-DE.png new file mode 100644 index 0000000..c908ca1 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/2_de-DE.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/short_description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/short_description.txt new file mode 100644 index 0000000..beba1bb --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +Listen und Notizen, auf allen Geräten synchron diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/title.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/title.txt new file mode 100644 index 0000000..f58c473 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/title.txt @@ -0,0 +1 @@ +Example Notes diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/41.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/41.txt new file mode 100644 index 0000000..a99e769 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/41.txt @@ -0,0 +1 @@ +Bug fixes. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/42.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/42.txt new file mode 100644 index 0000000..ff97e17 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/42.txt @@ -0,0 +1 @@ +Shared lists. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/default.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/default.txt new file mode 100644 index 0000000..43057e9 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/default.txt @@ -0,0 +1 @@ +Bug fixes and improvements. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/full_description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..ad40865 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,4 @@ +Example Notes: Lists and notes that stay in sync on every device. + +• Share a list +• Export your data diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/featureGraphic.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..1d5b18a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/icon.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..5b2ac24 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png new file mode 100644 index 0000000..c908ca1 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png new file mode 100644 index 0000000..c908ca1 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/promoGraphic.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/promoGraphic.png new file mode 100644 index 0000000..336ed3a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/promoGraphic.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/sevenInchScreenshots/1_en-US.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/sevenInchScreenshots/1_en-US.png new file mode 100644 index 0000000..4417a3b Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/sevenInchScreenshots/1_en-US.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png new file mode 100644 index 0000000..357fd0c Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/wearScreenshots/1_en-US.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/wearScreenshots/1_en-US.png new file mode 100644 index 0000000..026448f Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/wearScreenshots/1_en-US.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/short_description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..ac7a8c4 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Lists and notes that stay in sync on every device diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/title.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..f58c473 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Example Notes diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/video.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/video.txt new file mode 100644 index 0000000..d28d110 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/video.txt @@ -0,0 +1 @@ +https://www.youtube.com/watch?v=EXAMPLE0001 diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/41.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/41.txt new file mode 100644 index 0000000..a99e769 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/41.txt @@ -0,0 +1 @@ +Bug fixes. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/42.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/42.txt new file mode 100644 index 0000000..b710c55 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/42.txt @@ -0,0 +1 @@ +Paylaşılan listeler. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/default.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/default.txt new file mode 100644 index 0000000..43057e9 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/default.txt @@ -0,0 +1 @@ +Bug fixes and improvements. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/full_description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/full_description.txt new file mode 100644 index 0000000..af1beb1 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/full_description.txt @@ -0,0 +1,4 @@ +Example Notes: Tüm cihazlarında eşitlenen listeler ve notlar. + +• Share a list +• Export your data diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/featureGraphic.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/featureGraphic.png new file mode 100644 index 0000000..1d5b18a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/featureGraphic.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/1_tr-TR.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/1_tr-TR.png new file mode 100644 index 0000000..c908ca1 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/1_tr-TR.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/2_tr-TR.png b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/2_tr-TR.png new file mode 100644 index 0000000..c908ca1 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/2_tr-TR.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/short_description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/short_description.txt new file mode 100644 index 0000000..760e9a6 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/short_description.txt @@ -0,0 +1 @@ +Tüm cihazlarında eşitlenen listeler ve notlar diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/title.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/title.txt new file mode 100644 index 0000000..f58c473 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/title.txt @@ -0,0 +1 @@ +Example Notes diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/copyright.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/copyright.txt new file mode 100644 index 0000000..cb32390 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/copyright.txt @@ -0,0 +1 @@ +2026 Example Studio diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/description.txt new file mode 100644 index 0000000..fb48666 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/description.txt @@ -0,0 +1 @@ +Example Notes hält Listen und Notizen auf allen Geräten synchron. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/keywords.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/keywords.txt new file mode 100644 index 0000000..ccbd270 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/keywords.txt @@ -0,0 +1 @@ +aufgaben,einkauf,planer,familie diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/name.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/name.txt new file mode 100644 index 0000000..f58c473 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/name.txt @@ -0,0 +1 @@ +Example Notes diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/promotional_text.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/promotional_text.txt new file mode 100644 index 0000000..701dd37 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/promotional_text.txt @@ -0,0 +1 @@ +Geteilte Listen sind da. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/release_notes.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/release_notes.txt new file mode 100644 index 0000000..8e1967a --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/release_notes.txt @@ -0,0 +1 @@ +Geteilte Listen. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/subtitle.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/subtitle.txt new file mode 100644 index 0000000..43e6a08 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/subtitle.txt @@ -0,0 +1 @@ +Listen und Notizen, synchron diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/default/marketing_url.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/default/marketing_url.txt new file mode 100644 index 0000000..b7f1369 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/default/marketing_url.txt @@ -0,0 +1 @@ +https://example.com diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/default/privacy_url.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/default/privacy_url.txt new file mode 100644 index 0000000..b1aad14 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/default/privacy_url.txt @@ -0,0 +1 @@ +https://example.com/privacy diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/default/support_url.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/default/support_url.txt new file mode 100644 index 0000000..a6cbe45 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/default/support_url.txt @@ -0,0 +1 @@ +https://example.com/support diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/apple_tv_privacy_policy.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/apple_tv_privacy_policy.txt new file mode 100644 index 0000000..b1aad14 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/apple_tv_privacy_policy.txt @@ -0,0 +1 @@ +https://example.com/privacy diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/description.txt new file mode 100644 index 0000000..2450a51 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/description.txt @@ -0,0 +1,6 @@ +Example Notes keeps lists and notes in sync across your devices. + +FEATURES +• Lists and notes that sync +• Share a list with family or friends +• Export your data any time diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/keywords.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 0000000..0d8f2b6 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +todo, checklist, groceries, planner, shared, family, tasks diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/name.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/name.txt new file mode 100644 index 0000000..f58c473 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/name.txt @@ -0,0 +1 @@ +Example Notes diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/promotional_text.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/promotional_text.txt new file mode 100644 index 0000000..a03f03a --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +Shared lists are here: plan the week together. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/release_notes.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/release_notes.txt new file mode 100644 index 0000000..7a7a7bd --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/release_notes.txt @@ -0,0 +1 @@ +Shared lists and a calmer home screen. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/subtitle.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..7606d4b --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +Lists and notes, in sync diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/primary_category.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/primary_category.txt new file mode 100644 index 0000000..361db3a --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/primary_category.txt @@ -0,0 +1 @@ +MZGenre.Productivity diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_password.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_password.txt new file mode 100644 index 0000000..29321d1 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_password.txt @@ -0,0 +1 @@ +example-not-a-password diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_user.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_user.txt new file mode 100644 index 0000000..5fb4e39 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_user.txt @@ -0,0 +1 @@ +demo@example.com diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/email_address.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/email_address.txt new file mode 100644 index 0000000..988c14c --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/email_address.txt @@ -0,0 +1 @@ +review-contact@example.com diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/first_name.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/first_name.txt new file mode 100644 index 0000000..7aab7dd --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/first_name.txt @@ -0,0 +1 @@ +Alex diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/last_name.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/last_name.txt new file mode 100644 index 0000000..12a719a --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/last_name.txt @@ -0,0 +1 @@ +Example diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/notes.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/notes.txt new file mode 100644 index 0000000..3d1441a --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/notes.txt @@ -0,0 +1 @@ +Sign in with the demo account. Sample lists are already there. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/phone_number.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/phone_number.txt new file mode 100644 index 0000000..ecc3793 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/review_information/phone_number.txt @@ -0,0 +1 @@ ++1 555 0100 diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/secondary_category.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/secondary_category.txt new file mode 100644 index 0000000..19814b6 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/secondary_category.txt @@ -0,0 +1 @@ +Utilities diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/tr/description.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/description.txt new file mode 100644 index 0000000..6b7fc5c --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/description.txt @@ -0,0 +1 @@ +Example Notes listeleri ve notları tüm cihazlarında eşitler. diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/tr/keywords.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/keywords.txt new file mode 100644 index 0000000..1bfa4d8 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/keywords.txt @@ -0,0 +1 @@ +yapılacaklar,market,planlayıcı diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/tr/name.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/name.txt new file mode 100644 index 0000000..f58c473 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/name.txt @@ -0,0 +1 @@ +Example Notes diff --git a/tests/fixtures/fastlane-sample/fastlane/metadata/tr/subtitle.txt b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/subtitle.txt new file mode 100644 index 0000000..e9b7898 --- /dev/null +++ b/tests/fixtures/fastlane-sample/fastlane/metadata/tr/subtitle.txt @@ -0,0 +1 @@ +Listeler ve notlar, eşitli diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-01_home.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-01_home.png new file mode 100644 index 0000000..ab18c9a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-01_home.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-02_list.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-02_list.png new file mode 100644 index 0000000..ab18c9a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-02_list.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-03_share.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-03_share.png new file mode 100644 index 0000000..ab18c9a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-03_share.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 8 Plus-01_home.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 8 Plus-01_home.png new file mode 100644 index 0000000..27f67b5 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 8 Plus-01_home.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/Apple Watch Series 10-01_home.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/Apple Watch Series 10-01_home.png new file mode 100644 index 0000000..3143b13 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/Apple Watch Series 10-01_home.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPad Pro (12.9-inch) (6th generation)-01_home.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPad Pro (12.9-inch) (6th generation)-01_home.png new file mode 100644 index 0000000..aef8208 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPad Pro (12.9-inch) (6th generation)-01_home.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-01_home.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-01_home.png new file mode 100644 index 0000000..ab18c9a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-01_home.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-02_list.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-02_list.png new file mode 100644 index 0000000..ab18c9a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-02_list.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-03_share.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-03_share.png new file mode 100644 index 0000000..ab18c9a Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-03_share.png differ diff --git a/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 8 Plus-01_home.png b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 8 Plus-01_home.png new file mode 100644 index 0000000..27f67b5 Binary files /dev/null and b/tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 8 Plus-01_home.png differ