102 lines
5.2 KiB
Python
102 lines
5.2 KiB
Python
#!/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()
|