storebound/cli/init.ts

78 lines
4.7 KiB
TypeScript

// Copies the templates into the current project without overwriting anything, and makes sure .storebound/ is ignored.
// storebound init [--dir .] [--profile app|game|subscription-app] [--dry]
// Profiles (default app): app = utility / productivity app with an optional account and analytics, no ads;
// game = free-to-play game with ads and in-app purchases (the templates storebound shipped before 0.5.0);
// subscription-app = accounts plus auto-renewable subscriptions. Each profile has its own config, declarations,
// App Privacy answers, Data safety section, listing copy and screenshot copy; every answer is a starting point to edit.
// Lookup per file: templates/profiles/<profile>/<file>, then the profile it builds on, then templates/<file>.
import { appendFileSync, existsSync, mkdirSync, readFileSync, 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 { ignoreInRepo } from './lib/git-ignore';
import { log } from './lib/log';
import { TEMPLATE_TEXT } from './lib/template-files';
import * as rt from './lib/rt';
const T = join(rt.packageRoot(), 'templates');
export const FILES: [string, string][] = [
['storebound.config.ts', 'storebound.config.ts'],
['listing.md', 'store/listing.md'],
['appstore.md', 'store/appstore.md'],
['shots-copy.json', 'store/shots/copy.json'],
['app-privacy.md', 'store/app-privacy.md'],
['declarations.md', 'store/declarations.md'],
['privacy-policy.html', 'store/privacy-policy.html'],
['credentials.example.json', '.storebound/credentials.json'],
['testers.csv', '.storebound/testers.csv'],
['workflows/release.yml', 'store/workflows/release.yml'],
['workflows/beta.yml', 'store/workflows/beta.yml'],
];
/** Template folders a profile reads from (relative to templates/), most specific first. game = the root templates. */
const CHAIN: Record<AppProfile, string[]> = {
'game': [''],
'app': ['profiles/app/', ''],
'subscription-app': ['profiles/subscription-app/', 'profiles/app/', ''],
};
/** Key (path under templates/) of one template for a profile. Pure: looks it up in the embedded list. */
export function templateKey(profile: AppProfile, name: string): string {
for (const dir of CHAIN[profile]) if (TEMPLATE_TEXT[dir + name] !== undefined) return dir + name;
throw new Error(`template ${name} not found`);
}
/** Source file of one template for a profile (in the storebound folder; the compiled sidecar has none). */
export const templateFor = (profile: AppProfile, name: string): string => join(T, ...templateKey(profile, name).split('/'));
/** Template text: the embedded copy (the same bytes as the file; tests/apps.test.ts checks it). */
export const templateText = (profile: AppProfile, name: string): string => TEMPLATE_TEXT[templateKey(profile, name)]!;
export async function run(args: Args): Promise<void> {
const root = resolve(args.str('dir', '.'));
const profile = args.str('profile', 'app') as AppProfile;
if (!PROFILES.includes(profile)) throw new Error(`--profile ${profile}: one of ${PROFILES.join(', ')}`);
const dry = args.has('dry');
log('init', `profile ${profile}${dry ? ' (dry run: nothing is written)' : ''} (templates are starting points: edit every answer to match your app)`);
for (const [from, to] of FILES) {
const dest = join(root, to);
if (relative(root, dest).startsWith('..')) throw new Error(`template target outside the project: ${to}`);
if (existsSync(dest)) { log('init', `keep ${to} (exists)`); continue; }
if (dry) { log('init', `would create ${to}`); continue; }
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, templateText(profile, from));
log('init', `create ${to}`);
}
const gi = join(root, '.gitignore');
const cur = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
if (dry) { if (!/^\/?\.storebound\/?$/m.test(cur)) log('init', 'would add .storebound/ to .gitignore'); return; }
if (!/^\/?\.storebound\/?$/m.test(cur)) {
if (cur) appendFileSync(gi, `${cur.endsWith('\n') ? '' : '\n'}# storebound: credentials, fonts, temp files\n.storebound/\n`);
else writeFileSync(gi, '# storebound: credentials, fonts, temp files\n.storebound/\n');
log('init', '.gitignore: added .storebound/');
}
// store art (rendered or pulled from the stores) is never committed
const art = ignoreInRepo(join(root, 'store', 'renders'), 'storebound: store art (rendered or pulled from the stores), never committed');
if (art) log('init', `.gitignore: added ${art}`);
log('init', 'next: edit storebound.config.ts, store/*.md and .storebound/credentials.json, then `storebound check`; store/workflows/*.yml: storebound flow list');
}