#!/usr/bin/env python3 """Generate a standalone starter copy; never install, activate or overwrite an app.""" import argparse import hashlib import json from pathlib import Path import re TEMPLATE = Path(__file__).resolve().parents[1] / 'examples/archipelago-app-starter' def generate(app_id: str, output: Path, template: Path = TEMPLATE): if not re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*', app_id) or len(app_id) > 48: raise ValueError('App ID must be 1–48 lowercase letters/digits with single separating hyphens') if app_id == 'archipelago-app-starter': raise ValueError('Choose a distinct generated app ID') if template.is_symlink() or not template.is_dir(): raise ValueError('Template must be a regular directory') if output.is_symlink() or output.exists(): raise FileExistsError('Output must not already exist; existing work is never overwritten') if output.name != app_id: raise ValueError('Output directory name must match the app ID for manifest validation') parent = output.parent.resolve(strict=True) target = parent / output.name if target == template.resolve() or template.resolve() in target.parents: raise ValueError('Output must be outside the template') files = [] for source in sorted(template.rglob('*')): relative = source.relative_to(template) if any(p in {'.git', 'node_modules', 'dist'} for p in relative.parts): raise ValueError('Template contains build/cache or Git state; use a clean source export') if relative == Path('GENERATION.json'): raise ValueError('Template contains a reserved generation receipt') if source.is_symlink(): raise ValueError('Symlinks in template refused') if source.is_dir(): continue if not source.is_file(): raise ValueError('Nonregular template input refused') raw = source.read_bytes() generated = raw if source.suffix in {'.json', '.yml', '.md', '.html', '.vue', '.ts', '.mjs', '.css'} or source.name == 'Dockerfile': generated = raw.decode('utf-8').replace('archipelago-app-starter', app_id).encode('utf-8') files.append((relative, raw, generated)) required = {'package.json', 'package-lock.json', 'Dockerfile', 'nginx.conf', 'manifest.yml', 'vite.config.ts', 'index.html', 'src/main.ts', 'src/App.vue', 'src/styles.css', 'src/items.mjs'} missing = required - {str(path) for path, _, _ in files} if missing: raise ValueError('Incomplete template: missing ' + ', '.join(sorted(missing))) # Validate the captured bytes, not a second read that could disagree with # the source hashes or with what is written. This is a starter contract, # not a replacement for npm ci or package/artifact qualification. inputs = {str(path): raw for path, raw, _ in files} try: package = json.loads(inputs['package.json']) lock = json.loads(inputs['package-lock.json']) root = lock['packages'][''] if lock['lockfileVersion'] != 3 or any( obj['name'] != 'archipelago-app-starter' or obj['version'] != package['version'] for obj in (package, lock, root) ): raise ValueError('identity/version mismatch') for field in ('dependencies', 'devDependencies'): dependencies = package.get(field, {}) if dependencies != root.get(field, {}): raise ValueError('dependency root mismatch') for name, version in dependencies.items(): if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', version): raise ValueError('dependencies must use exact versions') if lock['packages']['node_modules/' + name]['version'] != version: raise ValueError('resolved dependency mismatch') except (ValueError, KeyError, TypeError, AttributeError) as error: raise ValueError('Incoherent package/lock: ' + str(error)) from error # Exclusive directory creation is the ownership boundary. On disk errors the # partial new output is retained for diagnosis; never remove unrelated work. target.mkdir() receipt = {'app_id': app_id, 'status': 'generated source only; not built, reviewed, installed or UAT-qualified', 'files': []} for relative, raw, generated in files: destination = target / relative destination.parent.mkdir(parents=True, exist_ok=True) with destination.open('xb') as stream: stream.write(generated) receipt['files'].append({'path': str(relative), 'template_sha256': hashlib.sha256(raw).hexdigest(), 'generated_sha256': hashlib.sha256(generated).hexdigest()}) with (target/'GENERATION.json').open('x') as stream: json.dump(receipt, stream, indent=2) stream.write('\n') return receipt def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('app_id') parser.add_argument('output', type=Path) args = parser.parse_args() receipt = generate(args.app_id, args.output) print(f"Generated {receipt['app_id']}: {len(receipt['files'])} files; source only, no install/build performed") if __name__ == '__main__': main()