#!/usr/bin/env python3 """Offline scaffolding, structural checks and allowlisted ZIPs; no upload or login.""" from __future__ import annotations import argparse import hashlib from html.parser import HTMLParser import json import math from pathlib import Path, PurePosixPath import re import shutil import subprocess import sys from urllib.parse import unquote, urlsplit import zipfile VENDOR = ( 'three.min.js', 'OrbitControls.js', 'CopyShader.js', 'LuminosityHighPassShader.js', 'Pass.js', 'EffectComposer.js', 'MaskPass.js', 'ShaderPass.js', 'RenderPass.js', 'UnrealBloomPass.js', ) KINDS = {'reference', 'illustrative', 'simulated'} class CheckError(ValueError): pass def require(condition, message): if not condition: raise CheckError(message) def safe_file(root, relative): require(isinstance(relative, str) and relative, 'File path must be a nonempty string') pure = PurePosixPath(relative) require(not pure.is_absolute() and '..' not in pure.parts and '\\' not in relative, f'Unsafe relative path: {relative}') require(':' not in relative and not relative.endswith('/'), f'Invalid file path: {relative}') current = root for part in pure.parts: current = current / part require(not current.is_symlink(), f'Symlink is not accepted: {relative}') require(current.resolve().is_relative_to(root.resolve()), f'Path escapes root: {relative}') require(current.is_file(), f'Missing file: {relative}') return current def read_spec(root): path = safe_file(root, 'process-data.js') match = re.fullmatch(r'\s*window\.PROCESS_MANIFEST\s*=\s*(\{[\s\S]*\})\s*;?\s*', path.read_text(encoding='utf-8')) require(match is not None, 'process-data.js must assign one JSON object to window.PROCESS_MANIFEST') try: result = json.loads(match.group(1)) except json.JSONDecodeError as exc: raise CheckError(f'Invalid manifest JSON at line {exc.lineno}') from None require(isinstance(result, dict), 'Process manifest must be an object') return result def finite(value): return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) def text_field(obj, name, where): require(isinstance(obj.get(name), str) and obj[name].strip(), f'{where}: missing {name}') def unique_records(records, where): require(isinstance(records, list), f'{where} must be an array') result = {} for entry in records: require(isinstance(entry, dict), f'{where}: entries must be objects') text_field(entry, 'id', where) require(entry['id'] not in result, f'{where}: duplicate ID {entry["id"]}') result[entry['id']] = entry return result def source_refs(value, sources, where, required=False): require(isinstance(value, list), f'{where}: source_ids must be an array') require(all(isinstance(x, str) and x in sources for x in value), f'{where}: unknown source ID') if required: require(bool(value), f'{where}: reference data needs a source') def validate_spec(spec): require(spec.get('version') == 1 and not isinstance(spec.get('version'), bool), 'Unsupported process manifest version; expected 1') for field in ('id', 'title', 'route', 'scope'): text_field(spec, field, 'process') sources = unique_records(spec.get('sources'), 'sources') for item in sources.values(): text_field(item, 'title', f'source {item["id"]}') url = urlsplit(item.get('url', '')) require(url.scheme in ('http', 'https') and bool(url.netloc), f'source {item["id"]}: expected an HTTP(S) URL') materials = unique_records(spec.get('materials'), 'materials') require(bool(materials), 'At least one material state is required') for item in materials.values(): text_field(item, 'label', f'material {item["id"]}') require(isinstance(item.get('initial'), bool), f'material {item["id"]}: initial must be boolean') available = {k for k, item in materials.items() if item['initial']} stages = unique_records(spec.get('stages'), 'stages') require(bool(stages), 'At least one stage is required') for stage in stages.values(): name = f'stage {stage["id"]}' for field in ('title', 'key', 'en', 'description'): text_field(stage, field, name) duration = stage.get('duration_seconds') require(finite(duration) and duration > 0, f'{name}: duration_seconds must be positive') for field in ('inputs', 'outputs'): values = stage.get(field) require(isinstance(values, list) and bool(values), f'{name}: {field} must not be empty') require(all(isinstance(x, str) and x in materials for x in values), f'{name}: unknown {field} material state') require(len(values) == len(set(values)), f'{name}: duplicate {field} material state') missing = set(stage['inputs']) - available require(not missing, f'{name}: inputs have no earlier producer: {sorted(missing)}') available.update(stage['outputs']) phases = stage.get('phases') require(isinstance(phases, list) and bool(phases), f'{name}: phases must not be empty') previous = -1 for i, phase in enumerate(phases): require(isinstance(phase, dict), f'{name}: phase must be an object') text_field(phase, 'label', name) at = phase.get('at') require(finite(at) and 0 <= at < 1 and at > previous, f'{name}: phase boundaries must increase within [0, 1)') require(i != 0 or at == 0, f'{name}: first phase must begin at 0') previous = at camera = stage.get('camera') require(isinstance(camera, dict), f'{name}: camera must be an object') for field in ('position', 'target'): vector = camera.get(field) require(isinstance(vector, list) and len(vector) == 3 and all(map(finite, vector)), f'{name}: camera {field} must contain three finite numbers') require(camera['position'] != camera['target'], f'{name}: camera cannot sit on its target') representation = stage.get('representation') require(isinstance(representation, dict), f'{name}: representation must be an object') require(representation.get('mode') in ('physical', 'section', 'mechanism'), f'{name}: invalid representation mode') text_field(representation, 'note', name) evidence = stage.get('evidence') require(isinstance(evidence, dict) and evidence.get('kind') in KINDS, f'{name}: invalid evidence kind') source_refs(evidence.get('source_ids'), sources, name, evidence['kind'] == 'reference') parameters = stage.get('parameters') require(isinstance(parameters, list), f'{name}: parameters must be an array') for item in parameters: require(isinstance(item, dict), f'{name}: parameter must be an object') text_field(item, 'name', name) text_field(item, 'value', name) require(item.get('kind') in KINDS, f'{name}: parameter must declare its kind') if item['kind'] == 'reference': require(item.get('source_id') in sources, f'{name}: reference parameter needs a valid source_id') return {'stages': len(stages), 'materials': len(materials), 'display_seconds': sum(s['duration_seconds'] for s in stages.values())} class HTMLRefs(HTMLParser): def __init__(self): super().__init__() self.refs = [] self.inline_scripts = [] self.script_parts = None def handle_starttag(self, tag, attrs): values = dict(attrs) if tag == 'script': self.script_parts = [] if 'src' not in values and values.get('type', '') in ('', 'text/javascript', 'module') else None for key in ('src', 'href', 'poster'): if key in values and tag in ('script', 'link', 'img', 'source', 'a', 'video', 'audio', 'iframe'): self.refs.append(values[key]) def handle_data(self, data): if self.script_parts is not None: self.script_parts.append(data) def handle_endtag(self, tag): if tag == 'script' and self.script_parts is not None: self.inline_scripts.append(''.join(self.script_parts)) self.script_parts = None def local_reference(root, owner, ref): parsed = urlsplit(ref) if parsed.scheme or parsed.netloc or not parsed.path: return None path = unquote(parsed.path) require('\\' not in path, f'{owner}: backslash in resource path') if path.startswith('/'): target = root / path.lstrip('/') else: target = (root / owner).parent / path cursor = root for part in target.relative_to(root).parts: cursor = cursor / part require(not cursor.is_symlink(), f'{owner}: resource traverses a symlink') require(target.resolve().is_relative_to(root.resolve()), f'{owner}: resource escapes site root') if target.is_dir(): target = target / 'index.html' # Normalize ../ within the root before applying the publication path rules. relative = target.resolve().relative_to(root.resolve()).as_posix() safe_file(root, relative) return relative def check_resources(root, paths, node_check=False): files = set(paths) external = [] node = shutil.which('node') if node_check else None require(not node_check or node is not None, '--node-check requested but Node is unavailable') for rel in paths: path = safe_file(root, rel) refs, inline = [], [] if path.suffix.lower() == '.html': parser = HTMLRefs() parser.feed(path.read_text(encoding='utf-8')) refs, inline = parser.refs, parser.inline_scripts elif path.suffix.lower() == '.css': css = path.read_text(encoding='utf-8') refs = re.findall(r'url\(\s*[\'"]?([^\)\'"\s]+)', css) refs += re.findall(r'@import\s+[\'"]([^\'"]+)', css) elif path.suffix.lower() in ('.js', '.mjs'): js = path.read_text(encoding='utf-8') refs = re.findall(r'(?:\bfrom\s*|\bimport\s*\(\s*|^\s*import\s*)[\'"]([^\'"]+)[\'"]', js, re.M) # Browser bare module specifiers need an import map/build; this checker does not resolve them. refs = [x for x in refs if x.startswith(('.', '/')) or urlsplit(x).scheme] for ref in refs: local = local_reference(root, rel, ref) if local: require(local in files, f'{rel}: referenced file not in publish manifest: {local}') elif urlsplit(ref).scheme in ('http', 'https'): external.append(ref) if node and path.suffix.lower() in ('.js', '.mjs'): result = subprocess.run([node, '--check', str(path)], capture_output=True, text=True, timeout=30) require(result.returncode == 0, f'{rel}: JavaScript syntax check failed') for script in inline if node else []: result = subprocess.run([node, '--check', '-'], input=script, capture_output=True, text=True, timeout=30) require(result.returncode == 0, f'{rel}: inline JavaScript syntax check failed') return {'files': len(paths), 'external_references': sorted(set(external)), 'node_checked': bool(node)} def publish_paths(root, manifest): source = safe_file(root, manifest) paths = json.loads(source.read_text(encoding='utf-8')) require(isinstance(paths, list) and bool(paths), 'Publish manifest must be a nonempty array of exact file paths') require(all(isinstance(x, str) for x in paths), 'Publish file paths must be strings') require(len(paths) == len(set(paths)), 'Publish manifest contains duplicate paths') for rel in paths: safe_file(root, rel) require('index.html' in paths, 'Publish manifest requires root index.html') return paths def check_publication(root, paths): credential_patterns = ( rb'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----', rb'"claim_token"\s*:\s*"[A-Za-z0-9._-]{16,}"', rb'#claim=[A-Za-z0-9._-]{20,}', rb'\bAKIA[A-Z0-9]{16}\b', ) private_names = {'AGENTS.md', 'Agent.md', 'CLAUDE.md', '.opendrop-receipt.json', 'id_rsa', 'id_ed25519', 'credentials', 'credentials.json'} for rel in paths: parts = PurePosixPath(rel).parts require(not any(p.startswith('.') or p in private_names or p in ('node_modules', '__pycache__') for p in parts), f'Private/workspace path is not publishable: {rel}') data = safe_file(root, rel).read_bytes() require(not any(re.search(pattern, data) for pattern in credential_patterns), f'Credential-shaped content detected in {rel}; inspect without printing it') def scaffold(args): destination = args.destination.expanduser().absolute() require(not destination.is_symlink(), 'Destination must not be a symlink') require(not destination.exists() or (destination.is_dir() and not any(destination.iterdir())), 'Destination exists and is not an empty directory') vendor = args.vendor_dir.expanduser().resolve() for filename in VENDOR: safe_file(vendor, filename) # This template uses the r147 globals/encoding APIs; never silently accept a mismatched core. core = (vendor / 'three.min.js').read_text(encoding='utf-8') require(bool(re.search(r'[\'"]147[\'"]', core)), 'Starter requires a Three.js r147-compatible vendor bundle') template = Path(__file__).resolve().parent.parent / 'assets/starter' for source in template.rglob('*'): require(not source.is_symlink(), 'Unexpected symlink inside starter') destination.mkdir(parents=True, exist_ok=True) shutil.copytree(template, destination, dirs_exist_ok=True) (destination / 'vendor').mkdir() for filename in VENDOR: shutil.copyfile(vendor / filename, destination / 'vendor' / filename) spec = read_spec(destination) spec['title'] = args.title (destination / 'process-data.js').write_text('window.PROCESS_MANIFEST = '+json.dumps(spec, ensure_ascii=False, indent=2)+';\n', encoding='utf-8') paths = sorted(p.relative_to(destination).as_posix() for p in destination.rglob('*') if p.is_file()) (destination / 'publish.json').write_text(json.dumps(paths, indent=2)+'\n', encoding='utf-8') validate_spec(spec) check_resources(destination, paths) print(json.dumps({'created': str(destination), 'files': len(paths), 'entry': (destination/'index.html').as_uri()}, ensure_ascii=False)) def validate(args): root = args.root.expanduser().resolve() spec = {} if args.resources_only else validate_spec(read_spec(root)) paths = publish_paths(root, args.manifest) resources = check_resources(root, paths, args.node_check) check_publication(root, paths) print(json.dumps({'structure': 'passed', 'scope': 'resources-only' if args.resources_only else 'process-and-resources', **spec, **resources, 'limits': 'Scientific accuracy, visual quality and browser interaction require separate review.'}, ensure_ascii=False)) def package(args): root = args.root.expanduser().resolve() paths = publish_paths(root, args.manifest) output = args.output.expanduser().absolute() require(not output.exists() and not output.is_symlink(), 'Output already exists; choose a new archive path') require(not output.resolve().is_relative_to(root), 'Archive must be outside the site root') check_resources(root, paths) check_publication(root, paths) output.parent.mkdir(parents=True, exist_ok=True) try: with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive: for rel in sorted(paths): archive.write(safe_file(root, rel), rel) except BaseException: output.unlink(missing_ok=True) raise print(json.dumps({'archive': str(output), 'files': len(paths), 'bytes': output.stat().st_size, 'sha256': hashlib.sha256(output.read_bytes()).hexdigest()})) def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest='command', required=True) create = sub.add_parser('scaffold', help='Create an isolated runnable example with explicit local dependencies') create.add_argument('destination', type=Path) create.add_argument('--title', required=True) create.add_argument('--vendor-dir', required=True, type=Path) create.set_defaults(handler=scaffold) check = sub.add_parser('validate', help='Check the template process contract, static resources and JS syntax') check.add_argument('root', type=Path) check.add_argument('--manifest', default='publish.json') check.add_argument('--node-check', action='store_true') check.add_argument('--resources-only', action='store_true', help='Check an existing project without requiring the starter process-data.js contract') check.set_defaults(handler=validate) pack = sub.add_parser('package', help='Build an explicit allowlisted ZIP; never deploys') pack.add_argument('root', type=Path) pack.add_argument('--manifest', required=True) pack.add_argument('--output', type=Path, required=True) pack.set_defaults(handler=package) args = parser.parse_args() try: args.handler(args) except (CheckError, OSError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: parser.exit(1, f'ERROR: {exc}\n') if __name__ == '__main__': main()