87 lines
5.4 KiB
TypeScript
87 lines
5.4 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 { bypassOn, readSettings, setBypass } from './lib/bypass';
|
|
import { stdinIsTTY } from './lib/tty';
|
|
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, promptOut } 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}`);
|
|
// bypass mode is asked once per machine, only of a person at a terminal (default no); agents never answer it
|
|
if (readSettings().bypass === undefined && stdinIsTTY() && process.stdout.isTTY && !args.has('yes')) {
|
|
promptOut().write('Bypass mode: let your AI agent also press the owner-only buttons (Submit for review, releases, rollouts, testers, replies, prices, console Create)? Every action is logged; turn it off any time with storebound settings bypass off. [y/N] ');
|
|
const a = await rt.readLine();
|
|
setBypass(a !== null && /^y(es)?$/i.test(a.trim()));
|
|
log('init', `bypass mode: ${bypassOn() ? 'ON' : 'off'} (storebound settings bypass on | off)`);
|
|
}
|
|
log('init', 'next: edit storebound.config.ts, store/*.md and .storebound/credentials.json, then `storebound check`; store/workflows/*.yml: storebound flow list');
|
|
}
|