Compare commits

..

2 commits

Author SHA1 Message Date
CRT
72dda418ea Merge fastlane-import: storebound import fastlane
# Conflicts:
#	CHANGELOG.md
2026-10-11 05:06:15 +08:00
CRT
de681dd967 import fastlane: config, App Store and Play listings, languages and screenshot frames from a fastlane setup
storebound import fastlane [--dir] [--out] [--profile] [--dry] (MCP import_fastlane, dry run by default) reads
Appfile / Deliverfile plain option lines, deliver and supply metadata and screenshots, and writes
storebound.config.ts, store/appstore.md, store/listing.md, the Play icon and feature graphics, and frames with a
folder capture source. App Store and Play language codes are paired (cli/lib/store-locales.ts). Existing files are
never overwritten (.imported copies with a diff hint); keys, team ids, the review contact and demo account are only
named in the report. Fixture tests/fixtures/fastlane-sample, skill migrate-from-fastlane, README and CHANGELOG.
2026-10-11 04:57:03 +08:00
91 changed files with 1469 additions and 3 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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

View file

@ -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
`<name>.imported.<ext>` 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

View file

@ -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 |

View file

@ -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 <name>.imported.<ext> 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/<locale>/*.txt` (+ `default/`) | `store/appstore.md` per App Store locale; URLs, copyright, categories and review notes in the config |
| supply `metadata/android/<lang>/*.txt`, newest `changelogs/<versionCode>.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/<locale>/`, `images/phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames/<lang>/<phone\|tablet>/NN.png` with a `folder` capture source |
| supply `icon`, `featureGraphic` | `play.icon`, `store/renders/<lang>/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 <tool>` | 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`,

73
cli/import.ts Normal file
View file

@ -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 <fastlane dir>[,<dir>]] [--out <project folder>] [--profile app|game|subscription-app] [--dry]
// fastlane: Appfile / Deliverfile ids and options, deliver metadata (fastlane/metadata/<locale>/*.txt, default/,
// review_information/notes.txt), supply metadata (fastlane/metadata/android/<lang>/*, 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/<lang>/<phone|tablet>/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 <name>.imported.<ext> 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<void> {
const what = args._[0];
if (what !== 'fastlane') throw new Error('usage: storebound import fastlane [--dir <fastlane dir>] [--out <project folder>] [--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 <file> <file.imported>) 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`');
}

View file

@ -10,6 +10,7 @@ type Plain = { run(args: ReturnType<typeof parseArgs>): Promise<void> };
/** Commands that work without a storebound.config.ts. */
const PLAIN: Record<string, [() => Promise<Plain>, 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)'],

704
cli/lib/fastlane.ts Normal file
View file

@ -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<string, RubyValue>;
/** 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<string, RubyValue> = {};
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<string, string>;
/** 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<string, string>;
/** Changelog file used for "What's new" (relative to the locale folder). */
changelog?: string;
images: Record<string, string>;
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<string, string>; review: string[]; default: boolean; screenshots: string | null; shotLocales: Record<string, string[]>; skippedDirs: string[] };
supply: { metadata: string | null; locales: SupplyLocale[] };
}
const DELIVER_FIELDS: Record<string, string> = {
'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<string, string> = 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<string, string> = { '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<string, string> = {};
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<string, string> = {};
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<string, string[]> = {};
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<string, string> = {};
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<string, string> = {};
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<Device, { f: string; area: number }[]> = { 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/<locale>/ and no metadata/android/<locale>/`);
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/<locale>/${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/<locale>/{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
\`## <ASC locale>\` section per locale, one fenced block per \`### <field>\`: \`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})`, `<!-- play: ${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<string, number> = { 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/<lang>/{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
\`<!-- play: <codes> -->\` 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/<lang>/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: <frames>/<lang id>/<phone|tablet>/NN.png; iOS shots win over Play shots for the same language
const sets = new Map<string, Record<Device, string[]>>();
const why = new Map<string, Record<Device, string>>();
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<string, Record<string, { title: [string, string]; sub: string }>> = {};
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<string, string> = {
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<string, string> = {
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 <aab>` (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: '<numeric Apple ID>', // 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/<lang>/<phone|tablet>/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);
}

111
cli/lib/store-locales.ts Normal file
View file

@ -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<Record<string, string>> = {
'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<Record<string, string>> = {
...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<Record<string, string>> = {
'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<Record<string, string>> = {
'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<string>();
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; }
}

View file

@ -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 <name>.imported.<ext> 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. */

View file

@ -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;

View file

@ -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 `<name>.imported.<ext>` 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 <same 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/<locale>/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/<lang>/title, short_description, full_description` | `store/listing.md` |
| newest `changelogs/<versionCode>.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/<lang>/feature/1024x500.*` |
| `screenshots/<locale>/` (largest iPhone set, iPad), else `phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames/<lang>/<phone\|tablet>/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).

348
tests/fastlane.test.ts Normal file
View file

@ -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<typeof plan>, path: string) => p.files.find(f => f.path === path)?.text;
async function loadPlannedConfig(p: ReturnType<typeof plan>): Promise<unknown> {
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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);
});
});

View file

@ -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

View file

@ -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"])

View file

@ -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

View file

@ -0,0 +1,4 @@
git_url("https://git.example.com/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.example.notes"])

View file

@ -0,0 +1 @@
Bug fixes.

View file

@ -0,0 +1 @@
Geteilte Listen.

View file

@ -0,0 +1 @@
Bug fixes and improvements.

View file

@ -0,0 +1,4 @@
Example Notes: Listen und Notizen, auf allen Geräten synchron.
• Share a list
• Export your data

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

View file

@ -0,0 +1 @@
Listen und Notizen, auf allen Geräten synchron

View file

@ -0,0 +1 @@
Example Notes

View file

@ -0,0 +1 @@
Bug fixes.

View file

@ -0,0 +1 @@
Shared lists.

View file

@ -0,0 +1 @@
Bug fixes and improvements.

View file

@ -0,0 +1,4 @@
Example Notes: Lists and notes that stay in sync on every device.
• Share a list
• Export your data

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

View file

@ -0,0 +1 @@
Lists and notes that stay in sync on every device

View file

@ -0,0 +1 @@
Example Notes

View file

@ -0,0 +1 @@
https://www.youtube.com/watch?v=EXAMPLE0001

View file

@ -0,0 +1 @@
Bug fixes.

View file

@ -0,0 +1 @@
Paylaşılan listeler.

View file

@ -0,0 +1 @@
Bug fixes and improvements.

View file

@ -0,0 +1,4 @@
Example Notes: Tüm cihazlarında eşitlenen listeler ve notlar.
• Share a list
• Export your data

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

View file

@ -0,0 +1 @@
Tüm cihazlarında eşitlenen listeler ve notlar

View file

@ -0,0 +1 @@
Example Notes

View file

@ -0,0 +1 @@
2026 Example Studio

View file

@ -0,0 +1 @@
Example Notes hält Listen und Notizen auf allen Geräten synchron.

View file

@ -0,0 +1 @@
aufgaben,einkauf,planer,familie

View file

@ -0,0 +1 @@
Example Notes

View file

@ -0,0 +1 @@
Geteilte Listen sind da.

View file

@ -0,0 +1 @@
Geteilte Listen.

View file

@ -0,0 +1 @@
Listen und Notizen, synchron

View file

@ -0,0 +1 @@
https://example.com

View file

@ -0,0 +1 @@
https://example.com/privacy

View file

@ -0,0 +1 @@
https://example.com/support

View file

@ -0,0 +1 @@
https://example.com/privacy

View file

@ -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

View file

@ -0,0 +1 @@
todo, checklist, groceries, planner, shared, family, tasks

View file

@ -0,0 +1 @@
Example Notes

View file

@ -0,0 +1 @@
Shared lists are here: plan the week together.

View file

@ -0,0 +1 @@
Shared lists and a calmer home screen.

View file

@ -0,0 +1 @@
Lists and notes, in sync

View file

@ -0,0 +1 @@
MZGenre.Productivity

View file

@ -0,0 +1 @@
example-not-a-password

View file

@ -0,0 +1 @@
demo@example.com

View file

@ -0,0 +1 @@
review-contact@example.com

View file

@ -0,0 +1 @@
Example

View file

@ -0,0 +1 @@
Sign in with the demo account. Sample lists are already there.

View file

@ -0,0 +1 @@
+1 555 0100

View file

@ -0,0 +1 @@
Utilities

View file

@ -0,0 +1 @@
Example Notes listeleri ve notları tüm cihazlarında eşitler.

View file

@ -0,0 +1 @@
yapılacaklar,market,planlayıcı

View file

@ -0,0 +1 @@
Example Notes

View file

@ -0,0 +1 @@
Listeler ve notlar, eşitli

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 B