// Reads an existing fastlane setup (Appfile, Deliverfile, Fastfile / Matchfile hints, deliver and supply metadata, // screenshots) and plans the storebound files for it: storebound.config.ts, store/listing.md, store/appstore.md and, // with screenshots, a `folder` capture source (store/shots/frames + store/shots/copy.json). Only reads: Ruby is never // run, only plain `key("value")` lines are taken, everything else is reported as "set by hand". // Secrets and personal data never enter the plan's files: key files are named as references to move into .storebound/, // App Review contact and demo account files are reported (the owner copies them), team / app ids become placeholders. import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; import { imageInfo } from '../shots/spec'; import { APP_PALETTES, PALETTES } from '../shots/lib'; import type { AppProfile, Language } from './config'; import { APPLE_LIMITS, len, PLAY_LIMITS, parseAppStoreListing, parsePlayListing } from './copy'; import { appleLocale, languageName, pairLanguages, playLocale } from './store-locales'; // --------------------------------------------------------------------------------------------------------------- // Ruby option files (Appfile, Deliverfile, Matchfile): plain `key("value")` / `key "value"` lines only. export type RubyValue = string | boolean | number | string[]; export interface RubyOptions { /** Top-level options with a literal value. */ values: Record; /** Options set in a way storebound will not guess: inside a block (for_platform / for_lane / if), from ENV, a * method call or string interpolation. Only the key, never the value. */ byHand: { key: string; line: number; why: string }[]; } const STR = /^(["'])((?:\\.|(?!\1).)*)\1$/; function literal(raw: string): RubyValue | undefined { const v = raw.trim(); const s = STR.exec(v); if (s) return s[1] === '"' && /#\{/.test(s[2]!) ? undefined : s[2]!.replace(/\\(["'\\])/g, '$1'); if (v === 'true' || v === 'false') return v === 'true'; if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v); const arr = /^\[([\s\S]*)\]$/.exec(v); if (arr) { const items = arr[1]!.split(',').map(x => x.trim()).filter(Boolean); const out: string[] = []; for (const it of items) { const m = STR.exec(it); if (!m || /#\{/.test(m[2]!)) return undefined; out.push(m[2]!); } return out; } return undefined; } /** Parses the simple option lines of an Appfile / Deliverfile / Matchfile. Pure. */ export function parseRubyOptions(text: string): RubyOptions { const values: Record = {}; const byHand: RubyOptions['byHand'] = []; let depth = 0; text.split(/\r?\n/).forEach((raw, i) => { const line = raw.replace(/\s+#(?![{]).*$/, '').trim(); if (!line || line.startsWith('#')) return; if (/^end\b/.test(line)) { depth = Math.max(0, depth - 1); return; } const opens = /\bdo(\s*\|[^|]*\|)?$/.test(line) || /^(if|unless|case|begin|while|until)\b/.test(line); const m = /^([a-z_][a-z0-9_]*)(?:\s*\(\s*([\s\S]*?)\s*\)|\s+([\s\S]+?))?$/.exec(line); if (opens) { if (m && depth === 0 && !/^(if|unless|case|begin|while|until|for_platform|for_lane|platform|lane)$/.test(m[1]!)) byHand.push({ key: m[1]!, line: i + 1, why: 'opens a block' }); depth++; return; } if (!m) { const k = /^([a-z_][a-z0-9_]*)\s*[("']/.exec(line); if (k) byHand.push({ key: k[1]!, line: i + 1, why: depth > 0 ? 'inside a block (for_platform / for_lane / if)' : 'not a plain value' }); return; } const key = m[1]!; const arg = m[2] ?? m[3]; if (arg === undefined) return; if (depth > 0) { byHand.push({ key, line: i + 1, why: 'inside a block (for_platform / for_lane / if)' }); return; } const v = literal(arg); if (v === undefined) { byHand.push({ key, line: i + 1, why: /ENV\[/.test(arg) ? 'from an environment variable' : 'not a plain value' }); return; } if (key in values) { byHand.push({ key, line: i + 1, why: 'set twice (the first one is used)' }); return; } values[key] = v; }); return { values, byHand }; } // --------------------------------------------------------------------------------------------------------------- // What a fastlane folder holds. export interface DeliverLocale { /** Folder name as found. */ folder: string; /** App Store locale it maps to. */ locale: string; known: boolean; fields: Record; /** Fields that came from metadata/default. */ fromDefault: string[]; /** .txt files deliver knows that storebound does not map, and unknown ones. */ extra: string[]; } export interface SupplyLocale { folder: string; /** Play language code it maps to. */ code: string; known: boolean; fields: Record; /** Changelog file used for "What's new" (relative to the locale folder). */ changelog?: string; images: Record; shots: Record<'phone' | 'seven' | 'ten' | 'tv' | 'wear', string[]>; extra: string[]; } export interface FastlaneScan { dirs: string[]; appfile: RubyOptions; deliverfile: RubyOptions; matchfile: RubyOptions | null; /** Fastfile hints: lane names and the actions storebound has its own answer for. */ fastfile: { lanes: string[]; actions: string[] } | null; deliver: { metadata: string | null; locales: DeliverLocale[]; root: Record; review: string[]; default: boolean; screenshots: string | null; shotLocales: Record; skippedDirs: string[] }; supply: { metadata: string | null; locales: SupplyLocale[] }; } const DELIVER_FIELDS: Record = { 'name.txt': 'name', 'subtitle.txt': 'subtitle', 'description.txt': 'description', 'keywords.txt': 'keywords', 'promotional_text.txt': 'promotionalText', 'release_notes.txt': 'whatsNew', 'privacy_url.txt': 'privacyUrl', 'support_url.txt': 'supportUrl', 'marketing_url.txt': 'marketingUrl', }; const FILE_OF: Record = Object.fromEntries(Object.entries(DELIVER_FIELDS).map(([f, k]) => [k, f])); const DELIVER_ROOT = ['copyright.txt', 'primary_category.txt', 'secondary_category.txt', 'primary_first_sub_category.txt', 'primary_second_sub_category.txt', 'secondary_first_sub_category.txt', 'secondary_second_sub_category.txt']; const DELIVER_SPECIAL = new Set(['default', 'review_information', 'trade_representative_contact_information', 'android']); const SUPPLY_FIELDS: Record = { 'title.txt': 'title', 'short_description.txt': 'short', 'full_description.txt': 'full', 'video.txt': 'video' }; const IMG = /\.(png|jpe?g)$/i; const readText = (p: string): string => readFileSync(p, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').trim(); const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } }; const isFile = (p: string): boolean => { try { return statSync(p).isFile(); } catch { return false; } }; const ls = (p: string): string[] => (isDir(p) ? readdirSync(p).filter(n => !n.startsWith('.')).sort() : []); const readOpts = (p: string): RubyOptions => (isFile(p) ? parseRubyOptions(readFileSync(p, 'utf8')) : { values: {}, byHand: [] }); /** Actions in a Fastfile that storebound has a counterpart for (reported, never run). */ const FASTFILE_ACTIONS = ['app_store_connect_api_key', 'upload_to_app_store', 'deliver', 'upload_to_play_store', 'supply', 'upload_to_testflight', 'pilot', 'match', 'sync_code_signing', 'snapshot', 'capture_screenshots', 'capture_ios_screenshots', 'screengrab', 'capture_android_screenshots', 'frameit', 'frame_screenshots', 'increment_build_number', 'increment_version_code', 'latest_testflight_build_number', 'google_play_track_version_codes', 'precheck', 'check_app_store_metadata', 'gym', 'build_app', 'build_ios_app', 'gradle', 'build_android_app']; function scanFastfile(p: string): FastlaneScan['fastfile'] { if (!isFile(p)) return null; const text = readFileSync(p, 'utf8'); const lanes = [...text.matchAll(/^\s*(?:private_)?lane\s+:([a-z0-9_]+)/gm)].map(m => m[1]!); const actions = FASTFILE_ACTIONS.filter(a => new RegExp(`(^|[^a-z0-9_.:])${a}\\b\\s*(\\(|$|\\s)`, 'm').test(text)); return { lanes, actions }; } /** Reads one or more fastlane folders (ios/fastlane + android/fastlane, or one fastlane/). Read-only. */ export function scanFastlane(dirs: string[]): FastlaneScan { const scan: FastlaneScan = { dirs, appfile: { values: {}, byHand: [] }, deliverfile: { values: {}, byHand: [] }, matchfile: null, fastfile: null, deliver: { metadata: null, locales: [], root: {}, review: [], default: false, screenshots: null, shotLocales: {}, skippedDirs: [] }, supply: { metadata: null, locales: [] }, }; const merge = (into: RubyOptions, from: RubyOptions, file: string) => { for (const [k, v] of Object.entries(from.values)) if (!(k in into.values)) into.values[k] = v; else if (JSON.stringify(into.values[k]) !== JSON.stringify(v)) into.byHand.push({ key: k, line: 0, why: `also set differently in ${file}` }); into.byHand.push(...from.byHand); }; for (const dir of dirs) { merge(scan.appfile, readOpts(join(dir, 'Appfile')), join(dir, 'Appfile')); merge(scan.deliverfile, readOpts(join(dir, 'Deliverfile')), join(dir, 'Deliverfile')); if (isFile(join(dir, 'Matchfile'))) { scan.matchfile ??= { values: {}, byHand: [] }; merge(scan.matchfile, readOpts(join(dir, 'Matchfile')), join(dir, 'Matchfile')); } const ff = scanFastfile(join(dir, 'Fastfile')); if (ff) scan.fastfile = { lanes: [...(scan.fastfile?.lanes ?? []), ...ff.lanes], actions: [...new Set([...(scan.fastfile?.actions ?? []), ...ff.actions])] }; // fastlane runs from the folder that contains fastlane/, so Deliverfile paths are relative to it const base = dirname(dir); const dv = readOpts(join(dir, 'Deliverfile')).values; const meta = typeof dv.metadata_path === 'string' ? resolve(base, dv.metadata_path) : join(dir, 'metadata'); const shots = typeof dv.screenshots_path === 'string' ? resolve(base, dv.screenshots_path) : join(dir, 'screenshots'); if (!scan.deliver.metadata) readDeliver(scan, meta); if (!scan.deliver.screenshots) readDeliverShots(scan, shots); if (!scan.supply.metadata) readSupply(scan, join(dir, 'metadata', 'android')); } return scan; } function readDeliver(scan: FastlaneScan, meta: string): void { const folders = ls(meta).filter(n => isDir(join(meta, n))); const locales = folders.filter(n => !DELIVER_SPECIAL.has(n)); const hasDefault = folders.includes('default'); const rootFiles = DELIVER_ROOT.filter(f => isFile(join(meta, f))); if (!locales.length && !hasDefault && !rootFiles.length) return; scan.deliver.metadata = meta; scan.deliver.default = hasDefault; for (const f of rootFiles) scan.deliver.root[f.replace(/\.txt$/, '')] = readText(join(meta, f)); const rev = join(meta, 'review_information'); scan.deliver.review = ls(rev).filter(f => /\.txt$/.test(f) && readText(join(rev, f)) !== ''); if (scan.deliver.review.includes('notes.txt')) scan.deliver.root.review_notes = readText(join(rev, 'notes.txt')); const def: Record = {}; if (hasDefault) for (const [f, k] of Object.entries(DELIVER_FIELDS)) if (isFile(join(meta, 'default', f))) def[k] = readText(join(meta, 'default', f)); // only a default folder: the Deliverfile languages, else en-US const dl = scan.deliverfile.values.languages; const names = locales.length ? locales : Array.isArray(dl) && dl.length ? dl : hasDefault ? ['en-US'] : []; for (const folder of names) { const d = join(meta, folder); const n = appleLocale(folder); const fields: Record = {}; const extra: string[] = []; for (const f of ls(d)) { if (!isFile(join(d, f))) continue; const k = DELIVER_FIELDS[f]; if (k) { const t = readText(join(d, f)); if (t) fields[k] = t; } else extra.push(f); } const fromDefault: string[] = []; for (const [k, v] of Object.entries(def)) if (!(k in fields) && v) { fields[k] = v; fromDefault.push(k); } scan.deliver.locales.push({ folder, locale: n.code, known: n.known, fields, fromDefault, extra }); } } function readDeliverShots(scan: FastlaneScan, dir: string): void { const out: Record = {}; for (const folder of ls(dir)) { const d = join(dir, folder); if (!isDir(d)) continue; const files = ls(d).filter(f => IMG.test(f) && isFile(join(d, f))).map(f => join(d, f)); if (!files.length) continue; const n = appleLocale(folder); if (!n.known && folder === 'default') { scan.deliver.skippedDirs.push(folder); continue; } out[n.code] = files; } if (Object.keys(out).length) { scan.deliver.screenshots = dir; scan.deliver.shotLocales = out; } } function readSupply(scan: FastlaneScan, dir: string): void { const folders = ls(dir).filter(n => isDir(join(dir, n))); if (!folders.length) return; scan.supply.metadata = dir; for (const folder of folders) { const d = join(dir, folder); const n = playLocale(folder); const fields: Record = {}; const extra: string[] = []; for (const f of ls(d)) { if (!isFile(join(d, f))) continue; const k = SUPPLY_FIELDS[f]; if (k) { const t = readText(join(d, f)); if (t) fields[k] = t; } else extra.push(f); } let changelog: string | undefined; const cl = ls(join(d, 'changelogs')).filter(f => /\.txt$/.test(f)); const numbered = cl.filter(f => /^\d+\.txt$/.test(f)).sort((a, b) => parseInt(b) - parseInt(a)); const pick = numbered[0] ?? (cl.includes('default.txt') ? 'default.txt' : undefined); if (pick) { const t = readText(join(d, 'changelogs', pick)); if (t) { fields.whatsNew = t; changelog = `changelogs/${pick}`; } } const img = join(d, 'images'); const images: Record = {}; for (const f of ls(img)) if (IMG.test(f) && isFile(join(img, f))) images[f.replace(IMG, '')] = join(img, f); const list = (sub: string) => ls(join(img, sub)).filter(f => IMG.test(f)).map(f => join(img, sub, f)); const shots = { phone: list('phoneScreenshots'), seven: list('sevenInchScreenshots'), ten: list('tenInchScreenshots'), tv: list('tvScreenshots'), wear: list('wearScreenshots') }; scan.supply.locales.push({ folder, code: n.code, known: n.known, fields, changelog, images, shots, extra }); } } // --------------------------------------------------------------------------------------------------------------- // The plan. export interface PlanFile { /** Path relative to the project root. */ path: string; /** Text content, or a file to copy. */ text?: string; from?: string; /** `from` for the log, relative to the folder fastlane runs from. */ src?: string; kind: 'config' | 'listing' | 'appstore' | 'copy' | 'frame' | 'icon' | 'feature'; } export interface Note { what: string; where: string } export interface ImportPlan { profile: AppProfile; appName: string; bundleId?: string; packageName?: string; languages: Language[]; files: PlanFile[]; /** source -> storebound target, for the report. */ mapped: { from: string; to: string }[]; /** What was found but not imported, and where it goes by hand. */ notMapped: Note[]; /** Keys, accounts and personal data: named, never copied. */ secrets: Note[]; /** Values written as placeholders the owner must replace. */ placeholders: Note[]; warnings: string[]; } export interface PlanOptions { /** Project root the paths are relative to (for the report). */ root: string; /** --profile; default from the App Store category (GAMES -> game), else app. */ profile?: AppProfile; } /** App Store Connect category ids (appCategories). deliver takes "Games", "MZGenre.Games" or the id itself. */ const CATEGORIES = ['BOOKS', 'BUSINESS', 'DEVELOPER_TOOLS', 'EDUCATION', 'ENTERTAINMENT', 'FINANCE', 'FOOD_AND_DRINK', 'GAMES', 'GRAPHICS_AND_DESIGN', 'HEALTH_AND_FITNESS', 'LIFESTYLE', 'MAGAZINES_AND_NEWSPAPERS', 'MEDICAL', 'MUSIC', 'NAVIGATION', 'NEWS', 'PHOTO_AND_VIDEO', 'PRODUCTIVITY', 'REFERENCE', 'SHOPPING', 'SOCIAL_NETWORKING', 'SPORTS', 'STICKERS', 'TRAVEL', 'UTILITIES', 'WEATHER']; const GAME_SUBS = ['ACTION', 'ADVENTURE', 'BOARD', 'CARD', 'CASINO', 'CASUAL', 'FAMILY', 'MUSIC', 'PUZZLE', 'RACING', 'ROLE_PLAYING', 'SIMULATION', 'SPORTS', 'STRATEGY', 'TRIVIA', 'WORD']; /** "Games", "MZGenre.Games", "Photo & Video", "GAMES" -> "GAMES" (null when unknown). Game subcategories: "GAMES_PUZZLE". */ export function categoryId(raw: string, sub = false): string | null { const s = raw.trim().replace(/^MZGenre\./i, '').replace(/^Apps\./i, '').replace(/&/g, 'AND').replace(/[\s.-]+/g, '_').replace(/[^A-Za-z_]/g, '').toUpperCase(); if (!s) return null; if (sub) { const g = s.replace(/^GAMES_/, ''); return GAME_SUBS.includes(g) ? `GAMES_${g}` : null; } return CATEGORIES.includes(s) ? s : s === 'PHOTOGRAPHY' ? 'PHOTO_AND_VIDEO' : null; } /** Single-quoted TypeScript string. */ export function tsString(s: string): string { return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`; } const fence = (t: string) => '```\n' + t + '\n```'; const https = (u: string | undefined): string | undefined => (u && /^https:\/\/\S+$/.test(u.trim()) ? u.trim() : undefined); const YOUTUBE = /^https:\/\/(www\.youtube\.com\/watch\?v=[\w-]{6,}|youtu\.be\/[\w-]{6,})$/; /** Keywords "a, b ,c" -> "a,b,c" (spaces after commas count against the 100 bytes on the App Store). */ export const normalizeKeywords = (k: string): string => k.split(/[,\n]/).map(x => x.trim()).filter(Boolean).join(','); /** Image size, null when the header is not PNG / JPEG. */ function dims(p: string): { w: number; h: number } | null { try { const i = imageInfo(readFileSync(p)); return i.format !== 'unknown' && i.w && i.h ? { w: i.w, h: i.h } : null; } catch { return null; } } type Device = 'phone' | 'tablet'; /** deliver screenshots of one locale -> phone / tablet sets. A folder often holds several iPhone sizes (snapshot): the * largest one wins per class. Watch, Apple TV, Mac and iMessage shots are left out. */ export function classifyAppleShots(files: string[]): { phone: string[]; tablet: string[]; skipped: string[] } { const out = { phone: [] as string[], tablet: [] as string[], skipped: [] as string[] }; const by: Record = { phone: [], tablet: [] }; for (const f of files) { const name = basename(f); if (/watch|apple ?tv|appletv|\bmac\b|desktop|imessage|messages/i.test(name)) { out.skipped.push(f); continue; } const d = dims(f); const tablet = /ipad/i.test(name) || (!!d && Math.min(d.w, d.h) / Math.max(d.w, d.h) > 0.7); by[tablet ? 'tablet' : 'phone'].push({ f, area: d ? d.w * d.h : 0 }); } for (const k of ['phone', 'tablet'] as Device[]) { const max = Math.max(0, ...by[k].map(x => x.area)); for (const x of by[k]) (x.area === max ? out[k] : out.skipped).push(x.f); } return out; } const MAX_SCENES = 10; export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan { // sources are shown relative to the folder fastlane runs from (the parent of fastlane/) const bases = scan.dirs.map(d => dirname(d)); const rel = (p: string) => { const b = bases.find(x => p.startsWith(x + sep)); return b ? relative(b, p) : relative(o.root, p) || '.'; }; const mapped: ImportPlan['mapped'] = []; const notMapped: Note[] = []; const secrets: Note[] = []; const placeholders: Note[] = []; const warnings: string[] = []; const files: PlanFile[] = []; const A = scan.appfile.values, D = scan.deliverfile.values; // --- ids const str = (v: RubyValue | undefined) => (typeof v === 'string' && v ? v : undefined); const bundleId = str(A.app_identifier) ?? str(D.app_identifier) ?? (Array.isArray(A.app_identifier) ? A.app_identifier[0] : undefined); const packageName = str(A.package_name); if (bundleId) mapped.push({ from: 'Appfile app_identifier', to: 'apple.bundleId' }); if (packageName) mapped.push({ from: 'Appfile package_name', to: 'play.packageName' }); for (const k of ['team_id', 'itc_team_id', 'team_name', 'itc_team_name']) if (k in A || k in D) secrets.push({ what: `${k} (Appfile / Deliverfile)`, where: 'not written: storebound finds the team from the App Store Connect API key (ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json)' }); if ('apple_id' in A || 'username' in A || 'username' in D) secrets.push({ what: 'apple_id / username (the Apple Account that signs in)', where: 'not written: storebound signs in with an API key, never an Apple Account' }); for (const k of ['json_key_file', 'json_key']) { const v = A[k] ?? D[k]; if (v !== undefined) secrets.push({ what: `${k}${typeof v === 'string' ? ` (${v})` : ''}`, where: 'move the Play service account key into .storebound/ (gitignored) and set PLAY_SERVICE_ACCOUNT to its path in .storebound/credentials.json; if it was ever committed, rotate the key' }); } if ('json_key_data_raw' in A) secrets.push({ what: 'json_key_data_raw', where: 'PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json), never the config' }); const appleAppId = D.app ?? D.apple_id ?? A.apple_app_id; const appIdFound = appleAppId !== undefined && /^\d+$/.test(String(appleAppId)); if (appIdFound) placeholders.push({ what: 'apple.appId', where: 'commented out in storebound.config.ts: the numeric Apple ID was found in the Deliverfile; uncomment and fill it in if you want it pinned (storebound otherwise looks it up by bundle id)' }); for (const b of [...scan.appfile.byHand, ...scan.deliverfile.byHand]) notMapped.push({ what: `${b.key}${b.line ? ` (line ${b.line})` : ''}: ${b.why}`, where: 'set by hand in storebound.config.ts if it matters (Ruby logic is never run)' }); // --- deliver (App Store) const dl = [...scan.deliver.locales]; const sl = [...scan.supply.locales]; const hasApple = !!bundleId || dl.length > 0 || Object.keys(scan.deliver.shotLocales).length > 0; const hasPlay = !!packageName || sl.length > 0; if (!hasApple && !hasPlay) throw new Error(`nothing to import in ${scan.dirs.map(rel).join(', ')}: no Appfile ids, no metadata// and no metadata/android//`); for (const l of dl) if (!l.known) warnings.push(`App Store locale "${l.folder}" (metadata/${l.folder}) is not an App Store Connect locale: imported as is; rename it in store/appstore.md and languages[] (e.g. en-US, de-DE, ja, zh-Hans)`); for (const l of dl) if (l.locale !== l.folder) mapped.push({ from: `metadata/${l.folder}`, to: `App Store locale ${l.locale}` }); for (const l of sl) if (!l.known) warnings.push(`Play language "${l.folder}" (metadata/android/${l.folder}) is not a Google Play language code: imported as is; rename it in store/listing.md and languages[] (e.g. en-US, tr-TR, iw-IL, zh-CN)`); for (const l of sl) if (l.code !== l.folder) mapped.push({ from: `metadata/android/${l.folder}`, to: `Play language ${l.code}` }); const appleCodes = [...new Set([...dl.map(l => l.locale), ...(dl.length ? [] : Object.keys(scan.deliver.shotLocales))])]; const playCodes = [...new Set(sl.map(l => l.code))]; const languages = pairLanguages(hasApple ? appleCodes : [], hasPlay ? playCodes : []); if (!languages.length) { languages.push({ id: 'en', ...(hasPlay ? { play: 'en-US' } : {}), ...(hasApple ? { apple: 'en-US' } : {}) }); placeholders.push({ what: 'languages', where: 'no metadata locales were found: en-US was assumed' }); } for (const l of languages) mapped.push({ from: [l.apple && `deliver ${l.apple}`, l.play && `supply ${l.play}`].filter(Boolean).join(' + '), to: `languages: { id: '${l.id}'${l.play ? `, play: '${l.play}'` : ''}${l.apple ? `, apple: '${l.apple}'` : ''} }` }); const idOfApple = (c: string) => languages.find(l => l.apple === c)?.id; const idOfPlay = (c: string) => languages.find(l => l.play === c)?.id; const order = (x: { apple?: string; play?: string }) => languages.findIndex(l => (x.apple !== undefined && l.apple === x.apple) || (x.play !== undefined && l.play === x.play)); dl.sort((a, b) => order({ apple: a.locale }) - order({ apple: b.locale })); sl.sort((a, b) => order({ play: a.code }) - order({ play: b.code })); const primaryApple = dl.find(l => l.locale === languages[0]?.apple) ?? dl[0]; const primaryPlay = sl.find(l => l.code === languages[0]?.play) ?? sl.find(l => l.code === 'en-US') ?? sl[0]; // --- app const appName = primaryApple?.fields.name ?? primaryPlay?.fields.title ?? (bundleId ?? packageName ?? 'My App').split('.').pop()!; if (primaryApple?.fields.name) mapped.push({ from: `metadata/${primaryApple.folder}/name.txt`, to: 'app.name' }); else if (primaryPlay?.fields.title) mapped.push({ from: `metadata/android/${primaryPlay.folder}/title.txt`, to: 'app.name' }); else placeholders.push({ what: 'app.name', where: `no name.txt / title.txt: "${appName}" was taken from the app id` }); const urlOf = (k: 'privacyUrl' | 'supportUrl' | 'marketingUrl') => { const vals = [...new Set(dl.map(l => l.fields[k]).filter((x): x is string => !!x))]; const pick = primaryApple?.fields[k] ?? vals[0]; const from = primaryApple && primaryApple.fields[k] ? `metadata/${primaryApple.fromDefault.includes(k) ? 'default' : primaryApple.folder}/${FILE_OF[k]}` : `metadata//${FILE_OF[k]}`; if (pick && https(pick)) mapped.push({ from, to: k === 'privacyUrl' ? 'app.privacyPolicyUrl' : k === 'marketingUrl' ? 'app.marketingUrl + app.website' : `app.${k}` }); if (vals.length > 1) notMapped.push({ what: `${k.replace('Url', '_url')}.txt differs per locale (${vals.length} values)`, where: `storebound has one ${k === 'privacyUrl' ? 'app.privacyPolicyUrl' : `app.${k}`} for every language: the ${primaryApple?.locale ?? 'first'} one was used` }); if (pick && !https(pick)) warnings.push(`${k.replace('Url', '_url')}.txt is not an https URL ("${pick}"): a placeholder was written instead`); return https(pick); }; const privacy = urlOf('privacyUrl'), support = urlOf('supportUrl'), marketing = urlOf('marketingUrl'); if (!privacy) placeholders.push({ what: 'app.privacyPolicyUrl', where: 'https://example.com/privacy: set your privacy policy page (privacy-policy skill)' }); if (!support) placeholders.push({ what: 'app.supportUrl', where: 'https://example.com/support: set your support page' }); if (!marketing) placeholders.push({ what: 'app.website', where: 'https://example.com: your developer website (Play shows it; app-ads.txt lives there)' }); placeholders.push({ what: 'app.contactEmail', where: 'support@example.com: the public support email (Play shows it). The App Review contact is not used for this' }); const copyright = scan.deliver.root.copyright; if (copyright) mapped.push({ from: 'metadata/copyright.txt', to: 'app.copyright' }); // --- categories and profile const cats: { primary?: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string } = {}; const catFile: [string, keyof typeof cats, boolean][] = [['primary_category', 'primary', false], ['primary_first_sub_category', 'primarySubOne', true], ['primary_second_sub_category', 'primarySubTwo', true], ['secondary_category', 'secondary', false]]; for (const [f, k, sub] of catFile) { const raw = scan.deliver.root[f] ?? str(D[f]); if (!raw) continue; const id = categoryId(raw, sub); if (id) { cats[k] = id; mapped.push({ from: `${f}${scan.deliver.root[f] ? '.txt' : ' (Deliverfile)'}`, to: `apple.info.categories.${k} = ${id}` }); } else notMapped.push({ what: `${f}: "${raw}"`, where: 'apple.info.categories in storebound.config.ts (App Store Connect ids such as GAMES, PRODUCTIVITY, GAMES_PUZZLE)' }); } for (const f of ['secondary_first_sub_category', 'secondary_second_sub_category']) if (scan.deliver.root[f]) notMapped.push({ what: `${f}.txt`, where: 'set in App Store Connect > App Information (storebound sets primary sub-categories only)' }); const profile: AppProfile = o.profile ?? (cats.primary === 'GAMES' ? 'game' : 'app'); const style: 'game' | 'app' = profile === 'game' ? 'game' : 'app'; // --- App Review information const review = scan.deliver.review; const reviewNotes = scan.deliver.root.review_notes; if (reviewNotes) mapped.push({ from: 'metadata/review_information/notes.txt', to: 'apple.info.reviewNotes' }); const contactFiles = review.filter(f => ['first_name.txt', 'last_name.txt', 'phone_number.txt', 'email_address.txt'].includes(f)); if (contactFiles.length) secrets.push({ what: `review_information/${contactFiles.join(', ')} (App Review contact)`, where: 'personal data: not copied. Put it in .storebound/credentials.json "reviewContact": { firstName, lastName, phone, email } yourself' }); const demo = review.filter(f => f === 'demo_user.txt' || f === 'demo_password.txt'); if (demo.length) secrets.push({ what: `review_information/${demo.join(', ')} (App Review demo account)`, where: 'not copied: the owner puts it in .storebound/credentials.json "reviewDemoAccount": { username, password } (review-access skill); reviewAccess.signInRequired was set to true' }); const signIn = demo.includes('demo_user.txt'); if (scan.deliver.metadata && isDir(join(scan.deliver.metadata, 'trade_representative_contact_information'))) notMapped.push({ what: 'trade_representative_contact_information/ (Korea)', where: 'App Store Connect > App Information (personal data, not imported)' }); // --- App Store listing const appleSections: string[] = []; for (const l of dl) { const f = l.fields; const parts: string[] = [`## ${l.locale} · ${languageName(l.locale)}`, '']; const add = (k: string, v: string | undefined) => { if (v) parts.push(`### ${k}`, fence(v), ''); }; if (f.name && f.name !== appName) add('name', f.name); add('subtitle', f.subtitle); add('promotionalText', f.promotionalText); if (f.keywords) { const k = normalizeKeywords(f.keywords); if (k !== f.keywords) warnings.push(`${l.locale}: keywords normalized (spaces after commas removed; they count against 100 bytes)`); add('keywords', k); } add('description', f.description); add('whatsNew', f.whatsNew); const missing = ['subtitle', 'promotionalText', 'keywords', 'description'].filter(k => !f[k]); if (missing.length) warnings.push(`${l.locale}: no ${missing.join(', ')} in fastlane: add ${missing.length > 1 ? 'them' : 'it'} to store/appstore.md before \`storebound apple listing\``); for (const [k, lim] of Object.entries(APPLE_LIMITS)) if (f[k] && len(k === 'keywords' ? normalizeKeywords(f[k]!) : f[k]!) > lim) warnings.push(`${l.locale}: ${k} is ${len(f[k]!)} characters > ${lim}`); const listed = l.fromDefault.filter(k => !/Url$/.test(k)).map(k => FILE_OF[k]); if (listed.length) mapped.push({ from: `metadata/default/{${listed.join(', ')}}`, to: `store/appstore.md ${l.locale} (no file of its own)` }); for (const x of l.extra) notMapped.push({ what: `metadata/${l.folder}/${x}`, where: /apple_tv_privacy_policy/.test(x) ? 'Apple TV privacy policy: App Store Connect' : 'not a deliver text storebound maps' }); appleSections.push(parts.join('\n').trimEnd()); } if (dl.length) { mapped.push({ from: 'metadata//{name, subtitle, promotional_text, keywords, description, release_notes}.txt', to: 'store/appstore.md (name, subtitle, promotionalText, keywords, description, whatsNew)' }); const md = `# ${appName} · App Store listing copy Imported from fastlane deliver (\`storebound import fastlane\`). Source for \`storebound apple listing\`. One \`## \` section per locale, one fenced block per \`### \`: \`name\` (optional, defaults to \`apple.name\`, else \`app.name\`), \`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 bytes, comma separated, no spaces after commas), \`description\` (4000), \`whatsNew\` (optional). Privacy policy, support and marketing URL come from \`storebound.config.ts\`. --- ${appleSections.join('\n\n')} `; if (md.includes('\n```\n```')) warnings.push('a text contains a ``` line: check store/appstore.md by hand'); try { parseAppStoreListing(md); } catch (e) { warnings.push(`store/appstore.md: ${(e as Error).message.replace(/^appstore listing: /, '')} (\`storebound check\` reports it too)`); } files.push({ path: 'store/appstore.md', text: md, kind: 'appstore' }); } // --- Play listing if (sl.length) { const secs: string[] = []; for (const l of sl) { const f = l.fields; const parts = [`## ${languageName(l.code)} (${l.code})`, ``, '']; const add = (h: string, v: string | undefined) => { if (v) parts.push(`### ${h}`, fence(v), ''); }; add('Title (max 30)', f.title); add('Play short description (max 80)', f.short); add('Play full description (max 4000)', f.full); add("What's new (max 500)", f.whatsNew); const missing = [['short', 'short_description.txt'], ['full', 'full_description.txt']].filter(([k]) => !f[k!]).map(([, n]) => n); if (missing.length) warnings.push(`${l.code}: no ${missing.join(', ')} in fastlane: add it to store/listing.md before \`storebound play listing\``); const lim: Record = { title: PLAY_LIMITS.title, short: PLAY_LIMITS.short, full: PLAY_LIMITS.full, whatsNew: PLAY_LIMITS.whatsNew }; for (const [k, n] of Object.entries(lim)) if (f[k] && len(f[k]!) > n) warnings.push(`${l.code}: ${k} is ${len(f[k]!)} characters > ${n}`); if (l.changelog) mapped.push({ from: `metadata/android/${l.folder}/${l.changelog}`, to: `store/listing.md ${l.code} What's new` }); for (const x of l.extra) notMapped.push({ what: `metadata/android/${l.folder}/${x}`, where: 'not a supply text storebound maps' }); secs.push(parts.join('\n').trimEnd()); } mapped.push({ from: 'metadata/android//{title, short_description, full_description}.txt', to: 'store/listing.md (title, short, full)' }); const md = `# ${appName} · Google Play listing copy Imported from fastlane supply (\`storebound import fastlane\`). Source for \`storebound play listing\` (title, short + full description) and \`storebound play release\` (release notes). Every \`## ...\` section with a \`\` line is a listing; \`### ...\` headings that contain **title**, **short**, **full** and **what's new**, each followed by one fenced block. Limits: title 30, short 80, full 4000, what's new 500 characters. --- ${secs.join('\n\n')} `; try { parsePlayListing(md, appName); } catch (e) { warnings.push(`store/listing.md: ${(e as Error).message.replace(/^listing: /, '')} (\`storebound check\` reports it too)`); } files.push({ path: 'store/listing.md', text: md, kind: 'listing' }); } const video = [...new Set(sl.map(l => l.fields.video).filter((x): x is string => !!x))]; const playVideo = video.find(v => YOUTUBE.test(v)); if (playVideo) mapped.push({ from: 'metadata/android//video.txt', to: 'play.video' }); for (const v of video) if (v !== playVideo) notMapped.push({ what: `video.txt "${v}"`, where: YOUTUBE.test(v) ? 'play.video holds one URL for every language: set the others in Play Console' : 'play.video needs a plain YouTube watch URL' }); // --- art: icon, feature graphic, screenshots let playIcon: string | undefined; const iconSrc = (primaryPlay?.images.icon ? primaryPlay : sl.find(l => l.images.icon))?.images.icon; if (iconSrc) { if (/\.png$/i.test(iconSrc)) { playIcon = 'store/icons/play-512.png'; files.push({ path: playIcon, from: iconSrc, src: rel(iconSrc), kind: 'icon' }); mapped.push({ from: rel(iconSrc), to: `${playIcon} (play.icon)` }); } else notMapped.push({ what: rel(iconSrc), where: 'play.icon must be a 512 x 512 PNG: convert it and set play.icon' }); } for (const l of sl) { const id = idOfPlay(l.code)!; const fg = l.images.featureGraphic; if (fg) { const p = `store/renders/${id}/feature/1024x500${extname(fg).toLowerCase().replace('.jpeg', '.jpg')}`; files.push({ path: p, from: fg, src: rel(fg), kind: 'feature' }); mapped.push({ from: rel(fg), to: p }); } for (const k of ['promoGraphic', 'tvBanner']) if (l.images[k]) notMapped.push({ what: rel(l.images[k]!), where: 'Play Console > Store listing (storebound uploads icon, feature graphic and phone / tablet screenshots)' }); for (const k of ['tv', 'wear'] as const) if (l.shots[k].length) notMapped.push({ what: `metadata/android/${l.folder}/images/${k}Screenshots (${l.shots[k].length})`, where: `Play Console > Store listing (${k === 'tv' ? 'Android TV' : 'Wear OS'} screenshots)` }); } // screenshots -> frames: ///NN.png; iOS shots win over Play shots for the same language const sets = new Map>(); const why = new Map>(); let ipad = false; for (const [loc, list] of Object.entries(scan.deliver.shotLocales)) { const id = idOfApple(loc); if (!id) { notMapped.push({ what: `screenshots/${loc}`, where: 'no matching language in the import (no metadata for it)' }); continue; } const c = classifyAppleShots(list); if (c.tablet.length) ipad = true; sets.set(id, { phone: c.phone, tablet: c.tablet }); why.set(id, { phone: `screenshots/${loc}`, tablet: `screenshots/${loc} (iPad)` }); if (c.skipped.length) notMapped.push({ what: `screenshots/${loc}: ${c.skipped.length} file(s) (other device sizes, Watch, TV or Mac)`, where: 'storebound renders one phone and one tablet set per language and scales them to every store size' }); } for (const l of sl) { const id = idOfPlay(l.code)!; const s = sets.get(id) ?? { phone: [], tablet: [] }; const w = why.get(id) ?? { phone: '', tablet: '' }; if (!s.phone.length && l.shots.phone.length) { s.phone = l.shots.phone; w.phone = `metadata/android/${l.folder}/images/phoneScreenshots`; } const tab = l.shots.ten.length ? ['ten', l.shots.ten] as const : l.shots.seven.length ? ['seven', l.shots.seven] as const : null; if (!s.tablet.length && tab) { s.tablet = [...tab[1]]; w.tablet = `metadata/android/${l.folder}/images/${tab[0]}InchScreenshots`; } if (s.phone.length || s.tablet.length) { sets.set(id, s); why.set(id, w); } } let scenes = 0; const copyJson: Record> = {}; for (const [id, s] of sets) { for (const device of ['phone', 'tablet'] as Device[]) { const list = s[device]; if (!list.length) continue; const png = list.filter(f => /\.png$/i.test(f)); if (png.length < list.length) notMapped.push({ what: `${why.get(id)![device]}: ${list.length - png.length} JPEG file(s)`, where: `frames must be PNG: convert them into store/shots/frames/${id}/${device}/` }); const take = png.slice(0, MAX_SCENES); if (png.length > MAX_SCENES) notMapped.push({ what: `${why.get(id)![device]}: ${png.length - MAX_SCENES} screenshot(s) over ${MAX_SCENES}`, where: 'the App Store takes 10 per set, Google Play 8' }); take.forEach((f, i) => files.push({ path: `store/shots/frames/${id}/${device}/${String(i + 1).padStart(2, '0')}.png`, from: f, src: rel(f), kind: 'frame' })); if (take.length) mapped.push({ from: `${why.get(id)![device]} (${take.length})`, to: `store/shots/frames/${id}/${device}/${take.length > 1 ? `01..${String(take.length).padStart(2, '0')}` : '01'}.png` }); if (device === 'phone') scenes = Math.max(scenes, take.length); } } if (!scenes && [...sets.values()].some(s => s.tablet.length)) scenes = Math.max(...[...sets.values()].map(s => Math.min(MAX_SCENES, s.tablet.filter(f => /\.png$/i.test(f)).length))); const sceneIds = Array.from({ length: scenes }, (_, i) => `shot-${String(i + 1).padStart(2, '0')}`); if (scenes) { for (const l of languages) copyJson[l.id] = Object.fromEntries(sceneIds.map(s => [s, { title: ['', ''] as [string, string], sub: '' }])); files.push({ path: 'store/shots/copy.json', text: JSON.stringify(copyJson, null, 2) + '\n', kind: 'copy' }); notMapped.push({ what: 'screenshot captions', where: 'fastlane screenshots are often finished art: write the headlines per scene in store/shots/copy.json and render (`storebound shots render`), or keep uploading the finished screenshots' }); notMapped.push({ what: 'frame sizes', where: '`storebound shots capture --source folder` checks every frame (missing languages / tablet frames, aspect ratio: full-screen screenshots need the status bar cropped)' }); } // --- deliver / supply options with a storebound home elsewhere const OPT: Record = { submit_for_review: 'never automated: the owner presses Submit for review in App Store Connect (AGENTS.md rule 1)', automatic_release: 'release-management skill (`storebound apple release`, the owner decides)', phased_release: 'release-management skill (`storebound apple release --phased on`, asks first)', price_tier: 'apple.info.free / the products and prices CSV (`storebound prices`)', app_rating_config_path: 'apple.info.ageRating, answered with the owner (store-declarations skill)', submission_information: 'export compliance: apple.usesNonExemptEncryption (export-compliance skill); content rights: apple.info.contentRights', reject_if_possible: 'never automated: cancel a submission in App Store Connect', run_precheck_before_submit: '`storebound check --aso` and `storebound validate`', skip_screenshots: 'run only the commands you need (`apple listing` vs `apple screenshots`)', skip_metadata: 'run only the commands you need', skip_binary_upload: 'storebound uploads no iOS binary (Xcode / xcodebuild / Transporter do)', force: 'every storebound write shows a dry run first', overwrite_screenshots: '`storebound apple screenshots` replaces the sets it uploads', languages: 'languages[] in storebound.config.ts', ipa: 'storebound uploads no iOS binary', pkg: 'storebound uploads no binary', app_version: '`storebound apple listing --version x`', build_number: '`storebound apple listing --build N` (explicit number)', platform: 'apple.ipad / screenshotTypes', copyright: 'app.copyright', primary_category: 'apple.info.categories', secondary_category: 'apple.info.categories', }; for (const [k, v] of Object.entries(D)) { if (['app_identifier', 'metadata_path', 'screenshots_path', 'username', 'team_id', 'team_name', 'itc_team_id', 'itc_team_name', 'app', 'apple_id'].includes(k)) continue; if (k === 'copyright') continue; if (k === 'primary_category' || k === 'secondary_category' || k.endsWith('_sub_category')) continue; notMapped.push({ what: `Deliverfile ${k}${typeof v === 'boolean' ? ` = ${v}` : ''}`, where: OPT[k] ?? 'no storebound equivalent; set in App Store Connect if you need it' }); } if (typeof D.copyright === 'string' && !copyright) mapped.push({ from: 'Deliverfile copyright', to: 'app.copyright' }); const appCopyright = copyright ?? (typeof D.copyright === 'string' ? D.copyright : undefined); // --- Fastfile / Matchfile hints if (scan.matchfile) notMapped.push({ what: `Matchfile (${Object.keys(scan.matchfile.values).filter(k => !/url|password|token|key/i.test(k)).join(', ') || 'options'})`, where: 'code signing stays with match / Xcode: storebound does not sign builds (keep the Matchfile; its git_url and passphrase stay out of storebound)' }); const ACT: Record = { app_store_connect_api_key: 'ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json (the .p8 path, never its contents)', upload_to_app_store: '`storebound apple listing`, `apple screenshots`, `apple previews` (dry run first; submitting stays with the owner)', deliver: '`storebound apple listing` / `apple screenshots`', upload_to_play_store: '`storebound play listing`, `play images`, `play release ` (testing tracks; production is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`', upload_to_testflight: '`storebound apple testflight` (testflight skill)', pilot: '`storebound apple testflight` (testflight skill)', match: 'keep match for signing', sync_code_signing: 'keep match for signing', snapshot: '`storebound shots capture --source ios-sim`', capture_screenshots: '`storebound shots capture --source ios-sim`', capture_ios_screenshots: '`storebound shots capture --source ios-sim`', screengrab: '`storebound shots capture --source android-emu`', capture_android_screenshots: '`storebound shots capture --source android-emu`', frameit: '`storebound shots render` (copy in store/shots/copy.json)', frame_screenshots: '`storebound shots render`', increment_build_number: '`storebound apple next-build`', latest_testflight_build_number: '`storebound apple next-build`', increment_version_code: '`storebound play next-version-code`', google_play_track_version_codes: '`storebound play next-version-code`', precheck: '`storebound check --aso` + `storebound validate`', check_app_store_metadata: '`storebound check --aso` + `storebound validate`', gym: 'keep your build step (storebound does not build)', build_app: 'keep your build step', build_ios_app: 'keep your build step', gradle: 'keep your build step', build_android_app: 'keep your build step', }; if (scan.fastfile) { if (scan.fastfile.lanes.length) notMapped.push({ what: `Fastfile lanes: ${scan.fastfile.lanes.join(', ')}`, where: 'not converted: the storebound commands below replace the store steps; keep fastlane for building and signing if you like' }); for (const a of scan.fastfile.actions) (a === 'app_store_connect_api_key' ? secrets : notMapped).push({ what: `Fastfile ${a}`, where: ACT[a] ?? 'no storebound equivalent' }); } // --- config const q = tsString; const L: string[] = []; L.push(`// storebound config imported from fastlane (\`storebound import fastlane\`, profile "${profile}"). Check every value, then`, '// run `storebound init` (adds the declarations, App Privacy, privacy policy and credentials templates; keeps these files)', '// and `storebound check`. Credentials NEVER go here (env vars or .storebound/credentials.json, gitignored).', "// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).", "import type { ShipConfig } from 'storebound';", '', 'const config: ShipConfig = {', ' app: {', ` name: ${q(appName)},`, ` profile: '${profile}',`); if (appCopyright) L.push(` copyright: ${q(appCopyright)},`); L.push(` privacyPolicyUrl: ${q(privacy ?? 'https://example.com/privacy')},${privacy ? '' : ' // placeholder: your privacy policy page'}`); if (profile === 'subscription-app') L.push(" termsUrl: 'https://example.com/terms', // placeholder: Terms of Use, required with subscriptions (Apple 3.1.2)"); L.push(` supportUrl: ${q(support ?? 'https://example.com/support')},${support ? '' : ' // placeholder'}`); if (marketing) L.push(` marketingUrl: ${q(marketing)},`); L.push(` website: ${q(marketing ?? 'https://example.com')},${marketing ? '' : ' // placeholder: developer website'}`, " contactEmail: 'support@example.com', // placeholder: the public support email", ' },', ''); if (signIn) L.push(' // fastlane had an App Review demo account: put it in .storebound/credentials.json ("reviewDemoAccount"), never here.', ' reviewAccess: {', ' signInRequired: true,', ' },', ''); L.push(' // id = folder / copy key; play = Google Play language code; apple = App Store Connect locale', ' languages: ['); for (const l of languages) L.push(` { id: ${q(l.id)}${l.play ? `, play: ${q(String(l.play))}` : ''}${l.apple ? `, apple: ${q(l.apple)}` : ''} },`); L.push(' ],', ''); if (hasPlay) { L.push(' play: {', ` packageName: ${q(packageName ?? 'com.example.app')},${packageName ? '' : ' // placeholder: no package_name in the Appfile'}`); const def = languages.find(l => l.play === 'en-US')?.play ?? languages.find(l => l.play)?.play; if (def) L.push(` defaultLanguage: ${q(String(def))},`); L.push(" listing: 'store/listing.md',"); if (playIcon) L.push(` icon: ${q(playIcon)},`); if (playVideo) L.push(` video: ${q(playVideo)},`); L.push(' },', ''); if (!packageName) placeholders.push({ what: 'play.packageName', where: 'com.example.app: no package_name in the Appfile' }); } if (hasApple) { L.push(' apple: {', ` bundleId: ${q(bundleId ?? 'com.example.app')},${bundleId ? '' : ' // placeholder: no app_identifier in the Appfile'}`); if (appIdFound) L.push(" // appId: '', // found in the Deliverfile; optional"); L.push(" listing: 'store/appstore.md',", ' // Export compliance (export-compliance skill): decide it with the owner, then uncomment. false ONLY if the app uses', ' // no encryption beyond the OS (HTTPS through the system, standard sign-in).', ' // usesNonExemptEncryption: false,'); if (ipad) L.push(' ipad: true, // fastlane had iPad screenshots'); const info: string[] = []; if (Object.keys(cats).length) info.push(` categories: { ${Object.entries(cats).map(([k, v]) => `${k}: ${q(v!)}`).join(', ')} },`); if (reviewNotes) info.push(` reviewNotes: ${q(reviewNotes)},`); if (info.length) L.push(' info: {', ...info, ' },'); L.push(' },', ''); if (!bundleId) placeholders.push({ what: 'apple.bundleId', where: 'com.example.app: no app_identifier in the Appfile' }); } L.push(" renders: 'store/renders',"); if (scenes) { const pal = Object.keys(style === 'app' ? APP_PALETTES : PALETTES); L.push('', ' // Frames imported from the fastlane screenshots: store/shots/frames///NN.png', ' shots: {', ` style: '${style}',`, " copy: 'store/shots/copy.json',", " frames: 'store/shots/frames',", " out: 'store/renders',", ' scenes: ['); sceneIds.forEach((s, i) => L.push(` { id: '${s}', palette: '${pal[i % pal.length]}', frames: [{ src: '${String(i + 1).padStart(2, '0')}.png' }] },`)); L.push(' ],', ` feature: { frame: '01.png', palette: '${pal[0]}' },`, " capture: { source: 'folder', folder: 'store/shots/frames', shots: {} },", ' },'); } L.push('};', '', 'export default config;', ''); files.unshift({ path: 'storebound.config.ts', text: L.join('\n'), kind: 'config' }); return { profile, appName, bundleId, packageName, languages, files, mapped, notMapped, secrets, placeholders, warnings }; } /** Where a text file goes when its target exists: name.imported.ext next to it. */ export const importedPath = (p: string): string => { const ext = extname(p); return `${p.slice(0, p.length - ext.length)}.imported${ext}`; }; /** Default fastlane folders under a project: fastlane/, ios/fastlane/, android/fastlane/ (the ones that exist). */ export function findFastlaneDirs(root: string): string[] { return ['fastlane', 'ios/fastlane', 'android/fastlane'].map(d => join(root, d)).filter(isDir); }