705 lines
50 KiB
TypeScript
705 lines
50 KiB
TypeScript
// 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 { consoleLink } from './console-links';
|
|
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 characters 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 it 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 ${consoleLink('asc.app-info')} (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: `${consoleLink('asc.app-info')} (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 characters)`);
|
|
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 \`apple.name\`, else \`app.name\`),
|
|
\`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 characters, 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 that text in store/appstore.md');
|
|
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: `${consoleLink('play.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: `${consoleLink('play.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);
|
|
}
|