// Migration from other tools. Only reads the source; writes storebound files next to it without overwriting anything. // storebound import fastlane [--dir [,]] [--out ] [--profile app|game|subscription-app] [--dry] // fastlane: Appfile / Deliverfile ids and options, deliver metadata (fastlane/metadata//*.txt, default/, // review_information/notes.txt), supply metadata (fastlane/metadata/android//*, changelogs, images/) and the // screenshots become storebound.config.ts, store/appstore.md, store/listing.md and, with screenshots, a `folder` capture // source (store/shots/frames///NN.png + store/shots/copy.json). Default --dir: fastlane/, // ios/fastlane/ and android/fastlane/ under --out (default: the current folder), every one that exists. // A file that already exists is kept: the import goes to .imported. next to it (images are skipped), with a // diff hint. Keys, team ids, the App Review contact and demo account are never copied: the report says where they go. import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import type { Args } from './lib/args'; import { type AppProfile, PROFILES } from './lib/config'; import { findFastlaneDirs, type ImportPlan, importedPath, planImport, scanFastlane } from './lib/fastlane'; import { log, warn } from './lib/log'; export interface Applied { created: string[]; imported: { target: string; written: string }[]; kept: string[] } /** Writes a plan under `root` (nothing with dry). Existing text files get a .imported copy; existing images are kept. */ export function applyPlan(plan: ImportPlan, root: string, dry: boolean): Applied { const out: Applied = { created: [], imported: [], kept: [] }; for (const f of plan.files) { const dest = join(root, f.path); if (relative(root, dest).startsWith('..')) throw new Error(`import target outside the project: ${f.path}`); let target = dest; if (existsSync(dest)) { if (f.text === undefined) { out.kept.push(f.path); log('import', `keep ${f.path} (exists)`); continue; } target = importedPath(dest); out.imported.push({ target: f.path, written: relative(root, target) }); log('import', `${dry ? 'would write' : 'write '} ${relative(root, target)} (${f.path} exists; diff: git diff --no-index ${f.path} ${relative(root, target)})`); } else { out.created.push(f.path); log('import', `${dry ? 'would create' : 'create'} ${f.path}${f.from ? ` <- ${f.src ?? relative(root, f.from)}` : ''}`); } if (dry) continue; mkdirSync(dirname(target), { recursive: true }); if (f.text !== undefined) writeFileSync(target, f.text); else copyFileSync(f.from!, target); } return out; } /** The report: what went where, what did not, secrets named (never copied), placeholders, warnings. */ export function report(plan: ImportPlan): string { const lines: string[] = []; const sec = (title: string, rows: string[]) => { if (rows.length) lines.push('', `${title}:`, ...rows.map(r => ` - ${r}`)); }; lines.push(`fastlane import: "${plan.appName}", profile ${plan.profile}, ${plan.languages.length} language(s)` + `${plan.bundleId ? `, iOS ${plan.bundleId}` : ''}${plan.packageName ? `, Android ${plan.packageName}` : ''}`); sec('mapped', plan.mapped.map(m => `${m.from} -> ${m.to}`)); sec('not mapped (do these by hand)', plan.notMapped.map(n => `${n.what}: ${n.where}`)); sec('keys, accounts and personal data (named only, nothing copied)', plan.secrets.map(n => `${n.what}: ${n.where}`)); sec('placeholders to replace in storebound.config.ts', plan.placeholders.map(n => `${n.what}: ${n.where}`)); sec('warnings', plan.warnings); return lines.join('\n'); } export async function run(args: Args): Promise { const what = args._[0]; if (what !== 'fastlane') throw new Error('usage: storebound import fastlane [--dir ] [--out ] [--profile app|game|subscription-app] [--dry]'); const root = resolve(args.str('out', '.')); const profile = args.get('profile') as AppProfile | undefined; if (profile !== undefined && !PROFILES.includes(profile)) throw new Error(`--profile ${profile}: one of ${PROFILES.join(', ')}`); const dirs = args.list('dir').length ? args.list('dir').map(d => resolve(d)) : findFastlaneDirs(root); if (!dirs.length) throw new Error(`no fastlane folder in ${root} (looked for fastlane/, ios/fastlane/, android/fastlane/); pass --dir`); for (const d of dirs) if (!existsSync(d)) throw new Error(`--dir ${d}: not found`); const dry = args.has('dry'); log('import', `fastlane: ${dirs.map(d => relative(root, d) || '.').join(', ')} -> ${root}${dry ? ' (dry run: nothing is written)' : ''}`); const plan = planImport(scanFastlane(dirs), { root, profile }); const applied = applyPlan(plan, root, dry); console.log(report(plan)); if (applied.imported.length) warn('import', `${applied.imported.length} file(s) existed and were kept: compare the .imported copies (git diff --no-index ) and merge what you want`); log('import', dry ? 'dry run: run again without --dry to write the files' : 'next: `storebound init` (adds the remaining templates, keeps these files), replace the placeholders, then `storebound check`'); }