- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
74 lines
4.4 KiB
TypeScript
74 lines
4.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 { dirname, join, relative, resolve } from 'node:path';
|
|
import type { Args } from './lib/args';
|
|
import { type AppProfile, PROFILES } from './lib/config';
|
|
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/');
|
|
}
|
|
log('init', 'next: edit storebound.config.ts, store/*.md and .storebound/credentials.json, then `storebound check`; store/workflows/*.yml: storebound flow list');
|
|
}
|