Files
archy/scripts/create-app-starter.py
T

102 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()