storebound/cli/import.ts
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

73 lines
5.3 KiB
TypeScript

// 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`');
}