Compare commits
2 commits
44e64cf9c6
...
72dda418ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 72dda418ea | |||
| de681dd967 |
|
|
@ -46,6 +46,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
||||||
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
||||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||||
|
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
||||||
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game
|
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game
|
||||||
- `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins)
|
- `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins)
|
||||||
- `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game
|
- `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game
|
||||||
|
|
|
||||||
1
.github/copilot-instructions.md
vendored
|
|
@ -48,6 +48,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
||||||
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
||||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||||
|
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
||||||
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |
|
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |
|
||||||
| `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) |
|
| `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) |
|
||||||
| `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game |
|
| `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game |
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
||||||
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
||||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||||
|
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
||||||
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game
|
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game
|
||||||
- `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins)
|
- `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins)
|
||||||
- `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game
|
- `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,15 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- **`storebound import fastlane`** (`--dir`, `--out`, `--profile`, `--dry`; MCP `import_fastlane`, dry run by default):
|
||||||
|
reads Appfile / Deliverfile (plain `key("value")` lines only; Ruby logic is reported, never run), deliver and supply
|
||||||
|
metadata and screenshots, and writes `storebound.config.ts`, `store/appstore.md`, `store/listing.md`, the Play icon
|
||||||
|
and feature graphics, and screenshot frames with a `folder` capture source. App Store and Play language codes are
|
||||||
|
paired into one `languages[]` entry (`cli/lib/store-locales.ts`). Existing files are never overwritten (a
|
||||||
|
`<name>.imported.<ext>` copy plus a diff hint). Keys, team ids, the App Review contact and demo account are only
|
||||||
|
named in the report; submission flags, `price_tier`, the age rating config, lanes and signing are listed with their
|
||||||
|
storebound counterpart. New skill `migrate-from-fastlane`.
|
||||||
|
|
||||||
- Fixes from a read-only run against live store accounts: `apps check` says `api-disabled` (not `permission`) when the
|
- Fixes from a read-only run against live store accounts: `apps check` says `api-disabled` (not `permission`) when the
|
||||||
Play Developer Reporting API is off, and `apps list` / serve `apps.list` fail with that reason and the fix instead of
|
Play Developer Reporting API is off, and `apps list` / serve `apps.list` fail with that reason and the fix instead of
|
||||||
a JSON fragment (API error bodies are kept on one line everywhere); the newest App Store version is the newest by
|
a JSON fragment (API error bodies are kept on one line everywhere); the newest App Store version is the newest by
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
||||||
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
||||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||||
|
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
||||||
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |
|
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |
|
||||||
| `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) |
|
| `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) |
|
||||||
| `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game |
|
| `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game |
|
||||||
|
|
|
||||||
30
README.md
|
|
@ -130,6 +130,32 @@ catalog", "the reviewer could not sign in", "make store screenshots in 5 languag
|
||||||
update rejected", "put build 42 on TestFlight", "how are our crash rates", "answer the new reviews", "did the new
|
update rejected", "put build 42 on TestFlight", "how are our crash rates", "answer the new reviews", "did the new
|
||||||
screenshots help". The matching skill takes over.
|
screenshots help". The matching skill takes over.
|
||||||
|
|
||||||
|
### Coming from fastlane
|
||||||
|
|
||||||
|
`storebound import fastlane` turns an existing fastlane setup into storebound files. It only reads (no Ruby is run),
|
||||||
|
never overwrites anything and prints what it could not map:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bunx storebound import fastlane --dry # the plan + report; default --dir: fastlane/, ios/fastlane/, android/fastlane/
|
||||||
|
bunx storebound import fastlane # writes the files below; an existing file gets a <name>.imported.<ext> copy
|
||||||
|
bunx storebound init # adds the remaining templates, keeps the imported files
|
||||||
|
bunx storebound check
|
||||||
|
```
|
||||||
|
|
||||||
|
| fastlane | storebound |
|
||||||
|
|---|---|
|
||||||
|
| Appfile `app_identifier`, `package_name` | `apple.bundleId`, `play.packageName` |
|
||||||
|
| deliver `metadata/<locale>/*.txt` (+ `default/`) | `store/appstore.md` per App Store locale; URLs, copyright, categories and review notes in the config |
|
||||||
|
| supply `metadata/android/<lang>/*.txt`, newest `changelogs/<versionCode>.txt` | `store/listing.md` per Play language |
|
||||||
|
| deliver and supply language folders | `languages[]` with both store codes (`tr` + `tr-TR`, `zh-Hans` + `zh-CN`, `he` + `iw-IL`) |
|
||||||
|
| `screenshots/<locale>/`, `images/phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames/<lang>/<phone\|tablet>/NN.png` with a `folder` capture source |
|
||||||
|
| supply `icon`, `featureGraphic` | `play.icon`, `store/renders/<lang>/feature/1024x500.*` |
|
||||||
|
|
||||||
|
Keys and accounts are never copied: `json_key_file`, the API key, team ids, the App Review contact and demo account are
|
||||||
|
named in the report with where they go (`.storebound/credentials.json`). Submission and release flags, `price_tier`,
|
||||||
|
the age rating config, lanes and signing (`match`) are listed with their storebound counterpart. The
|
||||||
|
`migrate-from-fastlane` skill walks an agent through it.
|
||||||
|
|
||||||
### Credentials (never in the repo)
|
### Credentials (never in the repo)
|
||||||
|
|
||||||
Read from environment variables, or from `.storebound/credentials.json` in your project (gitignored by `init`):
|
Read from environment variables, or from `.storebound/credentials.json` in your project (gitignored by `init`):
|
||||||
|
|
@ -173,6 +199,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| command | kind | what |
|
| command | kind | what |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `init [--profile app\|game\|subscription-app]` | local | copy the templates of a profile (default app), gitignore `.storebound/` |
|
| `init [--profile app\|game\|subscription-app]` | local | copy the templates of a profile (default app), gitignore `.storebound/` |
|
||||||
|
| `import fastlane [--dir fastlane] [--profile ...]` | local | config, `store/appstore.md`, `store/listing.md` and screenshot frames from a fastlane setup; never overwrites, reports what is not mapped |
|
||||||
| `install --agent <tool>` | local | skills + MCP entry for an AI tool |
|
| `install --agent <tool>` | local | skills + MCP entry for an AI tool |
|
||||||
| `mcp` | local | stdio MCP server wrapping the commands |
|
| `mcp` | local | stdio MCP server wrapping the commands |
|
||||||
| `serve --stdio` | local | JSON-RPC session for the storebound desktop app (see [Desktop machine interface](#desktop-machine-interface)) |
|
| `serve --stdio` | local | JSON-RPC session for the storebound desktop app (see [Desktop machine interface](#desktop-machine-interface)) |
|
||||||
|
|
@ -331,6 +358,7 @@ navigation.
|
||||||
|
|
||||||
| skill | for |
|
| skill | for |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `migrate-from-fastlane` | `storebound import fastlane`: ids, listings, languages and screenshots from fastlane; keys and accounts stay out |
|
||||||
| `play-first-release` | Google Play from zero to "In review" |
|
| `play-first-release` | Google Play from zero to "In review" |
|
||||||
| `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key |
|
| `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key |
|
||||||
| `testflight` | beta groups, testers from a CSV that never enters git, What to Test, Test Information, Beta App Review |
|
| `testflight` | beta groups, testers from a CSV that never enters git, What to Test, Test Information, Beta App Review |
|
||||||
|
|
@ -365,7 +393,7 @@ per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same proc
|
||||||
|
|
||||||
- One tool per command. Read-only: `check`, `check_aso`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`,
|
- One tool per command. Read-only: `check`, `check_aso`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`,
|
||||||
`analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`,
|
`analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`,
|
||||||
`play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`. Local writes: `whats_new`, `icons`,
|
`play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`. Local writes: `import_fastlane`, `whats_new`, `icons`,
|
||||||
`health_report`, `reviews_draft`, `prices_export`, `analytics_snapshot`, `shots_capture`, `shots_render`,
|
`health_report`, `reviews_draft`, `prices_export`, `analytics_snapshot`, `shots_capture`, `shots_render`,
|
||||||
`shots_gallery`, `shots_record`, `shots_video`. Store writes: `play_listing`, `play_images`, `play_products`,
|
`shots_gallery`, `shots_record`, `shots_video`. Store writes: `play_listing`, `play_images`, `play_products`,
|
||||||
`play_data_safety`, `play_subscriptions`, `play_release_testing`, `apple_bundle`, `apple_iap`, `apple_iap_review`,
|
`play_data_safety`, `play_subscriptions`, `play_release_testing`, `apple_bundle`, `apple_iap`, `apple_iap_review`,
|
||||||
|
|
|
||||||
73
cli/import.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// Migration from other tools. Only reads the source; writes storebound files next to it without overwriting anything.
|
||||||
|
// storebound import fastlane [--dir <fastlane dir>[,<dir>]] [--out <project folder>] [--profile app|game|subscription-app] [--dry]
|
||||||
|
// fastlane: Appfile / Deliverfile ids and options, deliver metadata (fastlane/metadata/<locale>/*.txt, default/,
|
||||||
|
// review_information/notes.txt), supply metadata (fastlane/metadata/android/<lang>/*, changelogs, images/) and the
|
||||||
|
// screenshots become storebound.config.ts, store/appstore.md, store/listing.md and, with screenshots, a `folder` capture
|
||||||
|
// source (store/shots/frames/<lang>/<phone|tablet>/NN.png + store/shots/copy.json). Default --dir: fastlane/,
|
||||||
|
// ios/fastlane/ and android/fastlane/ under --out (default: the current folder), every one that exists.
|
||||||
|
// A file that already exists is kept: the import goes to <name>.imported.<ext> next to it (images are skipped), with a
|
||||||
|
// diff hint. Keys, team ids, the App Review contact and demo account are never copied: the report says where they go.
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative, resolve } from 'node:path';
|
||||||
|
import type { Args } from './lib/args';
|
||||||
|
import { type AppProfile, PROFILES } from './lib/config';
|
||||||
|
import { findFastlaneDirs, type ImportPlan, importedPath, planImport, scanFastlane } from './lib/fastlane';
|
||||||
|
import { log, warn } from './lib/log';
|
||||||
|
|
||||||
|
export interface Applied { created: string[]; imported: { target: string; written: string }[]; kept: string[] }
|
||||||
|
|
||||||
|
/** Writes a plan under `root` (nothing with dry). Existing text files get a .imported copy; existing images are kept. */
|
||||||
|
export function applyPlan(plan: ImportPlan, root: string, dry: boolean): Applied {
|
||||||
|
const out: Applied = { created: [], imported: [], kept: [] };
|
||||||
|
for (const f of plan.files) {
|
||||||
|
const dest = join(root, f.path);
|
||||||
|
if (relative(root, dest).startsWith('..')) throw new Error(`import target outside the project: ${f.path}`);
|
||||||
|
let target = dest;
|
||||||
|
if (existsSync(dest)) {
|
||||||
|
if (f.text === undefined) { out.kept.push(f.path); log('import', `keep ${f.path} (exists)`); continue; }
|
||||||
|
target = importedPath(dest);
|
||||||
|
out.imported.push({ target: f.path, written: relative(root, target) });
|
||||||
|
log('import', `${dry ? 'would write' : 'write '} ${relative(root, target)} (${f.path} exists; diff: git diff --no-index ${f.path} ${relative(root, target)})`);
|
||||||
|
} else {
|
||||||
|
out.created.push(f.path);
|
||||||
|
log('import', `${dry ? 'would create' : 'create'} ${f.path}${f.from ? ` <- ${f.src ?? relative(root, f.from)}` : ''}`);
|
||||||
|
}
|
||||||
|
if (dry) continue;
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
if (f.text !== undefined) writeFileSync(target, f.text); else copyFileSync(f.from!, target);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report: what went where, what did not, secrets named (never copied), placeholders, warnings. */
|
||||||
|
export function report(plan: ImportPlan): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const sec = (title: string, rows: string[]) => { if (rows.length) lines.push('', `${title}:`, ...rows.map(r => ` - ${r}`)); };
|
||||||
|
lines.push(`fastlane import: "${plan.appName}", profile ${plan.profile}, ${plan.languages.length} language(s)` +
|
||||||
|
`${plan.bundleId ? `, iOS ${plan.bundleId}` : ''}${plan.packageName ? `, Android ${plan.packageName}` : ''}`);
|
||||||
|
sec('mapped', plan.mapped.map(m => `${m.from} -> ${m.to}`));
|
||||||
|
sec('not mapped (do these by hand)', plan.notMapped.map(n => `${n.what}: ${n.where}`));
|
||||||
|
sec('keys, accounts and personal data (named only, nothing copied)', plan.secrets.map(n => `${n.what}: ${n.where}`));
|
||||||
|
sec('placeholders to replace in storebound.config.ts', plan.placeholders.map(n => `${n.what}: ${n.where}`));
|
||||||
|
sec('warnings', plan.warnings);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(args: Args): Promise<void> {
|
||||||
|
const what = args._[0];
|
||||||
|
if (what !== 'fastlane') throw new Error('usage: storebound import fastlane [--dir <fastlane dir>] [--out <project folder>] [--profile app|game|subscription-app] [--dry]');
|
||||||
|
const root = resolve(args.str('out', '.'));
|
||||||
|
const profile = args.get('profile') as AppProfile | undefined;
|
||||||
|
if (profile !== undefined && !PROFILES.includes(profile)) throw new Error(`--profile ${profile}: one of ${PROFILES.join(', ')}`);
|
||||||
|
const dirs = args.list('dir').length ? args.list('dir').map(d => resolve(d)) : findFastlaneDirs(root);
|
||||||
|
if (!dirs.length) throw new Error(`no fastlane folder in ${root} (looked for fastlane/, ios/fastlane/, android/fastlane/); pass --dir`);
|
||||||
|
for (const d of dirs) if (!existsSync(d)) throw new Error(`--dir ${d}: not found`);
|
||||||
|
const dry = args.has('dry');
|
||||||
|
log('import', `fastlane: ${dirs.map(d => relative(root, d) || '.').join(', ')} -> ${root}${dry ? ' (dry run: nothing is written)' : ''}`);
|
||||||
|
const plan = planImport(scanFastlane(dirs), { root, profile });
|
||||||
|
const applied = applyPlan(plan, root, dry);
|
||||||
|
console.log(report(plan));
|
||||||
|
if (applied.imported.length) warn('import', `${applied.imported.length} file(s) existed and were kept: compare the .imported copies (git diff --no-index <file> <file.imported>) and merge what you want`);
|
||||||
|
log('import', dry ? 'dry run: run again without --dry to write the files'
|
||||||
|
: 'next: `storebound init` (adds the remaining templates, keeps these files), replace the placeholders, then `storebound check`');
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ type Plain = { run(args: ReturnType<typeof parseArgs>): Promise<void> };
|
||||||
/** Commands that work without a storebound.config.ts. */
|
/** Commands that work without a storebound.config.ts. */
|
||||||
const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
||||||
'init': [() => import('./init'), 'copy the templates (config, listing, copy, declarations, privacy policy) into this project; --profile app|game|subscription-app (default app)'],
|
'init': [() => import('./init'), 'copy the templates (config, listing, copy, declarations, privacy policy) into this project; --profile app|game|subscription-app (default app)'],
|
||||||
|
'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'],
|
||||||
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
|
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
|
||||||
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
||||||
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
||||||
|
|
|
||||||
704
cli/lib/fastlane.ts
Normal file
|
|
@ -0,0 +1,704 @@
|
||||||
|
// Reads an existing fastlane setup (Appfile, Deliverfile, Fastfile / Matchfile hints, deliver and supply metadata,
|
||||||
|
// screenshots) and plans the storebound files for it: storebound.config.ts, store/listing.md, store/appstore.md and,
|
||||||
|
// with screenshots, a `folder` capture source (store/shots/frames + store/shots/copy.json). Only reads: Ruby is never
|
||||||
|
// run, only plain `key("value")` lines are taken, everything else is reported as "set by hand".
|
||||||
|
// Secrets and personal data never enter the plan's files: key files are named as references to move into .storebound/,
|
||||||
|
// App Review contact and demo account files are reported (the owner copies them), team / app ids become placeholders.
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
||||||
|
import { imageInfo } from '../shots/spec';
|
||||||
|
import { APP_PALETTES, PALETTES } from '../shots/lib';
|
||||||
|
import type { AppProfile, Language } from './config';
|
||||||
|
import { APPLE_LIMITS, len, PLAY_LIMITS, parseAppStoreListing, parsePlayListing } from './copy';
|
||||||
|
import { appleLocale, languageName, pairLanguages, playLocale } from './store-locales';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// Ruby option files (Appfile, Deliverfile, Matchfile): plain `key("value")` / `key "value"` lines only.
|
||||||
|
|
||||||
|
export type RubyValue = string | boolean | number | string[];
|
||||||
|
export interface RubyOptions {
|
||||||
|
/** Top-level options with a literal value. */
|
||||||
|
values: Record<string, RubyValue>;
|
||||||
|
/** Options set in a way storebound will not guess: inside a block (for_platform / for_lane / if), from ENV, a
|
||||||
|
* method call or string interpolation. Only the key, never the value. */
|
||||||
|
byHand: { key: string; line: number; why: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STR = /^(["'])((?:\\.|(?!\1).)*)\1$/;
|
||||||
|
|
||||||
|
function literal(raw: string): RubyValue | undefined {
|
||||||
|
const v = raw.trim();
|
||||||
|
const s = STR.exec(v);
|
||||||
|
if (s) return s[1] === '"' && /#\{/.test(s[2]!) ? undefined : s[2]!.replace(/\\(["'\\])/g, '$1');
|
||||||
|
if (v === 'true' || v === 'false') return v === 'true';
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
||||||
|
const arr = /^\[([\s\S]*)\]$/.exec(v);
|
||||||
|
if (arr) {
|
||||||
|
const items = arr[1]!.split(',').map(x => x.trim()).filter(Boolean);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const it of items) { const m = STR.exec(it); if (!m || /#\{/.test(m[2]!)) return undefined; out.push(m[2]!); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the simple option lines of an Appfile / Deliverfile / Matchfile. Pure. */
|
||||||
|
export function parseRubyOptions(text: string): RubyOptions {
|
||||||
|
const values: Record<string, RubyValue> = {};
|
||||||
|
const byHand: RubyOptions['byHand'] = [];
|
||||||
|
let depth = 0;
|
||||||
|
text.split(/\r?\n/).forEach((raw, i) => {
|
||||||
|
const line = raw.replace(/\s+#(?![{]).*$/, '').trim();
|
||||||
|
if (!line || line.startsWith('#')) return;
|
||||||
|
if (/^end\b/.test(line)) { depth = Math.max(0, depth - 1); return; }
|
||||||
|
const opens = /\bdo(\s*\|[^|]*\|)?$/.test(line) || /^(if|unless|case|begin|while|until)\b/.test(line);
|
||||||
|
const m = /^([a-z_][a-z0-9_]*)(?:\s*\(\s*([\s\S]*?)\s*\)|\s+([\s\S]+?))?$/.exec(line);
|
||||||
|
if (opens) { if (m && depth === 0 && !/^(if|unless|case|begin|while|until|for_platform|for_lane|platform|lane)$/.test(m[1]!)) byHand.push({ key: m[1]!, line: i + 1, why: 'opens a block' }); depth++; return; }
|
||||||
|
if (!m) { const k = /^([a-z_][a-z0-9_]*)\s*[("']/.exec(line); if (k) byHand.push({ key: k[1]!, line: i + 1, why: depth > 0 ? 'inside a block (for_platform / for_lane / if)' : 'not a plain value' }); return; }
|
||||||
|
const key = m[1]!;
|
||||||
|
const arg = m[2] ?? m[3];
|
||||||
|
if (arg === undefined) return;
|
||||||
|
if (depth > 0) { byHand.push({ key, line: i + 1, why: 'inside a block (for_platform / for_lane / if)' }); return; }
|
||||||
|
const v = literal(arg);
|
||||||
|
if (v === undefined) { byHand.push({ key, line: i + 1, why: /ENV\[/.test(arg) ? 'from an environment variable' : 'not a plain value' }); return; }
|
||||||
|
if (key in values) { byHand.push({ key, line: i + 1, why: 'set twice (the first one is used)' }); return; }
|
||||||
|
values[key] = v;
|
||||||
|
});
|
||||||
|
return { values, byHand };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// What a fastlane folder holds.
|
||||||
|
|
||||||
|
export interface DeliverLocale {
|
||||||
|
/** Folder name as found. */
|
||||||
|
folder: string;
|
||||||
|
/** App Store locale it maps to. */
|
||||||
|
locale: string;
|
||||||
|
known: boolean;
|
||||||
|
fields: Record<string, string>;
|
||||||
|
/** Fields that came from metadata/default. */
|
||||||
|
fromDefault: string[];
|
||||||
|
/** .txt files deliver knows that storebound does not map, and unknown ones. */
|
||||||
|
extra: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyLocale {
|
||||||
|
folder: string;
|
||||||
|
/** Play language code it maps to. */
|
||||||
|
code: string;
|
||||||
|
known: boolean;
|
||||||
|
fields: Record<string, string>;
|
||||||
|
/** Changelog file used for "What's new" (relative to the locale folder). */
|
||||||
|
changelog?: string;
|
||||||
|
images: Record<string, string>;
|
||||||
|
shots: Record<'phone' | 'seven' | 'ten' | 'tv' | 'wear', string[]>;
|
||||||
|
extra: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FastlaneScan {
|
||||||
|
dirs: string[];
|
||||||
|
appfile: RubyOptions;
|
||||||
|
deliverfile: RubyOptions;
|
||||||
|
matchfile: RubyOptions | null;
|
||||||
|
/** Fastfile hints: lane names and the actions storebound has its own answer for. */
|
||||||
|
fastfile: { lanes: string[]; actions: string[] } | null;
|
||||||
|
deliver: { metadata: string | null; locales: DeliverLocale[]; root: Record<string, string>; review: string[]; default: boolean; screenshots: string | null; shotLocales: Record<string, string[]>; skippedDirs: string[] };
|
||||||
|
supply: { metadata: string | null; locales: SupplyLocale[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELIVER_FIELDS: Record<string, string> = {
|
||||||
|
'name.txt': 'name', 'subtitle.txt': 'subtitle', 'description.txt': 'description', 'keywords.txt': 'keywords',
|
||||||
|
'promotional_text.txt': 'promotionalText', 'release_notes.txt': 'whatsNew', 'privacy_url.txt': 'privacyUrl',
|
||||||
|
'support_url.txt': 'supportUrl', 'marketing_url.txt': 'marketingUrl',
|
||||||
|
};
|
||||||
|
const FILE_OF: Record<string, string> = Object.fromEntries(Object.entries(DELIVER_FIELDS).map(([f, k]) => [k, f]));
|
||||||
|
const DELIVER_ROOT = ['copyright.txt', 'primary_category.txt', 'secondary_category.txt', 'primary_first_sub_category.txt',
|
||||||
|
'primary_second_sub_category.txt', 'secondary_first_sub_category.txt', 'secondary_second_sub_category.txt'];
|
||||||
|
const DELIVER_SPECIAL = new Set(['default', 'review_information', 'trade_representative_contact_information', 'android']);
|
||||||
|
const SUPPLY_FIELDS: Record<string, string> = { 'title.txt': 'title', 'short_description.txt': 'short', 'full_description.txt': 'full', 'video.txt': 'video' };
|
||||||
|
const IMG = /\.(png|jpe?g)$/i;
|
||||||
|
|
||||||
|
const readText = (p: string): string => readFileSync(p, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').trim();
|
||||||
|
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
||||||
|
const isFile = (p: string): boolean => { try { return statSync(p).isFile(); } catch { return false; } };
|
||||||
|
const ls = (p: string): string[] => (isDir(p) ? readdirSync(p).filter(n => !n.startsWith('.')).sort() : []);
|
||||||
|
const readOpts = (p: string): RubyOptions => (isFile(p) ? parseRubyOptions(readFileSync(p, 'utf8')) : { values: {}, byHand: [] });
|
||||||
|
|
||||||
|
/** Actions in a Fastfile that storebound has a counterpart for (reported, never run). */
|
||||||
|
const FASTFILE_ACTIONS = ['app_store_connect_api_key', 'upload_to_app_store', 'deliver', 'upload_to_play_store', 'supply', 'upload_to_testflight', 'pilot',
|
||||||
|
'match', 'sync_code_signing', 'snapshot', 'capture_screenshots', 'capture_ios_screenshots', 'screengrab', 'capture_android_screenshots', 'frameit',
|
||||||
|
'frame_screenshots', 'increment_build_number', 'increment_version_code', 'latest_testflight_build_number', 'google_play_track_version_codes',
|
||||||
|
'precheck', 'check_app_store_metadata', 'gym', 'build_app', 'build_ios_app', 'gradle', 'build_android_app'];
|
||||||
|
|
||||||
|
function scanFastfile(p: string): FastlaneScan['fastfile'] {
|
||||||
|
if (!isFile(p)) return null;
|
||||||
|
const text = readFileSync(p, 'utf8');
|
||||||
|
const lanes = [...text.matchAll(/^\s*(?:private_)?lane\s+:([a-z0-9_]+)/gm)].map(m => m[1]!);
|
||||||
|
const actions = FASTFILE_ACTIONS.filter(a => new RegExp(`(^|[^a-z0-9_.:])${a}\\b\\s*(\\(|$|\\s)`, 'm').test(text));
|
||||||
|
return { lanes, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads one or more fastlane folders (ios/fastlane + android/fastlane, or one fastlane/). Read-only. */
|
||||||
|
export function scanFastlane(dirs: string[]): FastlaneScan {
|
||||||
|
const scan: FastlaneScan = {
|
||||||
|
dirs, appfile: { values: {}, byHand: [] }, deliverfile: { values: {}, byHand: [] }, matchfile: null, fastfile: null,
|
||||||
|
deliver: { metadata: null, locales: [], root: {}, review: [], default: false, screenshots: null, shotLocales: {}, skippedDirs: [] },
|
||||||
|
supply: { metadata: null, locales: [] },
|
||||||
|
};
|
||||||
|
const merge = (into: RubyOptions, from: RubyOptions, file: string) => {
|
||||||
|
for (const [k, v] of Object.entries(from.values)) if (!(k in into.values)) into.values[k] = v; else if (JSON.stringify(into.values[k]) !== JSON.stringify(v)) into.byHand.push({ key: k, line: 0, why: `also set differently in ${file}` });
|
||||||
|
into.byHand.push(...from.byHand);
|
||||||
|
};
|
||||||
|
for (const dir of dirs) {
|
||||||
|
merge(scan.appfile, readOpts(join(dir, 'Appfile')), join(dir, 'Appfile'));
|
||||||
|
merge(scan.deliverfile, readOpts(join(dir, 'Deliverfile')), join(dir, 'Deliverfile'));
|
||||||
|
if (isFile(join(dir, 'Matchfile'))) { scan.matchfile ??= { values: {}, byHand: [] }; merge(scan.matchfile, readOpts(join(dir, 'Matchfile')), join(dir, 'Matchfile')); }
|
||||||
|
const ff = scanFastfile(join(dir, 'Fastfile'));
|
||||||
|
if (ff) scan.fastfile = { lanes: [...(scan.fastfile?.lanes ?? []), ...ff.lanes], actions: [...new Set([...(scan.fastfile?.actions ?? []), ...ff.actions])] };
|
||||||
|
// fastlane runs from the folder that contains fastlane/, so Deliverfile paths are relative to it
|
||||||
|
const base = dirname(dir);
|
||||||
|
const dv = readOpts(join(dir, 'Deliverfile')).values;
|
||||||
|
const meta = typeof dv.metadata_path === 'string' ? resolve(base, dv.metadata_path) : join(dir, 'metadata');
|
||||||
|
const shots = typeof dv.screenshots_path === 'string' ? resolve(base, dv.screenshots_path) : join(dir, 'screenshots');
|
||||||
|
if (!scan.deliver.metadata) readDeliver(scan, meta);
|
||||||
|
if (!scan.deliver.screenshots) readDeliverShots(scan, shots);
|
||||||
|
if (!scan.supply.metadata) readSupply(scan, join(dir, 'metadata', 'android'));
|
||||||
|
}
|
||||||
|
return scan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDeliver(scan: FastlaneScan, meta: string): void {
|
||||||
|
const folders = ls(meta).filter(n => isDir(join(meta, n)));
|
||||||
|
const locales = folders.filter(n => !DELIVER_SPECIAL.has(n));
|
||||||
|
const hasDefault = folders.includes('default');
|
||||||
|
const rootFiles = DELIVER_ROOT.filter(f => isFile(join(meta, f)));
|
||||||
|
if (!locales.length && !hasDefault && !rootFiles.length) return;
|
||||||
|
scan.deliver.metadata = meta;
|
||||||
|
scan.deliver.default = hasDefault;
|
||||||
|
for (const f of rootFiles) scan.deliver.root[f.replace(/\.txt$/, '')] = readText(join(meta, f));
|
||||||
|
const rev = join(meta, 'review_information');
|
||||||
|
scan.deliver.review = ls(rev).filter(f => /\.txt$/.test(f) && readText(join(rev, f)) !== '');
|
||||||
|
if (scan.deliver.review.includes('notes.txt')) scan.deliver.root.review_notes = readText(join(rev, 'notes.txt'));
|
||||||
|
const def: Record<string, string> = {};
|
||||||
|
if (hasDefault) for (const [f, k] of Object.entries(DELIVER_FIELDS)) if (isFile(join(meta, 'default', f))) def[k] = readText(join(meta, 'default', f));
|
||||||
|
// only a default folder: the Deliverfile languages, else en-US
|
||||||
|
const dl = scan.deliverfile.values.languages;
|
||||||
|
const names = locales.length ? locales : Array.isArray(dl) && dl.length ? dl : hasDefault ? ['en-US'] : [];
|
||||||
|
for (const folder of names) {
|
||||||
|
const d = join(meta, folder);
|
||||||
|
const n = appleLocale(folder);
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
const extra: string[] = [];
|
||||||
|
for (const f of ls(d)) {
|
||||||
|
if (!isFile(join(d, f))) continue;
|
||||||
|
const k = DELIVER_FIELDS[f];
|
||||||
|
if (k) { const t = readText(join(d, f)); if (t) fields[k] = t; } else extra.push(f);
|
||||||
|
}
|
||||||
|
const fromDefault: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(def)) if (!(k in fields) && v) { fields[k] = v; fromDefault.push(k); }
|
||||||
|
scan.deliver.locales.push({ folder, locale: n.code, known: n.known, fields, fromDefault, extra });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDeliverShots(scan: FastlaneScan, dir: string): void {
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
for (const folder of ls(dir)) {
|
||||||
|
const d = join(dir, folder);
|
||||||
|
if (!isDir(d)) continue;
|
||||||
|
const files = ls(d).filter(f => IMG.test(f) && isFile(join(d, f))).map(f => join(d, f));
|
||||||
|
if (!files.length) continue;
|
||||||
|
const n = appleLocale(folder);
|
||||||
|
if (!n.known && folder === 'default') { scan.deliver.skippedDirs.push(folder); continue; }
|
||||||
|
out[n.code] = files;
|
||||||
|
}
|
||||||
|
if (Object.keys(out).length) { scan.deliver.screenshots = dir; scan.deliver.shotLocales = out; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSupply(scan: FastlaneScan, dir: string): void {
|
||||||
|
const folders = ls(dir).filter(n => isDir(join(dir, n)));
|
||||||
|
if (!folders.length) return;
|
||||||
|
scan.supply.metadata = dir;
|
||||||
|
for (const folder of folders) {
|
||||||
|
const d = join(dir, folder);
|
||||||
|
const n = playLocale(folder);
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
const extra: string[] = [];
|
||||||
|
for (const f of ls(d)) {
|
||||||
|
if (!isFile(join(d, f))) continue;
|
||||||
|
const k = SUPPLY_FIELDS[f];
|
||||||
|
if (k) { const t = readText(join(d, f)); if (t) fields[k] = t; } else extra.push(f);
|
||||||
|
}
|
||||||
|
let changelog: string | undefined;
|
||||||
|
const cl = ls(join(d, 'changelogs')).filter(f => /\.txt$/.test(f));
|
||||||
|
const numbered = cl.filter(f => /^\d+\.txt$/.test(f)).sort((a, b) => parseInt(b) - parseInt(a));
|
||||||
|
const pick = numbered[0] ?? (cl.includes('default.txt') ? 'default.txt' : undefined);
|
||||||
|
if (pick) { const t = readText(join(d, 'changelogs', pick)); if (t) { fields.whatsNew = t; changelog = `changelogs/${pick}`; } }
|
||||||
|
const img = join(d, 'images');
|
||||||
|
const images: Record<string, string> = {};
|
||||||
|
for (const f of ls(img)) if (IMG.test(f) && isFile(join(img, f))) images[f.replace(IMG, '')] = join(img, f);
|
||||||
|
const list = (sub: string) => ls(join(img, sub)).filter(f => IMG.test(f)).map(f => join(img, sub, f));
|
||||||
|
const shots = { phone: list('phoneScreenshots'), seven: list('sevenInchScreenshots'), ten: list('tenInchScreenshots'), tv: list('tvScreenshots'), wear: list('wearScreenshots') };
|
||||||
|
scan.supply.locales.push({ folder, code: n.code, known: n.known, fields, changelog, images, shots, extra });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// The plan.
|
||||||
|
|
||||||
|
export interface PlanFile {
|
||||||
|
/** Path relative to the project root. */
|
||||||
|
path: string;
|
||||||
|
/** Text content, or a file to copy. */
|
||||||
|
text?: string;
|
||||||
|
from?: string;
|
||||||
|
/** `from` for the log, relative to the folder fastlane runs from. */
|
||||||
|
src?: string;
|
||||||
|
kind: 'config' | 'listing' | 'appstore' | 'copy' | 'frame' | 'icon' | 'feature';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Note { what: string; where: string }
|
||||||
|
|
||||||
|
export interface ImportPlan {
|
||||||
|
profile: AppProfile;
|
||||||
|
appName: string;
|
||||||
|
bundleId?: string;
|
||||||
|
packageName?: string;
|
||||||
|
languages: Language[];
|
||||||
|
files: PlanFile[];
|
||||||
|
/** source -> storebound target, for the report. */
|
||||||
|
mapped: { from: string; to: string }[];
|
||||||
|
/** What was found but not imported, and where it goes by hand. */
|
||||||
|
notMapped: Note[];
|
||||||
|
/** Keys, accounts and personal data: named, never copied. */
|
||||||
|
secrets: Note[];
|
||||||
|
/** Values written as placeholders the owner must replace. */
|
||||||
|
placeholders: Note[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanOptions {
|
||||||
|
/** Project root the paths are relative to (for the report). */
|
||||||
|
root: string;
|
||||||
|
/** --profile; default from the App Store category (GAMES -> game), else app. */
|
||||||
|
profile?: AppProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App Store Connect category ids (appCategories). deliver takes "Games", "MZGenre.Games" or the id itself. */
|
||||||
|
const CATEGORIES = ['BOOKS', 'BUSINESS', 'DEVELOPER_TOOLS', 'EDUCATION', 'ENTERTAINMENT', 'FINANCE', 'FOOD_AND_DRINK', 'GAMES', 'GRAPHICS_AND_DESIGN',
|
||||||
|
'HEALTH_AND_FITNESS', 'LIFESTYLE', 'MAGAZINES_AND_NEWSPAPERS', 'MEDICAL', 'MUSIC', 'NAVIGATION', 'NEWS', 'PHOTO_AND_VIDEO', 'PRODUCTIVITY',
|
||||||
|
'REFERENCE', 'SHOPPING', 'SOCIAL_NETWORKING', 'SPORTS', 'STICKERS', 'TRAVEL', 'UTILITIES', 'WEATHER'];
|
||||||
|
const GAME_SUBS = ['ACTION', 'ADVENTURE', 'BOARD', 'CARD', 'CASINO', 'CASUAL', 'FAMILY', 'MUSIC', 'PUZZLE', 'RACING', 'ROLE_PLAYING', 'SIMULATION', 'SPORTS', 'STRATEGY', 'TRIVIA', 'WORD'];
|
||||||
|
|
||||||
|
/** "Games", "MZGenre.Games", "Photo & Video", "GAMES" -> "GAMES" (null when unknown). Game subcategories: "GAMES_PUZZLE". */
|
||||||
|
export function categoryId(raw: string, sub = false): string | null {
|
||||||
|
const s = raw.trim().replace(/^MZGenre\./i, '').replace(/^Apps\./i, '').replace(/&/g, 'AND').replace(/[\s.-]+/g, '_').replace(/[^A-Za-z_]/g, '').toUpperCase();
|
||||||
|
if (!s) return null;
|
||||||
|
if (sub) { const g = s.replace(/^GAMES_/, ''); return GAME_SUBS.includes(g) ? `GAMES_${g}` : null; }
|
||||||
|
return CATEGORIES.includes(s) ? s : s === 'PHOTOGRAPHY' ? 'PHOTO_AND_VIDEO' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-quoted TypeScript string. */
|
||||||
|
export function tsString(s: string): string {
|
||||||
|
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fence = (t: string) => '```\n' + t + '\n```';
|
||||||
|
const https = (u: string | undefined): string | undefined => (u && /^https:\/\/\S+$/.test(u.trim()) ? u.trim() : undefined);
|
||||||
|
const YOUTUBE = /^https:\/\/(www\.youtube\.com\/watch\?v=[\w-]{6,}|youtu\.be\/[\w-]{6,})$/;
|
||||||
|
|
||||||
|
/** Keywords "a, b ,c" -> "a,b,c" (spaces after commas count against the 100 bytes on the App Store). */
|
||||||
|
export const normalizeKeywords = (k: string): string => k.split(/[,\n]/).map(x => x.trim()).filter(Boolean).join(',');
|
||||||
|
|
||||||
|
/** Image size, null when the header is not PNG / JPEG. */
|
||||||
|
function dims(p: string): { w: number; h: number } | null {
|
||||||
|
try { const i = imageInfo(readFileSync(p)); return i.format !== 'unknown' && i.w && i.h ? { w: i.w, h: i.h } : null; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device = 'phone' | 'tablet';
|
||||||
|
/** deliver screenshots of one locale -> phone / tablet sets. A folder often holds several iPhone sizes (snapshot): the
|
||||||
|
* largest one wins per class. Watch, Apple TV, Mac and iMessage shots are left out. */
|
||||||
|
export function classifyAppleShots(files: string[]): { phone: string[]; tablet: string[]; skipped: string[] } {
|
||||||
|
const out = { phone: [] as string[], tablet: [] as string[], skipped: [] as string[] };
|
||||||
|
const by: Record<Device, { f: string; area: number }[]> = { phone: [], tablet: [] };
|
||||||
|
for (const f of files) {
|
||||||
|
const name = basename(f);
|
||||||
|
if (/watch|apple ?tv|appletv|\bmac\b|desktop|imessage|messages/i.test(name)) { out.skipped.push(f); continue; }
|
||||||
|
const d = dims(f);
|
||||||
|
const tablet = /ipad/i.test(name) || (!!d && Math.min(d.w, d.h) / Math.max(d.w, d.h) > 0.7);
|
||||||
|
by[tablet ? 'tablet' : 'phone'].push({ f, area: d ? d.w * d.h : 0 });
|
||||||
|
}
|
||||||
|
for (const k of ['phone', 'tablet'] as Device[]) {
|
||||||
|
const max = Math.max(0, ...by[k].map(x => x.area));
|
||||||
|
for (const x of by[k]) (x.area === max ? out[k] : out.skipped).push(x.f);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SCENES = 10;
|
||||||
|
|
||||||
|
export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
|
||||||
|
// sources are shown relative to the folder fastlane runs from (the parent of fastlane/)
|
||||||
|
const bases = scan.dirs.map(d => dirname(d));
|
||||||
|
const rel = (p: string) => { const b = bases.find(x => p.startsWith(x + sep)); return b ? relative(b, p) : relative(o.root, p) || '.'; };
|
||||||
|
const mapped: ImportPlan['mapped'] = [];
|
||||||
|
const notMapped: Note[] = [];
|
||||||
|
const secrets: Note[] = [];
|
||||||
|
const placeholders: Note[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const files: PlanFile[] = [];
|
||||||
|
const A = scan.appfile.values, D = scan.deliverfile.values;
|
||||||
|
|
||||||
|
// --- ids
|
||||||
|
const str = (v: RubyValue | undefined) => (typeof v === 'string' && v ? v : undefined);
|
||||||
|
const bundleId = str(A.app_identifier) ?? str(D.app_identifier) ?? (Array.isArray(A.app_identifier) ? A.app_identifier[0] : undefined);
|
||||||
|
const packageName = str(A.package_name);
|
||||||
|
if (bundleId) mapped.push({ from: 'Appfile app_identifier', to: 'apple.bundleId' });
|
||||||
|
if (packageName) mapped.push({ from: 'Appfile package_name', to: 'play.packageName' });
|
||||||
|
for (const k of ['team_id', 'itc_team_id', 'team_name', 'itc_team_name'])
|
||||||
|
if (k in A || k in D) secrets.push({ what: `${k} (Appfile / Deliverfile)`, where: 'not written: storebound finds the team from the App Store Connect API key (ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json)' });
|
||||||
|
if ('apple_id' in A || 'username' in A || 'username' in D) secrets.push({ what: 'apple_id / username (the Apple Account that signs in)', where: 'not written: storebound signs in with an API key, never an Apple Account' });
|
||||||
|
for (const k of ['json_key_file', 'json_key']) {
|
||||||
|
const v = A[k] ?? D[k];
|
||||||
|
if (v !== undefined) secrets.push({ what: `${k}${typeof v === 'string' ? ` (${v})` : ''}`, where: 'move the Play service account key into .storebound/ (gitignored) and set PLAY_SERVICE_ACCOUNT to its path in .storebound/credentials.json; if it was ever committed, rotate the key' });
|
||||||
|
}
|
||||||
|
if ('json_key_data_raw' in A) secrets.push({ what: 'json_key_data_raw', where: 'PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json), never the config' });
|
||||||
|
const appleAppId = D.app ?? D.apple_id ?? A.apple_app_id;
|
||||||
|
const appIdFound = appleAppId !== undefined && /^\d+$/.test(String(appleAppId));
|
||||||
|
if (appIdFound) placeholders.push({ what: 'apple.appId', where: 'commented out in storebound.config.ts: the numeric Apple ID was found in the Deliverfile; uncomment and fill it in if you want it pinned (storebound otherwise looks it up by bundle id)' });
|
||||||
|
for (const b of [...scan.appfile.byHand, ...scan.deliverfile.byHand])
|
||||||
|
notMapped.push({ what: `${b.key}${b.line ? ` (line ${b.line})` : ''}: ${b.why}`, where: 'set by hand in storebound.config.ts if it matters (Ruby logic is never run)' });
|
||||||
|
|
||||||
|
// --- deliver (App Store)
|
||||||
|
const dl = [...scan.deliver.locales];
|
||||||
|
const sl = [...scan.supply.locales];
|
||||||
|
const hasApple = !!bundleId || dl.length > 0 || Object.keys(scan.deliver.shotLocales).length > 0;
|
||||||
|
const hasPlay = !!packageName || sl.length > 0;
|
||||||
|
if (!hasApple && !hasPlay) throw new Error(`nothing to import in ${scan.dirs.map(rel).join(', ')}: no Appfile ids, no metadata/<locale>/ and no metadata/android/<locale>/`);
|
||||||
|
for (const l of dl) if (!l.known) warnings.push(`App Store locale "${l.folder}" (metadata/${l.folder}) is not an App Store Connect locale: imported as is; rename it in store/appstore.md and languages[] (e.g. en-US, de-DE, ja, zh-Hans)`);
|
||||||
|
for (const l of dl) if (l.locale !== l.folder) mapped.push({ from: `metadata/${l.folder}`, to: `App Store locale ${l.locale}` });
|
||||||
|
for (const l of sl) if (!l.known) warnings.push(`Play language "${l.folder}" (metadata/android/${l.folder}) is not a Google Play language code: imported as is; rename it in store/listing.md and languages[] (e.g. en-US, tr-TR, iw-IL, zh-CN)`);
|
||||||
|
for (const l of sl) if (l.code !== l.folder) mapped.push({ from: `metadata/android/${l.folder}`, to: `Play language ${l.code}` });
|
||||||
|
|
||||||
|
const appleCodes = [...new Set([...dl.map(l => l.locale), ...(dl.length ? [] : Object.keys(scan.deliver.shotLocales))])];
|
||||||
|
const playCodes = [...new Set(sl.map(l => l.code))];
|
||||||
|
const languages = pairLanguages(hasApple ? appleCodes : [], hasPlay ? playCodes : []);
|
||||||
|
if (!languages.length) { languages.push({ id: 'en', ...(hasPlay ? { play: 'en-US' } : {}), ...(hasApple ? { apple: 'en-US' } : {}) }); placeholders.push({ what: 'languages', where: 'no metadata locales were found: en-US was assumed' }); }
|
||||||
|
for (const l of languages) mapped.push({ from: [l.apple && `deliver ${l.apple}`, l.play && `supply ${l.play}`].filter(Boolean).join(' + '), to: `languages: { id: '${l.id}'${l.play ? `, play: '${l.play}'` : ''}${l.apple ? `, apple: '${l.apple}'` : ''} }` });
|
||||||
|
const idOfApple = (c: string) => languages.find(l => l.apple === c)?.id;
|
||||||
|
const idOfPlay = (c: string) => languages.find(l => l.play === c)?.id;
|
||||||
|
const order = (x: { apple?: string; play?: string }) => languages.findIndex(l => (x.apple !== undefined && l.apple === x.apple) || (x.play !== undefined && l.play === x.play));
|
||||||
|
dl.sort((a, b) => order({ apple: a.locale }) - order({ apple: b.locale }));
|
||||||
|
sl.sort((a, b) => order({ play: a.code }) - order({ play: b.code }));
|
||||||
|
const primaryApple = dl.find(l => l.locale === languages[0]?.apple) ?? dl[0];
|
||||||
|
const primaryPlay = sl.find(l => l.code === languages[0]?.play) ?? sl.find(l => l.code === 'en-US') ?? sl[0];
|
||||||
|
|
||||||
|
// --- app
|
||||||
|
const appName = primaryApple?.fields.name ?? primaryPlay?.fields.title ?? (bundleId ?? packageName ?? 'My App').split('.').pop()!;
|
||||||
|
if (primaryApple?.fields.name) mapped.push({ from: `metadata/${primaryApple.folder}/name.txt`, to: 'app.name' });
|
||||||
|
else if (primaryPlay?.fields.title) mapped.push({ from: `metadata/android/${primaryPlay.folder}/title.txt`, to: 'app.name' });
|
||||||
|
else placeholders.push({ what: 'app.name', where: `no name.txt / title.txt: "${appName}" was taken from the app id` });
|
||||||
|
const urlOf = (k: 'privacyUrl' | 'supportUrl' | 'marketingUrl') => {
|
||||||
|
const vals = [...new Set(dl.map(l => l.fields[k]).filter((x): x is string => !!x))];
|
||||||
|
const pick = primaryApple?.fields[k] ?? vals[0];
|
||||||
|
const from = primaryApple && primaryApple.fields[k] ? `metadata/${primaryApple.fromDefault.includes(k) ? 'default' : primaryApple.folder}/${FILE_OF[k]}` : `metadata/<locale>/${FILE_OF[k]}`;
|
||||||
|
if (pick && https(pick)) mapped.push({ from, to: k === 'privacyUrl' ? 'app.privacyPolicyUrl' : k === 'marketingUrl' ? 'app.marketingUrl + app.website' : `app.${k}` });
|
||||||
|
if (vals.length > 1) notMapped.push({ what: `${k.replace('Url', '_url')}.txt differs per locale (${vals.length} values)`, where: `storebound has one ${k === 'privacyUrl' ? 'app.privacyPolicyUrl' : `app.${k}`} for every language: the ${primaryApple?.locale ?? 'first'} one was used` });
|
||||||
|
if (pick && !https(pick)) warnings.push(`${k.replace('Url', '_url')}.txt is not an https URL ("${pick}"): a placeholder was written instead`);
|
||||||
|
return https(pick);
|
||||||
|
};
|
||||||
|
const privacy = urlOf('privacyUrl'), support = urlOf('supportUrl'), marketing = urlOf('marketingUrl');
|
||||||
|
if (!privacy) placeholders.push({ what: 'app.privacyPolicyUrl', where: 'https://example.com/privacy: set your privacy policy page (privacy-policy skill)' });
|
||||||
|
if (!support) placeholders.push({ what: 'app.supportUrl', where: 'https://example.com/support: set your support page' });
|
||||||
|
if (!marketing) placeholders.push({ what: 'app.website', where: 'https://example.com: your developer website (Play shows it; app-ads.txt lives there)' });
|
||||||
|
placeholders.push({ what: 'app.contactEmail', where: 'support@example.com: the public support email (Play shows it). The App Review contact is not used for this' });
|
||||||
|
const copyright = scan.deliver.root.copyright;
|
||||||
|
if (copyright) mapped.push({ from: 'metadata/copyright.txt', to: 'app.copyright' });
|
||||||
|
|
||||||
|
// --- categories and profile
|
||||||
|
const cats: { primary?: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string } = {};
|
||||||
|
const catFile: [string, keyof typeof cats, boolean][] = [['primary_category', 'primary', false], ['primary_first_sub_category', 'primarySubOne', true], ['primary_second_sub_category', 'primarySubTwo', true], ['secondary_category', 'secondary', false]];
|
||||||
|
for (const [f, k, sub] of catFile) {
|
||||||
|
const raw = scan.deliver.root[f] ?? str(D[f]);
|
||||||
|
if (!raw) continue;
|
||||||
|
const id = categoryId(raw, sub);
|
||||||
|
if (id) { cats[k] = id; mapped.push({ from: `${f}${scan.deliver.root[f] ? '.txt' : ' (Deliverfile)'}`, to: `apple.info.categories.${k} = ${id}` }); }
|
||||||
|
else notMapped.push({ what: `${f}: "${raw}"`, where: 'apple.info.categories in storebound.config.ts (App Store Connect ids such as GAMES, PRODUCTIVITY, GAMES_PUZZLE)' });
|
||||||
|
}
|
||||||
|
for (const f of ['secondary_first_sub_category', 'secondary_second_sub_category'])
|
||||||
|
if (scan.deliver.root[f]) notMapped.push({ what: `${f}.txt`, where: 'set in App Store Connect > App Information (storebound sets primary sub-categories only)' });
|
||||||
|
const profile: AppProfile = o.profile ?? (cats.primary === 'GAMES' ? 'game' : 'app');
|
||||||
|
const style: 'game' | 'app' = profile === 'game' ? 'game' : 'app';
|
||||||
|
|
||||||
|
// --- App Review information
|
||||||
|
const review = scan.deliver.review;
|
||||||
|
const reviewNotes = scan.deliver.root.review_notes;
|
||||||
|
if (reviewNotes) mapped.push({ from: 'metadata/review_information/notes.txt', to: 'apple.info.reviewNotes' });
|
||||||
|
const contactFiles = review.filter(f => ['first_name.txt', 'last_name.txt', 'phone_number.txt', 'email_address.txt'].includes(f));
|
||||||
|
if (contactFiles.length) secrets.push({ what: `review_information/${contactFiles.join(', ')} (App Review contact)`, where: 'personal data: not copied. Put it in .storebound/credentials.json "reviewContact": { firstName, lastName, phone, email } yourself' });
|
||||||
|
const demo = review.filter(f => f === 'demo_user.txt' || f === 'demo_password.txt');
|
||||||
|
if (demo.length) secrets.push({ what: `review_information/${demo.join(', ')} (App Review demo account)`, where: 'not copied: the owner puts it in .storebound/credentials.json "reviewDemoAccount": { username, password } (review-access skill); reviewAccess.signInRequired was set to true' });
|
||||||
|
const signIn = demo.includes('demo_user.txt');
|
||||||
|
if (scan.deliver.metadata && isDir(join(scan.deliver.metadata, 'trade_representative_contact_information')))
|
||||||
|
notMapped.push({ what: 'trade_representative_contact_information/ (Korea)', where: 'App Store Connect > App Information (personal data, not imported)' });
|
||||||
|
|
||||||
|
// --- App Store listing
|
||||||
|
const appleSections: string[] = [];
|
||||||
|
for (const l of dl) {
|
||||||
|
const f = l.fields;
|
||||||
|
const parts: string[] = [`## ${l.locale} · ${languageName(l.locale)}`, ''];
|
||||||
|
const add = (k: string, v: string | undefined) => { if (v) parts.push(`### ${k}`, fence(v), ''); };
|
||||||
|
if (f.name && f.name !== appName) add('name', f.name);
|
||||||
|
add('subtitle', f.subtitle);
|
||||||
|
add('promotionalText', f.promotionalText);
|
||||||
|
if (f.keywords) {
|
||||||
|
const k = normalizeKeywords(f.keywords);
|
||||||
|
if (k !== f.keywords) warnings.push(`${l.locale}: keywords normalized (spaces after commas removed; they count against 100 bytes)`);
|
||||||
|
add('keywords', k);
|
||||||
|
}
|
||||||
|
add('description', f.description);
|
||||||
|
add('whatsNew', f.whatsNew);
|
||||||
|
const missing = ['subtitle', 'promotionalText', 'keywords', 'description'].filter(k => !f[k]);
|
||||||
|
if (missing.length) warnings.push(`${l.locale}: no ${missing.join(', ')} in fastlane: add ${missing.length > 1 ? 'them' : 'it'} to store/appstore.md before \`storebound apple listing\``);
|
||||||
|
for (const [k, lim] of Object.entries(APPLE_LIMITS)) if (f[k] && len(k === 'keywords' ? normalizeKeywords(f[k]!) : f[k]!) > lim) warnings.push(`${l.locale}: ${k} is ${len(f[k]!)} characters > ${lim}`);
|
||||||
|
const listed = l.fromDefault.filter(k => !/Url$/.test(k)).map(k => FILE_OF[k]);
|
||||||
|
if (listed.length) mapped.push({ from: `metadata/default/{${listed.join(', ')}}`, to: `store/appstore.md ${l.locale} (no file of its own)` });
|
||||||
|
for (const x of l.extra) notMapped.push({ what: `metadata/${l.folder}/${x}`, where: /apple_tv_privacy_policy/.test(x) ? 'Apple TV privacy policy: App Store Connect' : 'not a deliver text storebound maps' });
|
||||||
|
appleSections.push(parts.join('\n').trimEnd());
|
||||||
|
}
|
||||||
|
if (dl.length) {
|
||||||
|
mapped.push({ from: 'metadata/<locale>/{name, subtitle, promotional_text, keywords, description, release_notes}.txt', to: 'store/appstore.md (name, subtitle, promotionalText, keywords, description, whatsNew)' });
|
||||||
|
const md = `# ${appName} · App Store listing copy
|
||||||
|
|
||||||
|
Imported from fastlane deliver (\`storebound import fastlane\`). Source for \`storebound apple listing\`. One
|
||||||
|
\`## <ASC locale>\` section per locale, one fenced block per \`### <field>\`: \`name\` (optional, defaults to \`app.name\`),
|
||||||
|
\`subtitle\` (30), \`promotionalText\` (170), \`keywords\` (100 bytes, comma separated, no spaces after commas),
|
||||||
|
\`description\` (4000), \`whatsNew\` (optional). Privacy policy, support and marketing URL come from
|
||||||
|
\`storebound.config.ts\`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
${appleSections.join('\n\n')}
|
||||||
|
`;
|
||||||
|
if (md.includes('\n```\n```')) warnings.push('a text contains a ``` line: check store/appstore.md by hand');
|
||||||
|
try { parseAppStoreListing(md); } catch (e) { warnings.push(`store/appstore.md: ${(e as Error).message.replace(/^appstore listing: /, '')} (\`storebound check\` reports it too)`); }
|
||||||
|
files.push({ path: 'store/appstore.md', text: md, kind: 'appstore' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Play listing
|
||||||
|
if (sl.length) {
|
||||||
|
const secs: string[] = [];
|
||||||
|
for (const l of sl) {
|
||||||
|
const f = l.fields;
|
||||||
|
const parts = [`## ${languageName(l.code)} (${l.code})`, `<!-- play: ${l.code} -->`, ''];
|
||||||
|
const add = (h: string, v: string | undefined) => { if (v) parts.push(`### ${h}`, fence(v), ''); };
|
||||||
|
add('Title (max 30)', f.title);
|
||||||
|
add('Play short description (max 80)', f.short);
|
||||||
|
add('Play full description (max 4000)', f.full);
|
||||||
|
add("What's new (max 500)", f.whatsNew);
|
||||||
|
const missing = [['short', 'short_description.txt'], ['full', 'full_description.txt']].filter(([k]) => !f[k!]).map(([, n]) => n);
|
||||||
|
if (missing.length) warnings.push(`${l.code}: no ${missing.join(', ')} in fastlane: add it to store/listing.md before \`storebound play listing\``);
|
||||||
|
const lim: Record<string, number> = { title: PLAY_LIMITS.title, short: PLAY_LIMITS.short, full: PLAY_LIMITS.full, whatsNew: PLAY_LIMITS.whatsNew };
|
||||||
|
for (const [k, n] of Object.entries(lim)) if (f[k] && len(f[k]!) > n) warnings.push(`${l.code}: ${k} is ${len(f[k]!)} characters > ${n}`);
|
||||||
|
if (l.changelog) mapped.push({ from: `metadata/android/${l.folder}/${l.changelog}`, to: `store/listing.md ${l.code} What's new` });
|
||||||
|
for (const x of l.extra) notMapped.push({ what: `metadata/android/${l.folder}/${x}`, where: 'not a supply text storebound maps' });
|
||||||
|
secs.push(parts.join('\n').trimEnd());
|
||||||
|
}
|
||||||
|
mapped.push({ from: 'metadata/android/<lang>/{title, short_description, full_description}.txt', to: 'store/listing.md (title, short, full)' });
|
||||||
|
const md = `# ${appName} · Google Play listing copy
|
||||||
|
|
||||||
|
Imported from fastlane supply (\`storebound import fastlane\`). Source for \`storebound play listing\` (title, short +
|
||||||
|
full description) and \`storebound play release\` (release notes). Every \`## ...\` section with a
|
||||||
|
\`<!-- play: <codes> -->\` line is a listing; \`### ...\` headings that contain **title**, **short**, **full** and
|
||||||
|
**what's new**, each followed by one fenced block. Limits: title 30, short 80, full 4000, what's new 500 characters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
${secs.join('\n\n')}
|
||||||
|
`;
|
||||||
|
try { parsePlayListing(md, appName); } catch (e) { warnings.push(`store/listing.md: ${(e as Error).message.replace(/^listing: /, '')} (\`storebound check\` reports it too)`); }
|
||||||
|
files.push({ path: 'store/listing.md', text: md, kind: 'listing' });
|
||||||
|
}
|
||||||
|
const video = [...new Set(sl.map(l => l.fields.video).filter((x): x is string => !!x))];
|
||||||
|
const playVideo = video.find(v => YOUTUBE.test(v));
|
||||||
|
if (playVideo) mapped.push({ from: 'metadata/android/<lang>/video.txt', to: 'play.video' });
|
||||||
|
for (const v of video) if (v !== playVideo) notMapped.push({ what: `video.txt "${v}"`, where: YOUTUBE.test(v) ? 'play.video holds one URL for every language: set the others in Play Console' : 'play.video needs a plain YouTube watch URL' });
|
||||||
|
|
||||||
|
// --- art: icon, feature graphic, screenshots
|
||||||
|
let playIcon: string | undefined;
|
||||||
|
const iconSrc = (primaryPlay?.images.icon ? primaryPlay : sl.find(l => l.images.icon))?.images.icon;
|
||||||
|
if (iconSrc) {
|
||||||
|
if (/\.png$/i.test(iconSrc)) { playIcon = 'store/icons/play-512.png'; files.push({ path: playIcon, from: iconSrc, src: rel(iconSrc), kind: 'icon' }); mapped.push({ from: rel(iconSrc), to: `${playIcon} (play.icon)` }); }
|
||||||
|
else notMapped.push({ what: rel(iconSrc), where: 'play.icon must be a 512 x 512 PNG: convert it and set play.icon' });
|
||||||
|
}
|
||||||
|
for (const l of sl) {
|
||||||
|
const id = idOfPlay(l.code)!;
|
||||||
|
const fg = l.images.featureGraphic;
|
||||||
|
if (fg) { const p = `store/renders/${id}/feature/1024x500${extname(fg).toLowerCase().replace('.jpeg', '.jpg')}`; files.push({ path: p, from: fg, src: rel(fg), kind: 'feature' }); mapped.push({ from: rel(fg), to: p }); }
|
||||||
|
for (const k of ['promoGraphic', 'tvBanner']) if (l.images[k]) notMapped.push({ what: rel(l.images[k]!), where: 'Play Console > Store listing (storebound uploads icon, feature graphic and phone / tablet screenshots)' });
|
||||||
|
for (const k of ['tv', 'wear'] as const) if (l.shots[k].length) notMapped.push({ what: `metadata/android/${l.folder}/images/${k}Screenshots (${l.shots[k].length})`, where: `Play Console > Store listing (${k === 'tv' ? 'Android TV' : 'Wear OS'} screenshots)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenshots -> frames: <frames>/<lang id>/<phone|tablet>/NN.png; iOS shots win over Play shots for the same language
|
||||||
|
const sets = new Map<string, Record<Device, string[]>>();
|
||||||
|
const why = new Map<string, Record<Device, string>>();
|
||||||
|
let ipad = false;
|
||||||
|
for (const [loc, list] of Object.entries(scan.deliver.shotLocales)) {
|
||||||
|
const id = idOfApple(loc);
|
||||||
|
if (!id) { notMapped.push({ what: `screenshots/${loc}`, where: 'no matching language in the import (no metadata for it)' }); continue; }
|
||||||
|
const c = classifyAppleShots(list);
|
||||||
|
if (c.tablet.length) ipad = true;
|
||||||
|
sets.set(id, { phone: c.phone, tablet: c.tablet });
|
||||||
|
why.set(id, { phone: `screenshots/${loc}`, tablet: `screenshots/${loc} (iPad)` });
|
||||||
|
if (c.skipped.length) notMapped.push({ what: `screenshots/${loc}: ${c.skipped.length} file(s) (other device sizes, Watch, TV or Mac)`, where: 'storebound renders one phone and one tablet set per language and scales them to every store size' });
|
||||||
|
}
|
||||||
|
for (const l of sl) {
|
||||||
|
const id = idOfPlay(l.code)!;
|
||||||
|
const s = sets.get(id) ?? { phone: [], tablet: [] };
|
||||||
|
const w = why.get(id) ?? { phone: '', tablet: '' };
|
||||||
|
if (!s.phone.length && l.shots.phone.length) { s.phone = l.shots.phone; w.phone = `metadata/android/${l.folder}/images/phoneScreenshots`; }
|
||||||
|
const tab = l.shots.ten.length ? ['ten', l.shots.ten] as const : l.shots.seven.length ? ['seven', l.shots.seven] as const : null;
|
||||||
|
if (!s.tablet.length && tab) { s.tablet = [...tab[1]]; w.tablet = `metadata/android/${l.folder}/images/${tab[0]}InchScreenshots`; }
|
||||||
|
if (s.phone.length || s.tablet.length) { sets.set(id, s); why.set(id, w); }
|
||||||
|
}
|
||||||
|
let scenes = 0;
|
||||||
|
const copyJson: Record<string, Record<string, { title: [string, string]; sub: string }>> = {};
|
||||||
|
for (const [id, s] of sets) {
|
||||||
|
for (const device of ['phone', 'tablet'] as Device[]) {
|
||||||
|
const list = s[device];
|
||||||
|
if (!list.length) continue;
|
||||||
|
const png = list.filter(f => /\.png$/i.test(f));
|
||||||
|
if (png.length < list.length) notMapped.push({ what: `${why.get(id)![device]}: ${list.length - png.length} JPEG file(s)`, where: `frames must be PNG: convert them into store/shots/frames/${id}/${device}/` });
|
||||||
|
const take = png.slice(0, MAX_SCENES);
|
||||||
|
if (png.length > MAX_SCENES) notMapped.push({ what: `${why.get(id)![device]}: ${png.length - MAX_SCENES} screenshot(s) over ${MAX_SCENES}`, where: 'the App Store takes 10 per set, Google Play 8' });
|
||||||
|
take.forEach((f, i) => files.push({ path: `store/shots/frames/${id}/${device}/${String(i + 1).padStart(2, '0')}.png`, from: f, src: rel(f), kind: 'frame' }));
|
||||||
|
if (take.length) mapped.push({ from: `${why.get(id)![device]} (${take.length})`, to: `store/shots/frames/${id}/${device}/${take.length > 1 ? `01..${String(take.length).padStart(2, '0')}` : '01'}.png` });
|
||||||
|
if (device === 'phone') scenes = Math.max(scenes, take.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!scenes && [...sets.values()].some(s => s.tablet.length)) scenes = Math.max(...[...sets.values()].map(s => Math.min(MAX_SCENES, s.tablet.filter(f => /\.png$/i.test(f)).length)));
|
||||||
|
const sceneIds = Array.from({ length: scenes }, (_, i) => `shot-${String(i + 1).padStart(2, '0')}`);
|
||||||
|
if (scenes) {
|
||||||
|
for (const l of languages) copyJson[l.id] = Object.fromEntries(sceneIds.map(s => [s, { title: ['', ''] as [string, string], sub: '' }]));
|
||||||
|
files.push({ path: 'store/shots/copy.json', text: JSON.stringify(copyJson, null, 2) + '\n', kind: 'copy' });
|
||||||
|
notMapped.push({ what: 'screenshot captions', where: 'fastlane screenshots are often finished art: write the headlines per scene in store/shots/copy.json and render (`storebound shots render`), or keep uploading the finished screenshots' });
|
||||||
|
notMapped.push({ what: 'frame sizes', where: '`storebound shots capture --source folder` checks every frame (missing languages / tablet frames, aspect ratio: full-screen screenshots need the status bar cropped)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- deliver / supply options with a storebound home elsewhere
|
||||||
|
const OPT: Record<string, string> = {
|
||||||
|
submit_for_review: 'never automated: the owner presses Submit for review in App Store Connect (AGENTS.md rule 1)',
|
||||||
|
automatic_release: 'release-management skill (`storebound apple release`, the owner decides)',
|
||||||
|
phased_release: 'release-management skill (`storebound apple release --phased on`, asks first)',
|
||||||
|
price_tier: 'apple.info.free / the products and prices CSV (`storebound prices`)',
|
||||||
|
app_rating_config_path: 'apple.info.ageRating, answered with the owner (store-declarations skill)',
|
||||||
|
submission_information: 'export compliance: apple.usesNonExemptEncryption (export-compliance skill); content rights: apple.info.contentRights',
|
||||||
|
reject_if_possible: 'never automated: cancel a submission in App Store Connect',
|
||||||
|
run_precheck_before_submit: '`storebound check --aso` and `storebound validate`',
|
||||||
|
skip_screenshots: 'run only the commands you need (`apple listing` vs `apple screenshots`)', skip_metadata: 'run only the commands you need',
|
||||||
|
skip_binary_upload: 'storebound uploads no iOS binary (Xcode / xcodebuild / Transporter do)', force: 'every storebound write shows a dry run first',
|
||||||
|
overwrite_screenshots: '`storebound apple screenshots` replaces the sets it uploads', languages: 'languages[] in storebound.config.ts',
|
||||||
|
ipa: 'storebound uploads no iOS binary', pkg: 'storebound uploads no binary', app_version: '`storebound apple listing --version x`',
|
||||||
|
build_number: '`storebound apple listing --build N` (explicit number)', platform: 'apple.ipad / screenshotTypes',
|
||||||
|
copyright: 'app.copyright', primary_category: 'apple.info.categories', secondary_category: 'apple.info.categories',
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(D)) {
|
||||||
|
if (['app_identifier', 'metadata_path', 'screenshots_path', 'username', 'team_id', 'team_name', 'itc_team_id', 'itc_team_name', 'app', 'apple_id'].includes(k)) continue;
|
||||||
|
if (k === 'copyright') continue;
|
||||||
|
if (k === 'primary_category' || k === 'secondary_category' || k.endsWith('_sub_category')) continue;
|
||||||
|
notMapped.push({ what: `Deliverfile ${k}${typeof v === 'boolean' ? ` = ${v}` : ''}`, where: OPT[k] ?? 'no storebound equivalent; set in App Store Connect if you need it' });
|
||||||
|
}
|
||||||
|
if (typeof D.copyright === 'string' && !copyright) mapped.push({ from: 'Deliverfile copyright', to: 'app.copyright' });
|
||||||
|
const appCopyright = copyright ?? (typeof D.copyright === 'string' ? D.copyright : undefined);
|
||||||
|
|
||||||
|
// --- Fastfile / Matchfile hints
|
||||||
|
if (scan.matchfile) notMapped.push({ what: `Matchfile (${Object.keys(scan.matchfile.values).filter(k => !/url|password|token|key/i.test(k)).join(', ') || 'options'})`, where: 'code signing stays with match / Xcode: storebound does not sign builds (keep the Matchfile; its git_url and passphrase stay out of storebound)' });
|
||||||
|
const ACT: Record<string, string> = {
|
||||||
|
app_store_connect_api_key: 'ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json (the .p8 path, never its contents)',
|
||||||
|
upload_to_app_store: '`storebound apple listing`, `apple screenshots`, `apple previews` (dry run first; submitting stays with the owner)', deliver: '`storebound apple listing` / `apple screenshots`',
|
||||||
|
upload_to_play_store: '`storebound play listing`, `play images`, `play release <aab>` (testing tracks; production is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`',
|
||||||
|
upload_to_testflight: '`storebound apple testflight` (testflight skill)', pilot: '`storebound apple testflight` (testflight skill)',
|
||||||
|
match: 'keep match for signing', sync_code_signing: 'keep match for signing',
|
||||||
|
snapshot: '`storebound shots capture --source ios-sim`', capture_screenshots: '`storebound shots capture --source ios-sim`', capture_ios_screenshots: '`storebound shots capture --source ios-sim`',
|
||||||
|
screengrab: '`storebound shots capture --source android-emu`', capture_android_screenshots: '`storebound shots capture --source android-emu`',
|
||||||
|
frameit: '`storebound shots render` (copy in store/shots/copy.json)', frame_screenshots: '`storebound shots render`',
|
||||||
|
increment_build_number: '`storebound apple next-build`', latest_testflight_build_number: '`storebound apple next-build`',
|
||||||
|
increment_version_code: '`storebound play next-version-code`', google_play_track_version_codes: '`storebound play next-version-code`',
|
||||||
|
precheck: '`storebound check --aso` + `storebound validate`', check_app_store_metadata: '`storebound check --aso` + `storebound validate`',
|
||||||
|
gym: 'keep your build step (storebound does not build)', build_app: 'keep your build step', build_ios_app: 'keep your build step', gradle: 'keep your build step', build_android_app: 'keep your build step',
|
||||||
|
};
|
||||||
|
if (scan.fastfile) {
|
||||||
|
if (scan.fastfile.lanes.length) notMapped.push({ what: `Fastfile lanes: ${scan.fastfile.lanes.join(', ')}`, where: 'not converted: the storebound commands below replace the store steps; keep fastlane for building and signing if you like' });
|
||||||
|
for (const a of scan.fastfile.actions) (a === 'app_store_connect_api_key' ? secrets : notMapped).push({ what: `Fastfile ${a}`, where: ACT[a] ?? 'no storebound equivalent' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- config
|
||||||
|
const q = tsString;
|
||||||
|
const L: string[] = [];
|
||||||
|
L.push(`// storebound config imported from fastlane (\`storebound import fastlane\`, profile "${profile}"). Check every value, then`,
|
||||||
|
'// run `storebound init` (adds the declarations, App Privacy, privacy policy and credentials templates; keeps these files)',
|
||||||
|
'// and `storebound check`. Credentials NEVER go here (env vars or .storebound/credentials.json, gitignored).',
|
||||||
|
"// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).",
|
||||||
|
"import type { ShipConfig } from 'storebound';", '', 'const config: ShipConfig = {', ' app: {',
|
||||||
|
` name: ${q(appName)},`, ` profile: '${profile}',`);
|
||||||
|
if (appCopyright) L.push(` copyright: ${q(appCopyright)},`);
|
||||||
|
L.push(` privacyPolicyUrl: ${q(privacy ?? 'https://example.com/privacy')},${privacy ? '' : ' // placeholder: your privacy policy page'}`);
|
||||||
|
if (profile === 'subscription-app') L.push(" termsUrl: 'https://example.com/terms', // placeholder: Terms of Use, required with subscriptions (Apple 3.1.2)");
|
||||||
|
L.push(` supportUrl: ${q(support ?? 'https://example.com/support')},${support ? '' : ' // placeholder'}`);
|
||||||
|
if (marketing) L.push(` marketingUrl: ${q(marketing)},`);
|
||||||
|
L.push(` website: ${q(marketing ?? 'https://example.com')},${marketing ? '' : ' // placeholder: developer website'}`,
|
||||||
|
" contactEmail: 'support@example.com', // placeholder: the public support email", ' },', '');
|
||||||
|
if (signIn) L.push(' // fastlane had an App Review demo account: put it in .storebound/credentials.json ("reviewDemoAccount"), never here.',
|
||||||
|
' reviewAccess: {', ' signInRequired: true,', ' },', '');
|
||||||
|
L.push(' // id = folder / copy key; play = Google Play language code; apple = App Store Connect locale', ' languages: [');
|
||||||
|
for (const l of languages) L.push(` { id: ${q(l.id)}${l.play ? `, play: ${q(String(l.play))}` : ''}${l.apple ? `, apple: ${q(l.apple)}` : ''} },`);
|
||||||
|
L.push(' ],', '');
|
||||||
|
if (hasPlay) {
|
||||||
|
L.push(' play: {', ` packageName: ${q(packageName ?? 'com.example.app')},${packageName ? '' : ' // placeholder: no package_name in the Appfile'}`);
|
||||||
|
const def = languages.find(l => l.play === 'en-US')?.play ?? languages.find(l => l.play)?.play;
|
||||||
|
if (def) L.push(` defaultLanguage: ${q(String(def))},`);
|
||||||
|
L.push(" listing: 'store/listing.md',");
|
||||||
|
if (playIcon) L.push(` icon: ${q(playIcon)},`);
|
||||||
|
if (playVideo) L.push(` video: ${q(playVideo)},`);
|
||||||
|
L.push(' },', '');
|
||||||
|
if (!packageName) placeholders.push({ what: 'play.packageName', where: 'com.example.app: no package_name in the Appfile' });
|
||||||
|
}
|
||||||
|
if (hasApple) {
|
||||||
|
L.push(' apple: {', ` bundleId: ${q(bundleId ?? 'com.example.app')},${bundleId ? '' : ' // placeholder: no app_identifier in the Appfile'}`);
|
||||||
|
if (appIdFound) L.push(" // appId: '<numeric Apple ID>', // found in the Deliverfile; optional");
|
||||||
|
L.push(" listing: 'store/appstore.md',",
|
||||||
|
' // Export compliance (export-compliance skill): decide it with the owner, then uncomment. false ONLY if the app uses',
|
||||||
|
' // no encryption beyond the OS (HTTPS through the system, standard sign-in).',
|
||||||
|
' // usesNonExemptEncryption: false,');
|
||||||
|
if (ipad) L.push(' ipad: true, // fastlane had iPad screenshots');
|
||||||
|
const info: string[] = [];
|
||||||
|
if (Object.keys(cats).length) info.push(` categories: { ${Object.entries(cats).map(([k, v]) => `${k}: ${q(v!)}`).join(', ')} },`);
|
||||||
|
if (reviewNotes) info.push(` reviewNotes: ${q(reviewNotes)},`);
|
||||||
|
if (info.length) L.push(' info: {', ...info, ' },');
|
||||||
|
L.push(' },', '');
|
||||||
|
if (!bundleId) placeholders.push({ what: 'apple.bundleId', where: 'com.example.app: no app_identifier in the Appfile' });
|
||||||
|
}
|
||||||
|
L.push(" renders: 'store/renders',");
|
||||||
|
if (scenes) {
|
||||||
|
const pal = Object.keys(style === 'app' ? APP_PALETTES : PALETTES);
|
||||||
|
L.push('', ' // Frames imported from the fastlane screenshots: store/shots/frames/<lang>/<phone|tablet>/NN.png',
|
||||||
|
' shots: {', ` style: '${style}',`, " copy: 'store/shots/copy.json',", " frames: 'store/shots/frames',", " out: 'store/renders',", ' scenes: [');
|
||||||
|
sceneIds.forEach((s, i) => L.push(` { id: '${s}', palette: '${pal[i % pal.length]}', frames: [{ src: '${String(i + 1).padStart(2, '0')}.png' }] },`));
|
||||||
|
L.push(' ],', ` feature: { frame: '01.png', palette: '${pal[0]}' },`, " capture: { source: 'folder', folder: 'store/shots/frames', shots: {} },", ' },');
|
||||||
|
}
|
||||||
|
L.push('};', '', 'export default config;', '');
|
||||||
|
files.unshift({ path: 'storebound.config.ts', text: L.join('\n'), kind: 'config' });
|
||||||
|
|
||||||
|
return { profile, appName, bundleId, packageName, languages, files, mapped, notMapped, secrets, placeholders, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a text file goes when its target exists: name.imported.ext next to it. */
|
||||||
|
export const importedPath = (p: string): string => {
|
||||||
|
const ext = extname(p);
|
||||||
|
return `${p.slice(0, p.length - ext.length)}.imported${ext}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Default fastlane folders under a project: fastlane/, ios/fastlane/, android/fastlane/ (the ones that exist). */
|
||||||
|
export function findFastlaneDirs(root: string): string[] {
|
||||||
|
return ['fastlane', 'ios/fastlane', 'android/fastlane'].map(d => join(root, d)).filter(isDir);
|
||||||
|
}
|
||||||
111
cli/lib/store-locales.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// Store language codes on both stores, and how one maps onto the other. Pure.
|
||||||
|
// App Store Connect locales (what fastlane deliver names its metadata folders after) and Google Play language codes
|
||||||
|
// (what fastlane supply names its folders after) differ for the same language: "tr" vs "tr-TR", "zh-Hans" vs "zh-CN",
|
||||||
|
// "he" vs "iw-IL". storebound keeps one `languages[]` entry per language with both codes, so an importer needs the pairs.
|
||||||
|
import type { Language } from './config';
|
||||||
|
|
||||||
|
/** App Store Connect localizations (App Store Connect Help, "App Store localizations"). The Indian languages, Bangla,
|
||||||
|
* Urdu and Slovenian were added in 2025. */
|
||||||
|
export const APPLE_LOCALES: readonly string[] = [
|
||||||
|
'ar-SA', 'bn-BD', 'ca', 'cs', 'da', 'de-DE', 'el', 'en-AU', 'en-CA', 'en-GB', 'en-US', 'es-ES', 'es-MX', 'fi', 'fr-CA',
|
||||||
|
'fr-FR', 'gu-IN', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'ja', 'kn-IN', 'ko', 'ml-IN', 'mr-IN', 'ms', 'nl-NL', 'no',
|
||||||
|
'or-IN', 'pa-IN', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl-SI', 'sv', 'ta-IN', 'te-IN', 'th', 'tr', 'uk', 'ur-PK',
|
||||||
|
'vi', 'zh-Hans', 'zh-Hant',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Google Play store listing languages (Play Console > Store presence > Translations; the API uses the same codes). */
|
||||||
|
export const PLAY_LOCALES: readonly string[] = [
|
||||||
|
'af', 'am', 'ar', 'az-AZ', 'be', 'bg', 'bn-BD', 'ca', 'cs-CZ', 'da-DK', 'de-DE', 'el-GR', 'en-AU', 'en-CA', 'en-GB',
|
||||||
|
'en-IN', 'en-SG', 'en-US', 'en-ZA', 'es-419', 'es-ES', 'es-US', 'et', 'eu-ES', 'fa', 'fa-AE', 'fa-AF', 'fa-IR', 'fi-FI',
|
||||||
|
'fil', 'fr-CA', 'fr-FR', 'gl-ES', 'gu', 'hi-IN', 'hr', 'hu-HU', 'hy-AM', 'id', 'is-IS', 'it-IT', 'iw-IL', 'ja-JP',
|
||||||
|
'ka-GE', 'kk', 'km-KH', 'kn-IN', 'ko-KR', 'ky-KG', 'lo-LA', 'lt', 'lv', 'mk-MK', 'ml-IN', 'mn-MN', 'mr-IN', 'ms', 'ms-MY',
|
||||||
|
'my-MM', 'ne-NP', 'nl-NL', 'no-NO', 'pa', 'pl-PL', 'pt-BR', 'pt-PT', 'rm', 'ro', 'ru-RU', 'si-LK', 'sk', 'sl', 'sq',
|
||||||
|
'sr', 'sv-SE', 'sw', 'ta-IN', 'te-IN', 'th', 'tr-TR', 'uk', 'ur', 'vi', 'zh-CN', 'zh-HK', 'zh-TW', 'zu',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** App Store locale -> the Play code of the same language, where the two differ (the rest are spelled the same). */
|
||||||
|
export const APPLE_TO_PLAY: Readonly<Record<string, string>> = {
|
||||||
|
'ar-SA': 'ar', 'cs': 'cs-CZ', 'da': 'da-DK', 'el': 'el-GR', 'es-MX': 'es-419', 'fi': 'fi-FI', 'gu-IN': 'gu', 'he': 'iw-IL',
|
||||||
|
'hi': 'hi-IN', 'hu': 'hu-HU', 'it': 'it-IT', 'ja': 'ja-JP', 'ko': 'ko-KR', 'no': 'no-NO', 'pa-IN': 'pa', 'pl': 'pl-PL',
|
||||||
|
'ru': 'ru-RU', 'sl-SI': 'sl', 'sv': 'sv-SE', 'tr': 'tr-TR', 'ur-PK': 'ur', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-TW',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Play code -> App Store locale (the inverse of APPLE_TO_PLAY plus Play codes that share one App Store locale). */
|
||||||
|
export const PLAY_TO_APPLE: Readonly<Record<string, string>> = {
|
||||||
|
...Object.fromEntries(Object.entries(APPLE_TO_PLAY).map(([a, p]) => [p, a])),
|
||||||
|
'ms-MY': 'ms',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Spellings seen in fastlane folders and older tools that are not the store's own code. */
|
||||||
|
const APPLE_ALIASES: Readonly<Record<string, string>> = {
|
||||||
|
'en': 'en-US', 'de': 'de-DE', 'fr': 'fr-FR', 'es': 'es-ES', 'nl': 'nl-NL', 'ar': 'ar-SA', 'iw': 'he', 'he-IL': 'he',
|
||||||
|
'nb': 'no', 'nb-NO': 'no', 'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant', 'ja-JP': 'ja', 'ko-KR': 'ko', 'it-IT': 'it',
|
||||||
|
'ru-RU': 'ru', 'tr-TR': 'tr', 'sv-SE': 'sv', 'da-DK': 'da', 'fi-FI': 'fi', 'no-NO': 'no', 'pl-PL': 'pl', 'cs-CZ': 'cs',
|
||||||
|
'el-GR': 'el', 'hu-HU': 'hu', 'hi-IN': 'hi', 'uk-UA': 'uk', 'th-TH': 'th', 'vi-VN': 'vi', 'id-ID': 'id', 'ms-MY': 'ms',
|
||||||
|
'ro-RO': 'ro', 'sk-SK': 'sk', 'hr-HR': 'hr', 'ca-ES': 'ca',
|
||||||
|
};
|
||||||
|
const PLAY_ALIASES: Readonly<Record<string, string>> = {
|
||||||
|
'he-IL': 'iw-IL', 'he': 'iw-IL', 'iw': 'iw-IL', 'id-ID': 'id', 'in-ID': 'id', 'nb-NO': 'no-NO', 'nb': 'no-NO',
|
||||||
|
'fil-PH': 'fil', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-TW', 'en': 'en-US', 'de': 'de-DE', 'fr': 'fr-FR', 'es': 'es-ES',
|
||||||
|
'tr': 'tr-TR', 'ja': 'ja-JP', 'ko': 'ko-KR', 'it': 'it-IT', 'ru': 'ru-RU', 'pt': 'pt-PT', 'nl': 'nl-NL', 'pl': 'pl-PL',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Normalized { code: string; known: boolean; alias?: string }
|
||||||
|
|
||||||
|
/** A fastlane deliver folder name -> App Store locale. Unknown codes are kept as they are (known: false). */
|
||||||
|
export function appleLocale(folder: string): Normalized {
|
||||||
|
if (APPLE_LOCALES.includes(folder)) return { code: folder, known: true };
|
||||||
|
const hit = APPLE_ALIASES[folder] ?? APPLE_LOCALES.find(l => l.toLowerCase() === folder.toLowerCase());
|
||||||
|
return hit ? { code: hit, known: true, alias: folder } : { code: folder, known: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fastlane supply folder name -> Play language code. Unknown codes are kept as they are (known: false). */
|
||||||
|
export function playLocale(folder: string): Normalized {
|
||||||
|
if (PLAY_LOCALES.includes(folder)) return { code: folder, known: true };
|
||||||
|
const hit = PLAY_ALIASES[folder] ?? PLAY_LOCALES.find(l => l.toLowerCase() === folder.toLowerCase());
|
||||||
|
return hit ? { code: hit, known: true, alias: folder } : { code: folder, known: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play code of the same language as an App Store locale, if Play has it. */
|
||||||
|
export const appleToPlay = (apple: string): string | undefined =>
|
||||||
|
APPLE_TO_PLAY[apple] ?? (PLAY_LOCALES.includes(apple) ? apple : undefined);
|
||||||
|
|
||||||
|
/** App Store locale of the same language as a Play code, if the App Store has it. */
|
||||||
|
export const playToApple = (play: string): string | undefined =>
|
||||||
|
PLAY_TO_APPLE[play] ?? (APPLE_LOCALES.includes(play) ? play : undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* storebound `languages[]` from the App Store locales and Play codes a project has: one entry per language with both
|
||||||
|
* codes when both stores have it, else one store's code. Ids are the base language ("en", "tr", "zh"), or the whole
|
||||||
|
* code in lower case when the base is taken ("en-gb", "pt-pt"). en-US (else the first) comes first.
|
||||||
|
*/
|
||||||
|
export function pairLanguages(apple: string[], play: string[], primary?: string): Language[] {
|
||||||
|
const pairs: { apple?: string; play?: string }[] = [];
|
||||||
|
const playLeft = new Set(play);
|
||||||
|
for (const a of [...new Set(apple)]) {
|
||||||
|
const p = appleToPlay(a);
|
||||||
|
if (p && playLeft.has(p)) { pairs.push({ apple: a, play: p }); playLeft.delete(p); continue; }
|
||||||
|
// a Play code that maps back onto this locale (ms-MY -> ms)
|
||||||
|
const back = [...playLeft].find(x => playToApple(x) === a);
|
||||||
|
if (back) { pairs.push({ apple: a, play: back }); playLeft.delete(back); continue; }
|
||||||
|
pairs.push({ apple: a });
|
||||||
|
}
|
||||||
|
for (const p of playLeft) pairs.push({ play: p });
|
||||||
|
const tag = (x: { apple?: string; play?: string }) => x.apple ?? playToApple(x.play!) ?? x.play!;
|
||||||
|
const first = (x: { apple?: string; play?: string }) => (primary ? x.apple === primary || x.play === primary : x.apple === 'en-US' || x.play === 'en-US');
|
||||||
|
pairs.sort((x, y) => Number(first(y)) - Number(first(x)) || tag(x).localeCompare(tag(y)));
|
||||||
|
const used = new Set<string>();
|
||||||
|
return pairs.map(x => {
|
||||||
|
const t = tag(x);
|
||||||
|
let id = t.split('-')[0]!.toLowerCase();
|
||||||
|
if (used.has(id)) id = t.toLowerCase();
|
||||||
|
for (let n = 2; used.has(id); n++) id = `${t.toLowerCase()}-${n}`;
|
||||||
|
used.add(id);
|
||||||
|
return { id, ...(x.play ? { play: x.play } : {}), ...(x.apple ? { apple: x.apple } : {}) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** English name of a language code for headings ("tr-TR" -> "Turkish"), else the code. */
|
||||||
|
export function languageName(code: string): string {
|
||||||
|
try { return new Intl.DisplayNames(['en'], { type: 'language' }).of(code.replace(/^iw\b/, 'he')) ?? code; } catch { return code; }
|
||||||
|
}
|
||||||
|
|
@ -206,6 +206,11 @@ export const TOOLS: ToolDef[] = [
|
||||||
required: ['variant'], args: i => [...opt('variant', i.variant), ...opt('experiment', i.experiment), ...opt('only', i.only)] },
|
required: ['variant'], args: i => [...opt('variant', i.variant), ...opt('experiment', i.experiment), ...opt('only', i.only)] },
|
||||||
{ name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true,
|
{ name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true,
|
||||||
description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' },
|
description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' },
|
||||||
|
{ name: 'import_fastlane', title: 'Import a fastlane setup', command: ['import', 'fastlane'], write: true, network: false, idempotent: true,
|
||||||
|
description: 'Read the project\'s fastlane folder(s) (Appfile, Deliverfile, deliver + supply metadata, screenshots) and plan storebound.config.ts, store/appstore.md, store/listing.md and screenshot frames (folder capture source). Local files only; existing files are never overwritten (a <name>.imported.<ext> copy goes next to them). Keys, team ids, the App Review contact and demo account are only named, never copied. dry_run prints the plan and the report of what is not mapped.',
|
||||||
|
props: { dir: { type: 'string', pattern: '^(?!/)(?!.*\\.\\.)(?!.*,/)[A-Za-z0-9._ /,-]{1,400}$', description: 'fastlane folder(s) relative to the project, comma-separated (default: fastlane, ios/fastlane, android/fastlane)' },
|
||||||
|
profile: { type: 'string', enum: ['app', 'game', 'subscription-app'], description: 'Default: game for the GAMES category, else app' } },
|
||||||
|
args: i => [...opt('dir', i.dir), ...opt('profile', i.profile)] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */
|
/** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,9 @@ export type Runner = (argv: string[], o: RunOptions) => Running;
|
||||||
|
|
||||||
const MAX_OUTPUT = 60_000;
|
const MAX_OUTPUT = 60_000;
|
||||||
|
|
||||||
/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes). */
|
/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes; the fastlane import
|
||||||
export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release']);
|
* needs a folder without a config, which the desktop first-run guide does not offer yet). */
|
||||||
|
export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release', 'import_fastlane']);
|
||||||
/** Words no desktop method name may contain. */
|
/** Words no desktop method name may contain. */
|
||||||
export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i;
|
export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i;
|
||||||
|
|
||||||
|
|
|
||||||
61
skills/migrate-from-fastlane/SKILL.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
name: migrate-from-fastlane
|
||||||
|
description: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` - store ids, App Store and Google Play listing text in every language, categories, review notes, Play icon and feature graphic, screenshots as frames - without copying keys, team ids or the App Review demo account. Use when a project has a fastlane/ (or ios/fastlane, android/fastlane) folder, when the user says "we use fastlane", "migrate from fastlane", or before running `storebound init` in such a project.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migrate from fastlane
|
||||||
|
|
||||||
|
CLI: `storebound import fastlane [--dir fastlane] [--out .] [--profile app|game|subscription-app] [--dry]` (MCP:
|
||||||
|
`import_fastlane`, `dry_run` defaults to true). It only reads fastlane's files and never runs Ruby. Default `--dir`:
|
||||||
|
every one of `fastlane/`, `ios/fastlane/`, `android/fastlane/` that exists; pass several with commas.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. `storebound import fastlane --dry`. Show the owner the plan and the report. The report has five parts:
|
||||||
|
**mapped** (source -> target), **not mapped** (with where each item goes by hand), **keys, accounts and personal
|
||||||
|
data** (named only), **placeholders** and **warnings** (missing fields, store limits, unknown locale codes).
|
||||||
|
2. With the owner's yes, run it without `--dry`. Nothing that exists is overwritten:
|
||||||
|
- a text file that exists gets a `<name>.imported.<ext>` copy next to it. Compare with
|
||||||
|
`git diff --no-index store/listing.md store/listing.imported.md` and merge what the owner wants;
|
||||||
|
- an image that exists is kept.
|
||||||
|
3. `storebound init --profile <same profile>`: it adds the remaining templates (declarations, App Privacy, Data
|
||||||
|
safety CSV, privacy policy, `.storebound/credentials.json`) and keeps the imported files.
|
||||||
|
4. Secrets, done by the owner (never in chat, never in the config; AGENTS.md rules 4 and 9):
|
||||||
|
- Play `json_key_file`: move the key into `.storebound/`, set `PLAY_SERVICE_ACCOUNT` to its path. If the key was
|
||||||
|
ever committed, rotate it in Google Cloud.
|
||||||
|
- `app_store_connect_api_key` in the Fastfile: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_P8` (the .p8 path).
|
||||||
|
- `review_information/` contact and demo account: `reviewContact` and `reviewDemoAccount` in
|
||||||
|
`.storebound/credentials.json`. The import sets `reviewAccess.signInRequired: true` when fastlane had a demo user.
|
||||||
|
5. Replace the placeholders the report lists (`app.contactEmail` always: the App Review contact is personal data and
|
||||||
|
is not used for it).
|
||||||
|
6. `storebound check`, then `storebound shots capture --source folder` to check the imported frames.
|
||||||
|
|
||||||
|
## What maps where
|
||||||
|
| fastlane | storebound |
|
||||||
|
|---|---|
|
||||||
|
| Appfile `app_identifier` / `package_name` | `apple.bundleId` / `play.packageName` |
|
||||||
|
| deliver locale folders + supply language folders | `languages[]`, one entry per language with both codes (`tr` + `tr-TR`, `zh-Hans` + `zh-CN`, `he` + `iw-IL`, `es-MX` + `es-419`) |
|
||||||
|
| `metadata/<locale>/name, subtitle, promotional_text, keywords, description, release_notes` (+ `default/`) | `store/appstore.md` (`name`, `subtitle`, `promotionalText`, `keywords` without spaces after commas, `description`, `whatsNew`) |
|
||||||
|
| `privacy_url`, `support_url`, `marketing_url` | `app.privacyPolicyUrl`, `app.supportUrl`, `app.marketingUrl` + `app.website` (one for all languages) |
|
||||||
|
| `copyright.txt`, `primary_category.txt` ... | `app.copyright`, `apple.info.categories` (`GAMES` picks the game profile) |
|
||||||
|
| `review_information/notes.txt` | `apple.info.reviewNotes` |
|
||||||
|
| `metadata/android/<lang>/title, short_description, full_description` | `store/listing.md` |
|
||||||
|
| newest `changelogs/<versionCode>.txt` (else `default.txt`) | Play "What's new" |
|
||||||
|
| `video.txt` (YouTube) | `play.video` |
|
||||||
|
| `images/icon.png`, `images/featureGraphic.*` | `store/icons/play-512.png` (`play.icon`), `store/renders/<lang>/feature/1024x500.*` |
|
||||||
|
| `screenshots/<locale>/` (largest iPhone set, iPad), else `phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames/<lang>/<phone\|tablet>/NN.png`, scenes `shot-01`..., `shots.capture.source: 'folder'`, empty copy in `store/shots/copy.json` |
|
||||||
|
|
||||||
|
## Not imported (on purpose)
|
||||||
|
- `submit_for_review`, `automatic_release`, `reject_if_possible`: the owner submits and releases (AGENTS.md rule 1);
|
||||||
|
phased release is `release-management`.
|
||||||
|
- `price_tier`: products and the prices CSV (`store-iap-setup`). Age rating config: `store-declarations`.
|
||||||
|
Export compliance: `export-compliance` (`apple.usesNonExemptEncryption` stays commented out).
|
||||||
|
- Lanes, `match`, `gym` / `gradle`: building and signing stay where they are; storebound has no lanes.
|
||||||
|
- Ruby logic (`for_platform`, `for_lane`, `ENV[...]`, interpolation): reported by key and line, set by hand.
|
||||||
|
- Promo graphic, TV banner, TV / Wear screenshots, Apple TV privacy policy, Korean trade representative contact:
|
||||||
|
consoles.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
fastlane screenshots are often finished store art (framed, captioned). The import puts them in the frames folder so
|
||||||
|
`shots render` can rebuild them from one source in every size; full-screen captures usually need the status bar cut
|
||||||
|
(`shots capture --source folder` reports the aspect ratio per frame). If the owner prefers to keep the finished art,
|
||||||
|
leave the copy empty and upload as before. JPEG screenshots are listed, not copied (frames are PNG).
|
||||||
348
tests/fastlane.test.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
// storebound import fastlane: locale mapping, Ruby option lines, the plan from tests/fixtures/fastlane-sample and the
|
||||||
|
// no-overwrite writer. Fixture folders are copied into temp dirs so each case can drop parts of the setup.
|
||||||
|
import { afterAll, describe, expect, test } from 'bun:test';
|
||||||
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { applyPlan, report } from '../cli/import';
|
||||||
|
import { validateConfig } from '../cli/lib/config';
|
||||||
|
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
|
||||||
|
import { categoryId, classifyAppleShots, findFastlaneDirs, importedPath, normalizeKeywords, parseRubyOptions, planImport, scanFastlane, tsString } from '../cli/lib/fastlane';
|
||||||
|
import { APPLE_LOCALES, APPLE_TO_PLAY, appleLocale, appleToPlay, pairLanguages, PLAY_LOCALES, playLocale, playToApple } from '../cli/lib/store-locales';
|
||||||
|
import { TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
||||||
|
import { png } from './fixtures';
|
||||||
|
|
||||||
|
const SAMPLE = join(import.meta.dir, 'fixtures', 'fastlane-sample', 'fastlane');
|
||||||
|
const temps: string[] = [];
|
||||||
|
const temp = () => { const d = mkdtempSync(join(tmpdir(), 'sb-fastlane-')); temps.push(d); return d; };
|
||||||
|
afterAll(() => { for (const d of temps) rmSync(d, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
/** A project folder with a copy of the sample fastlane folder, minus the given paths (relative to fastlane/). */
|
||||||
|
function project(drop: string[] = [], sub = 'fastlane'): { root: string; fl: string } {
|
||||||
|
const root = temp();
|
||||||
|
const fl = join(root, sub);
|
||||||
|
cpSync(SAMPLE, fl, { recursive: true });
|
||||||
|
for (const p of drop) rmSync(join(fl, p), { recursive: true, force: true });
|
||||||
|
return { root, fl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = (root: string, dirs: string[], profile?: 'app' | 'game' | 'subscription-app') => planImport(scanFastlane(dirs), { root, profile });
|
||||||
|
const fileText = (p: ReturnType<typeof plan>, path: string) => p.files.find(f => f.path === path)?.text;
|
||||||
|
|
||||||
|
async function loadPlannedConfig(p: ReturnType<typeof plan>): Promise<unknown> {
|
||||||
|
const d = temp();
|
||||||
|
const f = join(d, 'storebound.config.ts');
|
||||||
|
writeFileSync(f, fileText(p, 'storebound.config.ts')!);
|
||||||
|
return (await import(f)).default;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('store locales', () => {
|
||||||
|
test('every pair names a code the other store has', () => {
|
||||||
|
for (const [a, p] of Object.entries(APPLE_TO_PLAY)) {
|
||||||
|
expect(APPLE_LOCALES).toContain(a);
|
||||||
|
expect(PLAY_LOCALES).toContain(p);
|
||||||
|
expect(playToApple(p)).toBe(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('fastlane deliver folders -> App Store locales', () => {
|
||||||
|
expect(appleLocale('en-US')).toEqual({ code: 'en-US', known: true });
|
||||||
|
expect(appleLocale('zh-Hans')).toEqual({ code: 'zh-Hans', known: true });
|
||||||
|
expect(appleLocale('tr-TR')).toEqual({ code: 'tr', known: true, alias: 'tr-TR' });
|
||||||
|
expect(appleLocale('en')).toEqual({ code: 'en-US', known: true, alias: 'en' });
|
||||||
|
expect(appleLocale('zh-CN').code).toBe('zh-Hans');
|
||||||
|
expect(appleLocale('he-IL').code).toBe('he');
|
||||||
|
expect(appleLocale('EN-gb')).toEqual({ code: 'en-GB', known: true, alias: 'EN-gb' });
|
||||||
|
expect(appleLocale('xx-YY')).toEqual({ code: 'xx-YY', known: false });
|
||||||
|
});
|
||||||
|
test('fastlane supply folders -> Play codes', () => {
|
||||||
|
expect(playLocale('tr-TR')).toEqual({ code: 'tr-TR', known: true });
|
||||||
|
expect(playLocale('iw-IL').known).toBe(true);
|
||||||
|
expect(playLocale('he-IL')).toEqual({ code: 'iw-IL', known: true, alias: 'he-IL' });
|
||||||
|
expect(playLocale('nb-NO').code).toBe('no-NO');
|
||||||
|
expect(playLocale('zh-Hans').code).toBe('zh-CN');
|
||||||
|
expect(playLocale('qq')).toEqual({ code: 'qq', known: false });
|
||||||
|
});
|
||||||
|
test('cross-store pairs', () => {
|
||||||
|
expect(appleToPlay('tr')).toBe('tr-TR');
|
||||||
|
expect(appleToPlay('ja')).toBe('ja-JP');
|
||||||
|
expect(appleToPlay('zh-Hant')).toBe('zh-TW');
|
||||||
|
expect(appleToPlay('he')).toBe('iw-IL');
|
||||||
|
expect(appleToPlay('es-MX')).toBe('es-419');
|
||||||
|
expect(appleToPlay('de-DE')).toBe('de-DE');
|
||||||
|
expect(appleToPlay('or-IN')).toBeUndefined();
|
||||||
|
expect(playToApple('ar')).toBe('ar-SA');
|
||||||
|
expect(playToApple('ms-MY')).toBe('ms');
|
||||||
|
expect(playToApple('en-IN')).toBeUndefined();
|
||||||
|
});
|
||||||
|
test('pairLanguages: one entry per language, en-US first, unique ids', () => {
|
||||||
|
const l = pairLanguages(['tr', 'en-US', 'en-GB', 'zh-Hans', 'ja'], ['en-US', 'tr-TR', 'zh-CN', 'es-419', 'iw-IL']);
|
||||||
|
expect(l).toEqual([
|
||||||
|
{ id: 'en', play: 'en-US', apple: 'en-US' },
|
||||||
|
{ id: 'en-gb', apple: 'en-GB' },
|
||||||
|
{ id: 'es', play: 'es-419' },
|
||||||
|
{ id: 'he', play: 'iw-IL' },
|
||||||
|
{ id: 'ja', apple: 'ja' },
|
||||||
|
{ id: 'tr', play: 'tr-TR', apple: 'tr' },
|
||||||
|
{ id: 'zh', play: 'zh-CN', apple: 'zh-Hans' },
|
||||||
|
]);
|
||||||
|
expect(pairLanguages(['ms'], ['ms-MY'])).toEqual([{ id: 'ms', play: 'ms-MY', apple: 'ms' }]);
|
||||||
|
expect(pairLanguages(['pt-BR', 'pt-PT'], []).map(x => x.id)).toEqual(['pt', 'pt-pt']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fastlane option files', () => {
|
||||||
|
test('plain lines are read, Ruby logic is reported by key only', () => {
|
||||||
|
const o = parseRubyOptions([
|
||||||
|
'app_identifier("com.example.app") # comment',
|
||||||
|
"package_name 'com.example.app'",
|
||||||
|
'languages(["en-US", "de-DE"])',
|
||||||
|
'submit_for_review(false)',
|
||||||
|
'phased_release true',
|
||||||
|
'price_tier(0)',
|
||||||
|
'username(ENV["FASTLANE_USER"])',
|
||||||
|
'team_id("#{prefix}X")',
|
||||||
|
'for_platform :ios do',
|
||||||
|
' app_identifier "com.example.app.ios"',
|
||||||
|
'end',
|
||||||
|
'app_identifier("com.example.other")',
|
||||||
|
].join('\n'));
|
||||||
|
expect(o.values).toEqual({ app_identifier: 'com.example.app', package_name: 'com.example.app', languages: ['en-US', 'de-DE'], submit_for_review: false, phased_release: true, price_tier: 0 });
|
||||||
|
expect(o.byHand.map(b => `${b.key}:${b.line}`)).toEqual(['username:7', 'team_id:8', 'app_identifier:10', 'app_identifier:12']);
|
||||||
|
expect(o.byHand[0]!.why).toMatch(/environment/);
|
||||||
|
expect(JSON.stringify(o.byHand)).not.toContain('FASTLANE_USER');
|
||||||
|
});
|
||||||
|
test('categories, keywords, TS strings', () => {
|
||||||
|
expect(categoryId('MZGenre.Productivity')).toBe('PRODUCTIVITY');
|
||||||
|
expect(categoryId('Photo & Video')).toBe('PHOTO_AND_VIDEO');
|
||||||
|
expect(categoryId('GAMES')).toBe('GAMES');
|
||||||
|
expect(categoryId('Puzzle', true)).toBe('GAMES_PUZZLE');
|
||||||
|
expect(categoryId('MZGenre.Games.Puzzle'.replace('Games.', 'Games_'), true)).toBe('GAMES_PUZZLE');
|
||||||
|
expect(categoryId('Knitting')).toBeNull();
|
||||||
|
expect(normalizeKeywords('a, b ,c\nd')).toBe('a,b,c,d');
|
||||||
|
expect(tsString("it's\n\\")).toBe("'it\\'s\\n\\\\'");
|
||||||
|
});
|
||||||
|
test('deliver screenshots: largest iPhone set, iPad as tablet, Watch left out', () => {
|
||||||
|
const d = temp();
|
||||||
|
const f = (n: string, w: number, h: number) => { const p = join(d, n); writeFileSync(p, png(w, h)); return p; };
|
||||||
|
const files = [f('iPhone 16 Pro Max-01.png', 1320, 2868), f('iPhone 8 Plus-01.png', 1242, 2208), f('iPad Pro-01.png', 2048, 2732), f('Apple Watch-01.png', 416, 496), f('01_tablet_by_size.png', 1536, 2048)];
|
||||||
|
const c = classifyAppleShots(files);
|
||||||
|
expect(c.phone).toEqual([files[0]!]);
|
||||||
|
expect(c.tablet).toEqual([files[2]!]);
|
||||||
|
expect(c.skipped.sort()).toEqual([files[1]!, files[3]!, files[4]!].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('import plan: iOS + Android (tests/fixtures/fastlane-sample)', () => {
|
||||||
|
const { root, fl } = project();
|
||||||
|
const p = plan(root, [fl]);
|
||||||
|
|
||||||
|
test('ids, languages, profile', () => {
|
||||||
|
expect(p.bundleId).toBe('com.example.notes');
|
||||||
|
expect(p.packageName).toBe('com.example.notes');
|
||||||
|
expect(p.profile).toBe('app');
|
||||||
|
expect(p.appName).toBe('Example Notes');
|
||||||
|
expect(p.languages).toEqual([
|
||||||
|
{ id: 'en', play: 'en-US', apple: 'en-US' },
|
||||||
|
{ id: 'de', play: 'de-DE', apple: 'de-DE' },
|
||||||
|
{ id: 'tr', play: 'tr-TR', apple: 'tr' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the config is valid and holds what fastlane had', async () => {
|
||||||
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
||||||
|
expect(validateConfig(c)).toEqual([]);
|
||||||
|
expect(c.app).toMatchObject({ name: 'Example Notes', copyright: '2026 Example Studio', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com' });
|
||||||
|
expect(c.reviewAccess).toEqual({ signInRequired: true });
|
||||||
|
expect(c.play).toMatchObject({ packageName: 'com.example.notes', defaultLanguage: 'en-US', listing: 'store/listing.md', icon: 'store/icons/play-512.png', video: 'https://www.youtube.com/watch?v=EXAMPLE0001' });
|
||||||
|
expect(c.apple).toMatchObject({ bundleId: 'com.example.notes', listing: 'store/appstore.md', ipad: true, info: { categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' }, reviewNotes: 'Sign in with the demo account. Sample lists are already there.' } });
|
||||||
|
expect(c.apple.usesNonExemptEncryption).toBeUndefined(); // a compliance answer is never guessed
|
||||||
|
expect(c.shots.capture).toEqual({ source: 'folder', folder: 'store/shots/frames', shots: {} });
|
||||||
|
expect(c.shots.scenes.map((s: { id: string }) => s.id)).toEqual(['shot-01', 'shot-02', 'shot-03']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listing files parse with the existing parsers', () => {
|
||||||
|
const play = parsePlayListing(fileText(p, 'store/listing.md')!, p.appName);
|
||||||
|
expect(play.map(x => x.lang)).toEqual(['en-US', 'de-DE', 'tr-TR']);
|
||||||
|
expect(play[0]).toMatchObject({ title: 'Example Notes', short: 'Lists and notes that stay in sync on every device', whatsNew: 'Shared lists.' }); // newest changelog
|
||||||
|
const md = fileText(p, 'store/appstore.md')!;
|
||||||
|
expect(() => parseAppStoreListing(md)).toThrow(/tr: missing promotionalText/);
|
||||||
|
const fixed = md.replace('## tr · Turkish\n', '## tr · Turkish\n\n### promotionalText\n```\nYeni\n```\n');
|
||||||
|
const apple = parseAppStoreListing(fixed);
|
||||||
|
expect(Object.keys(apple)).toEqual(['en-US', 'de-DE', 'tr']);
|
||||||
|
expect(apple['en-US']).toMatchObject({ keywords: 'todo,checklist,groceries,planner,shared,family,tasks', whatsNew: 'Shared lists and a calmer home screen.' });
|
||||||
|
expect(apple['en-US']!.name).toBeUndefined(); // same as app.name
|
||||||
|
expect(p.warnings.join('\n')).toMatch(/tr: no promotionalText/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screenshots become folder frames; iOS wins over Play for the same language', () => {
|
||||||
|
const frames = p.files.filter(f => f.kind === 'frame').map(f => `${f.path} <- ${f.src}`);
|
||||||
|
expect(frames).toContain('store/shots/frames/en/phone/01.png <- fastlane/screenshots/en-US/iPhone 16 Pro Max-01_home.png');
|
||||||
|
expect(frames).toContain('store/shots/frames/en/tablet/01.png <- fastlane/screenshots/en-US/iPad Pro (12.9-inch) (6th generation)-01_home.png');
|
||||||
|
expect(frames).toContain('store/shots/frames/tr/phone/02.png <- fastlane/metadata/android/tr-TR/images/phoneScreenshots/2_tr-TR.png');
|
||||||
|
expect(frames.some(f => /iPhone 8 Plus|Watch/.test(f))).toBe(false);
|
||||||
|
expect(frames.filter(f => f.startsWith('store/shots/frames/en/phone/'))).toHaveLength(3);
|
||||||
|
const copy = JSON.parse(fileText(p, 'store/shots/copy.json')!);
|
||||||
|
expect(Object.keys(copy)).toEqual(['en', 'de', 'tr']);
|
||||||
|
expect(copy.en['shot-01']).toEqual({ title: ['', ''], sub: '' });
|
||||||
|
expect(p.files.find(f => f.kind === 'feature' && f.path === 'store/renders/tr/feature/1024x500.png')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keys, team ids, the review contact and the demo account never reach a file', () => {
|
||||||
|
const all = p.files.filter(f => f.text !== undefined).map(f => f.text).join('\n');
|
||||||
|
for (const secret of ['example-not-a-password', 'demo@example.com', 'review-contact@example.com', 'developer@example.com', 'TEAMID1234', '000000000', 'play-service-account.json', 'AuthKey', 'Alex', '555 0100', 'git.example.com'])
|
||||||
|
expect(all).not.toContain(secret);
|
||||||
|
const r = report(p);
|
||||||
|
expect(r).not.toContain('example-not-a-password');
|
||||||
|
expect(r).not.toContain('review-contact@example.com');
|
||||||
|
expect(r).toContain('json_key_file (fastlane/play-service-account.json): move the Play service account key into .storebound/');
|
||||||
|
expect(r).toMatch(/demo_password\.txt, demo_user\.txt \(App Review demo account\): not copied/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the report lists what is not mapped and where it goes', () => {
|
||||||
|
const r = report(p);
|
||||||
|
for (const s of ['Deliverfile submit_for_review = false: never automated', 'Deliverfile price_tier:', 'Deliverfile app_rating_config_path: apple.info.ageRating',
|
||||||
|
'Fastfile lanes: beta, screenshots, release, deploy', 'Fastfile capture_screenshots: `storebound shots capture --source ios-sim`', 'Matchfile',
|
||||||
|
'apple_tv_privacy_policy.txt', 'promoGraphic.png', 'wearScreenshots', 'app_identifier (line 13): inside a block', 'username (line 11): from an environment variable',
|
||||||
|
'app.contactEmail'])
|
||||||
|
expect(r).toContain(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a GAMES category picks the game profile unless --profile says otherwise', () => {
|
||||||
|
const g = project();
|
||||||
|
writeFileSync(join(g.fl, 'metadata', 'primary_category.txt'), 'Games\n');
|
||||||
|
writeFileSync(join(g.fl, 'metadata', 'primary_first_sub_category.txt'), 'Puzzle\n');
|
||||||
|
const gp = plan(g.root, [g.fl]);
|
||||||
|
expect(gp.profile).toBe('game');
|
||||||
|
expect(fileText(gp, 'storebound.config.ts')).toContain("categories: { primary: 'GAMES', primarySubOne: 'GAMES_PUZZLE', secondary: 'UTILITIES' }");
|
||||||
|
expect(fileText(gp, 'storebound.config.ts')).toContain("style: 'game'");
|
||||||
|
expect(plan(g.root, [g.fl], 'subscription-app').profile).toBe('subscription-app');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('import plan: one store, unknown locales, missing files', () => {
|
||||||
|
test('iOS only: no play section, no store/listing.md', async () => {
|
||||||
|
const { root, fl } = project(['metadata/android']);
|
||||||
|
writeFileSync(join(fl, 'Appfile'), 'app_identifier("com.example.notes")\n');
|
||||||
|
const p = plan(root, [fl]);
|
||||||
|
expect(p.packageName).toBeUndefined();
|
||||||
|
expect(p.files.map(f => f.path)).not.toContain('store/listing.md');
|
||||||
|
expect(p.languages).toEqual([{ id: 'en', apple: 'en-US' }, { id: 'de', apple: 'de-DE' }, { id: 'tr', apple: 'tr' }]);
|
||||||
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
||||||
|
expect(validateConfig(c)).toEqual([]);
|
||||||
|
expect(c.play).toBeUndefined();
|
||||||
|
expect(c.apple.bundleId).toBe('com.example.notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Android only: no apple section, no store/appstore.md, title from supply', async () => {
|
||||||
|
const { root, fl } = project(['metadata/en-US', 'metadata/de-DE', 'metadata/tr', 'metadata/default', 'metadata/review_information', 'metadata/copyright.txt',
|
||||||
|
'metadata/primary_category.txt', 'metadata/secondary_category.txt', 'screenshots', 'Deliverfile', 'Matchfile']);
|
||||||
|
writeFileSync(join(fl, 'Appfile'), 'json_key_file("keys/play.json")\npackage_name("com.example.notes")\n');
|
||||||
|
const p = plan(root, [fl]);
|
||||||
|
expect(p.bundleId).toBeUndefined();
|
||||||
|
expect(p.appName).toBe('Example Notes');
|
||||||
|
expect(p.files.map(f => f.path)).not.toContain('store/appstore.md');
|
||||||
|
expect(p.languages).toEqual([{ id: 'en', play: 'en-US' }, { id: 'de', play: 'de-DE' }, { id: 'tr', play: 'tr-TR' }]);
|
||||||
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
||||||
|
expect(validateConfig(c)).toEqual([]);
|
||||||
|
expect(c.apple).toBeUndefined();
|
||||||
|
expect(c.reviewAccess).toBeUndefined();
|
||||||
|
expect(p.placeholders.map(x => x.what)).toEqual(expect.arrayContaining(['app.privacyPolicyUrl', 'app.supportUrl', 'app.website', 'app.contactEmail']));
|
||||||
|
// Play phone screenshots become the phone frames, the 10" set the tablet frames
|
||||||
|
expect(p.files.filter(f => f.kind === 'frame').map(f => f.path)).toEqual(expect.arrayContaining(['store/shots/frames/en/phone/01.png', 'store/shots/frames/en/tablet/01.png']));
|
||||||
|
expect(p.files.find(f => f.path === 'store/shots/frames/en/tablet/01.png')!.src).toBe('fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both stores from ios/fastlane + android/fastlane', () => {
|
||||||
|
const root = temp();
|
||||||
|
cpSync(SAMPLE, join(root, 'ios', 'fastlane'), { recursive: true });
|
||||||
|
rmSync(join(root, 'ios', 'fastlane', 'metadata', 'android'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'ios', 'fastlane', 'Appfile'), 'app_identifier("com.example.notes")\n');
|
||||||
|
mkdirSync(join(root, 'android', 'fastlane', 'metadata'), { recursive: true });
|
||||||
|
cpSync(join(SAMPLE, 'metadata', 'android'), join(root, 'android', 'fastlane', 'metadata', 'android'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'android', 'fastlane', 'Appfile'), 'package_name("com.example.notes.android")\n');
|
||||||
|
const dirs = findFastlaneDirs(root);
|
||||||
|
expect(dirs.map(d => d.slice(root.length + 1))).toEqual(['ios/fastlane', 'android/fastlane']);
|
||||||
|
const p = plan(root, dirs);
|
||||||
|
expect(p.bundleId).toBe('com.example.notes');
|
||||||
|
expect(p.packageName).toBe('com.example.notes.android');
|
||||||
|
expect(p.languages).toHaveLength(3);
|
||||||
|
expect(p.languages.every(l => l.apple && l.play)).toBe(true);
|
||||||
|
expect(p.files.map(f => f.path)).toEqual(expect.arrayContaining(['store/appstore.md', 'store/listing.md']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown locales are imported as they are, with a warning', async () => {
|
||||||
|
const { root, fl } = project();
|
||||||
|
cpSync(join(fl, 'metadata', 'tr'), join(fl, 'metadata', 'xx-YY'), { recursive: true });
|
||||||
|
cpSync(join(fl, 'metadata', 'android', 'tr-TR'), join(fl, 'metadata', 'android', 'qq-QQ'), { recursive: true });
|
||||||
|
cpSync(join(fl, 'metadata', 'de-DE'), join(fl, 'metadata', 'ja-JP'), { recursive: true });
|
||||||
|
const p = plan(root, [fl]);
|
||||||
|
expect(p.warnings.join('\n')).toContain('App Store locale "xx-YY"');
|
||||||
|
expect(p.warnings.join('\n')).toContain('Play language "qq-QQ"');
|
||||||
|
expect(p.languages).toEqual(expect.arrayContaining([{ id: 'xx', apple: 'xx-YY' }, { id: 'qq', play: 'qq-QQ' }, { id: 'ja', apple: 'ja' }]));
|
||||||
|
expect(p.mapped).toContainEqual({ from: 'metadata/ja-JP', to: 'App Store locale ja' });
|
||||||
|
expect(fileText(p, 'store/appstore.md')).toContain('## ja · Japanese');
|
||||||
|
expect(validateConfig(await loadPlannedConfig(p))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing files: empty folder fails, missing fields and ids become warnings and placeholders', async () => {
|
||||||
|
const empty = temp();
|
||||||
|
mkdirSync(join(empty, 'fastlane'));
|
||||||
|
expect(() => plan(empty, [join(empty, 'fastlane')])).toThrow(/nothing to import/);
|
||||||
|
|
||||||
|
const { root, fl } = project(['Appfile', 'Deliverfile', 'Fastfile', 'Matchfile', 'screenshots', 'metadata/default', 'metadata/de-DE/description.txt', 'metadata/android/de-DE/short_description.txt', 'metadata/android/en-US/images']);
|
||||||
|
const p = plan(root, [fl]);
|
||||||
|
expect(p.warnings.join('\n')).toMatch(/de-DE: no description in fastlane/);
|
||||||
|
expect(p.warnings.join('\n')).toMatch(/de-DE: no short_description\.txt in fastlane/);
|
||||||
|
expect(p.placeholders.map(x => x.what)).toEqual(expect.arrayContaining(['apple.bundleId', 'play.packageName', 'app.privacyPolicyUrl']));
|
||||||
|
expect(p.files.some(f => f.kind === 'frame' && f.path.startsWith('store/shots/frames/en/'))).toBe(false);
|
||||||
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
||||||
|
expect(validateConfig(c)).toEqual([]);
|
||||||
|
expect(c.apple.bundleId).toBe('com.example.app');
|
||||||
|
expect(c.play.icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writing the plan', () => {
|
||||||
|
test('dry run writes nothing; a real run creates the files', () => {
|
||||||
|
const { root, fl } = project();
|
||||||
|
const p = plan(root, [fl]);
|
||||||
|
const dry = applyPlan(p, root, true);
|
||||||
|
expect(dry.created).toContain('storebound.config.ts');
|
||||||
|
expect(existsSync(join(root, 'storebound.config.ts'))).toBe(false);
|
||||||
|
expect(existsSync(join(root, 'store'))).toBe(false);
|
||||||
|
const real = applyPlan(p, root, false);
|
||||||
|
expect(real.created).toEqual(dry.created);
|
||||||
|
expect(readFileSync(join(root, 'store', 'listing.md'), 'utf8')).toBe(fileText(p, 'store/listing.md')!);
|
||||||
|
expect(readFileSync(join(root, 'store/shots/frames/en/phone/01.png'))).toEqual(readFileSync(join(fl, 'screenshots/en-US/iPhone 16 Pro Max-01_home.png')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existing files are never overwritten: text goes to .imported, images are kept', () => {
|
||||||
|
const { root, fl } = project();
|
||||||
|
mkdirSync(join(root, 'store', 'icons'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'storebound.config.ts'), '// mine\n');
|
||||||
|
writeFileSync(join(root, 'store', 'icons', 'play-512.png'), 'mine');
|
||||||
|
const p = plan(root, [fl]);
|
||||||
|
const r = applyPlan(p, root, false);
|
||||||
|
expect(readFileSync(join(root, 'storebound.config.ts'), 'utf8')).toBe('// mine\n');
|
||||||
|
expect(readFileSync(join(root, 'storebound.config.imported.ts'), 'utf8')).toBe(fileText(p, 'storebound.config.ts')!);
|
||||||
|
expect(readFileSync(join(root, 'store', 'icons', 'play-512.png'), 'utf8')).toBe('mine');
|
||||||
|
expect(r.imported).toEqual([{ target: 'storebound.config.ts', written: 'storebound.config.imported.ts' }]);
|
||||||
|
expect(r.kept).toEqual(['store/icons/play-512.png']);
|
||||||
|
expect(importedPath('store/shots/copy.json')).toBe('store/shots/copy.imported.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCP tool', () => {
|
||||||
|
const t = TOOLS.find(x => x.name === 'import_fastlane')!;
|
||||||
|
test('local write, dry run by default', () => {
|
||||||
|
expect(t).toMatchObject({ write: true, network: false, command: ['import', 'fastlane'] });
|
||||||
|
expect(toArgv(t, {}).argv).toEqual(['import', 'fastlane', '--dry']);
|
||||||
|
expect(toArgv(t, { dir: 'ios/fastlane,android/fastlane', profile: 'game', dry_run: false }).argv).toEqual(['import', 'fastlane', '--dir', 'ios/fastlane,android/fastlane', '--profile', 'game']);
|
||||||
|
});
|
||||||
|
test('dir stays inside the project', () => {
|
||||||
|
expect(validateInput(t, { dir: 'fastlane' })).toEqual([]);
|
||||||
|
expect(validateInput(t, { dir: '../other/fastlane' }).length).toBe(1);
|
||||||
|
expect(validateInput(t, { dir: '/etc' }).length).toBe(1);
|
||||||
|
expect(validateInput(t, { dir: 'fastlane,/etc' }).length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
tests/fixtures/fastlane-sample/fastlane/Appfile
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Appfile: shared by deliver, supply, match and pilot
|
||||||
|
app_identifier("com.example.notes") # The bundle identifier of your app
|
||||||
|
apple_id("developer@example.com") # Your Apple Developer Portal username
|
||||||
|
|
||||||
|
itc_team_id("000000000") # App Store Connect Team ID
|
||||||
|
team_id("TEAMID1234") # Developer Portal Team ID
|
||||||
|
|
||||||
|
json_key_file("fastlane/play-service-account.json") # Path to the json secret file
|
||||||
|
package_name("com.example.notes") # e.g. com.example.app
|
||||||
|
|
||||||
|
for_platform :ios do
|
||||||
|
for_lane :beta do
|
||||||
|
app_identifier "com.example.notes.beta"
|
||||||
|
end
|
||||||
|
end
|
||||||
11
tests/fixtures/fastlane-sample/fastlane/Deliverfile
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# The Deliverfile allows you to store various App Store Connect metadata
|
||||||
|
# https://docs.fastlane.tools/actions/deliver/
|
||||||
|
|
||||||
|
languages(["en-US", "de-DE", "tr"])
|
||||||
|
submit_for_review(false)
|
||||||
|
automatic_release(true)
|
||||||
|
phased_release true
|
||||||
|
price_tier(0)
|
||||||
|
app_rating_config_path("./fastlane/rating_config.json")
|
||||||
|
precheck_include_in_app_purchases(false)
|
||||||
|
username(ENV["FASTLANE_USER"])
|
||||||
28
tests/fixtures/fastlane-sample/fastlane/Fastfile
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Push a new beta build to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
app_store_connect_api_key(key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_filepath: "./fastlane/AuthKey.p8")
|
||||||
|
increment_build_number(xcodeproj: "Notes.xcodeproj")
|
||||||
|
match(type: "appstore")
|
||||||
|
build_app(scheme: "Notes")
|
||||||
|
upload_to_testflight
|
||||||
|
end
|
||||||
|
|
||||||
|
lane :screenshots do
|
||||||
|
capture_screenshots
|
||||||
|
frameit(white: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
lane :release do
|
||||||
|
deliver(submit_for_review: false, force: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :android do
|
||||||
|
lane :deploy do
|
||||||
|
gradle(task: "bundle", build_type: "Release")
|
||||||
|
upload_to_play_store(track: "internal")
|
||||||
|
end
|
||||||
|
end
|
||||||
4
tests/fixtures/fastlane-sample/fastlane/Matchfile
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
git_url("https://git.example.com/ios-certificates.git")
|
||||||
|
storage_mode("git")
|
||||||
|
type("appstore")
|
||||||
|
app_identifier(["com.example.notes"])
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/41.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Bug fixes.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/42.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Geteilte Listen.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/changelogs/default.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Bug fixes and improvements.
|
||||||
4
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/full_description.txt
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
Example Notes: Listen und Notizen, auf allen Geräten synchron.
|
||||||
|
|
||||||
|
• Share a list
|
||||||
|
• Export your data
|
||||||
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/featureGraphic.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/1_de-DE.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/images/phoneScreenshots/2_de-DE.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/short_description.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Listen und Notizen, auf allen Geräten synchron
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/de-DE/title.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/41.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Bug fixes.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/42.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Shared lists.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/changelogs/default.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Bug fixes and improvements.
|
||||||
4
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/full_description.txt
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
Example Notes: Lists and notes that stay in sync on every device.
|
||||||
|
|
||||||
|
• Share a list
|
||||||
|
• Export your data
|
||||||
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/featureGraphic.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/icon.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/promoGraphic.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/images/wearScreenshots/1_en-US.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/short_description.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Lists and notes that stay in sync on every device
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/title.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/en-US/video.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
https://www.youtube.com/watch?v=EXAMPLE0001
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/41.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Bug fixes.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/42.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Paylaşılan listeler.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/changelogs/default.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Bug fixes and improvements.
|
||||||
4
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/full_description.txt
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
Example Notes: Tüm cihazlarında eşitlenen listeler ve notlar.
|
||||||
|
|
||||||
|
• Share a list
|
||||||
|
• Export your data
|
||||||
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/featureGraphic.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/1_tr-TR.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/images/phoneScreenshots/2_tr-TR.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/short_description.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Tüm cihazlarında eşitlenen listeler ve notlar
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/android/tr-TR/title.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/copyright.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
2026 Example Studio
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/description.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes hält Listen und Notizen auf allen Geräten synchron.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/keywords.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
aufgaben,einkauf,planer,familie
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/name.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/promotional_text.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Geteilte Listen sind da.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/release_notes.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Geteilte Listen.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/de-DE/subtitle.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Listen und Notizen, synchron
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/default/marketing_url.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
https://example.com
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/default/privacy_url.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
https://example.com/privacy
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/default/support_url.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
https://example.com/support
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/en-US/apple_tv_privacy_policy.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
https://example.com/privacy
|
||||||
6
tests/fixtures/fastlane-sample/fastlane/metadata/en-US/description.txt
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Example Notes keeps lists and notes in sync across your devices.
|
||||||
|
|
||||||
|
FEATURES
|
||||||
|
• Lists and notes that sync
|
||||||
|
• Share a list with family or friends
|
||||||
|
• Export your data any time
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/en-US/keywords.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
todo, checklist, groceries, planner, shared, family, tasks
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/en-US/name.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/en-US/promotional_text.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Shared lists are here: plan the week together.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/en-US/release_notes.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Shared lists and a calmer home screen.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/en-US/subtitle.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Lists and notes, in sync
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/primary_category.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
MZGenre.Productivity
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_password.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
example-not-a-password
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/review_information/demo_user.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
demo@example.com
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/review_information/email_address.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
review-contact@example.com
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/review_information/first_name.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Alex
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/review_information/last_name.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/review_information/notes.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Sign in with the demo account. Sample lists are already there.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/review_information/phone_number.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
+1 555 0100
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/secondary_category.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Utilities
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/tr/description.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes listeleri ve notları tüm cihazlarında eşitler.
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/tr/keywords.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
yapılacaklar,market,planlayıcı
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/tr/name.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Example Notes
|
||||||
1
tests/fixtures/fastlane-sample/fastlane/metadata/tr/subtitle.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Listeler ve notlar, eşitli
|
||||||
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-01_home.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-02_list.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 16 Pro Max-03_share.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/de-DE/iPhone 8 Plus-01_home.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/Apple Watch Series 10-01_home.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-01_home.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-02_list.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 16 Pro Max-03_share.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |
BIN
tests/fixtures/fastlane-sample/fastlane/screenshots/en-US/iPhone 8 Plus-01_home.png
vendored
Normal file
|
After Width: | Height: | Size: 45 B |