aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/course-daily-update.py138
-rw-r--r--scripts/course-insights.py57
-rw-r--r--scripts/course-validate.py95
-rw-r--r--scripts/gen-course-atlas.py113
-rw-r--r--scripts/newos4-maintain.py118
-rw-r--r--scripts/test-course.mjs59
6 files changed, 580 insertions, 0 deletions
diff --git a/scripts/course-daily-update.py b/scripts/course-daily-update.py
new file mode 100644
index 0000000..4f509d6
--- /dev/null
+++ b/scripts/course-daily-update.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""Daily newOS4 course expansion. Grounded only: every command must exist in atlas.json."""
+import json, os, re, sys, subprocess
+from datetime import datetime, timezone
+import requests
+
+ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
+COURSE = os.path.join(ROOT, 'course')
+ATLAS, FEED, FAQ = (os.path.join(COURSE, n) for n in ('atlas.json', 'feed.json', 'faq.json'))
+STATE = os.path.join(COURSE, '_state.json'); CHANGELOG = os.path.join(COURSE, 'changelog.jsonl')
+PENDING = os.path.join(COURSE, 'pending')
+SEARCH = 'https://webx11.duckdns.org/api/search'
+OLLAMA = 'http://127.0.0.1:11434/v1/chat/completions'
+MODEL = 'deepseek-v4.1-flash:cloud'
+TOPICS = ['terminal UX best practices command design', 'browser virtual filesystem design',
+ 'module lazy loading cache busting web apps', 'Ollama local LLM tool calling best practices',
+ 'WebDAV client sync reliability', 'developer onboarding interactive tutorial design',
+ 'AI agent tool safety confirmation patterns', 'cron automation in single page apps',
+ 'privacy preserving product analytics', 'prompt engineering grounded answers no hallucination',
+ 'web terminal accessibility keyboard UX', 'self-hosted AI assistant cost control',
+ 'spaced repetition micro learning programming', 'secure API key handling in browser apps']
+SYS_DRAFT = ('You write ONE micro-lesson for the newOS4 terminal course. Return STRICT JSON only (no fences):\n'
+ '{"title":"...","minutes":4,"blocks":[{"type":"text|code|cmd|tip|warn","text":"..."}],'
+ '"exercise":{"instruction":"...","accept":["cmd ..."],"check":{"type":"manual"},"hint":"..."},"sources":["https://..."]}\n'
+ 'Rules: 3-5 blocks; at least one cmd block; every command MUST appear verbatim in the ALLOWED list; '
+ 'only state facts present in the research snippets; sources must be URLs from the snippets.')
+
+def load(p, d):
+ try:
+ with open(p, encoding='utf-8') as f: return json.load(f)
+ except Exception: return d
+
+def save(p, obj):
+ tmp = p + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f: json.dump(obj, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, p); os.chmod(p, 0o644)
+
+def parse_json(txt):
+ txt = (txt or '').strip()
+ if txt.startswith('`' * 3): txt = txt.split('\n', 1)[1] if '\n' in txt else ''
+ txt = txt.strip()
+ if txt.endswith('`' * 3): txt = txt[:-3].strip()
+ m = re.search(r'\{[\s\S]*\}', txt)
+ try: return json.loads(m.group(0) if m else txt)
+ except Exception: return None
+
+def llm(system, user, n=900):
+ try:
+ r = requests.post(OLLAMA, json={'model': MODEL, 'stream': False, 'temperature': 0.4, 'max_tokens': n,
+ 'messages': [{'role': 'system', 'content': system}, {'role': 'user', 'content': user}]}, timeout=180)
+ if not r.ok: return None
+ return (r.json().get('choices') or [{}])[0].get('message', {}).get('content')
+ except Exception:
+ return None
+
+def research(q):
+ try:
+ r = requests.get(SEARCH, params={'q': q}, timeout=25)
+ if not r.ok: return []
+ return [{'title': (x.get('title') or '')[:160], 'url': x['url'], 'snippet': (x.get('content') or '')[:600]}
+ for x in (r.json().get('results') or [])[:4] if x.get('url')]
+ except Exception:
+ return []
+
+def validate_item(it, known, urls):
+ if not isinstance(it, dict): return False, 'not object'
+ if not (10 <= len(it.get('title') or '') <= 90): return False, 'title'
+ blocks = it.get('blocks') or []
+ if not (3 <= len(blocks) <= 6): return False, 'block count'
+ if any(b.get('type') not in ('text', 'code', 'cmd', 'tip', 'warn', 'table') or not (b.get('text') or '').strip() for b in blocks): return False, 'block'
+ if not any(b.get('type') == 'cmd' for b in blocks): return False, 'no cmd'
+ ex = it.get('exercise') or {}
+ if not ex.get('instruction') or not ex.get('accept'): return False, 'exercise'
+ for a in ex['accept']:
+ if a.split(' ')[0] not in known: return False, 'unknown command ' + a.split(' ')[0]
+ if not any(str(s) in urls for s in (it.get('sources') or []) if str(s).startswith('http')): return False, 'no source'
+ return True, ''
+
+def main():
+ dry = '--dry-run' in sys.argv
+ now = datetime.now(timezone.utc); day = now.strftime('%Y-%m-%d')
+ state = load(STATE, {'seen_commands': [], 'topic_index': 0, 'last_run': None})
+ if not dry: subprocess.run([sys.executable, os.path.join(ROOT, 'scripts', 'gen-course-atlas.py')], check=True)
+ atlas = load(ATLAS, {}); known = set(atlas.get('commands', {}))
+ seen = set(state.get('seen_commands') or known)
+ new = sorted(known - seen) if state.get('seen_commands') else []
+ feed = load(FEED, {'version': '', 'items': []}); added = []; rejected = []
+ if new:
+ feed['items'].insert(0, {'id': 'new-' + day, 'date': day, 'title': f'{len(new)} new command(s) in newOS4', 'minutes': 2,
+ 'generated': False, 'sources': ['atlas.json'],
+ 'blocks': [{'type': 'text', 'text': 'Run `help <cmd>` for details.'}, {'type': 'cmd', 'text': '\n'.join(new[:12])}]})
+ added.append('new-' + day)
+ if not any(i.get('id') == 'daily-' + day for i in feed['items']):
+ topic = TOPICS[state.get('topic_index', 0) % len(TOPICS)]
+ results = research(topic)
+ if results:
+ snippets = '\n'.join(f"- {r['title']} | {r['url']}\n {r['snippet']}" for r in results)
+ draft = parse_json(llm(SYS_DRAFT, f'TOPIC: {topic}\n\nSNIPPETS:\n{snippets}\n\nALLOWED COMMANDS:\n' + ', '.join(sorted(known))))
+ ok, why = validate_item(draft, known, {r['url'] for r in results}) if draft else (False, 'no JSON')
+ if ok:
+ draft.update({'id': 'daily-' + day, 'date': day, 'generated': True})
+ feed['items'].insert(0, draft); added.append('daily-' + day)
+ else:
+ os.makedirs(PENDING, exist_ok=True)
+ save(os.path.join(PENDING, f'daily-{day}.json'), {'draft': draft, 'why': why, 'topic': topic, 'results': results})
+ rejected.append('daily: ' + why)
+ else:
+ rejected.append('daily: no research results')
+ state['topic_index'] = state.get('topic_index', 0) + 1
+ feed['items'] = feed['items'][:60]; feed['version'] = day
+ if not dry: save(FEED, feed)
+ if not dry: subprocess.run([sys.executable, os.path.join(ROOT, 'scripts', 'course-insights.py')], check=True)
+ insights = load(os.path.join(COURSE, 'insights.json'), {})
+ faq = load(FAQ, {'items': []}); have = {f.get('q') for f in faq['items']}; faq_n = 0
+ for q in (insights.get('top_questions') or [])[:5]:
+ if q['q'] in have: continue
+ draft = parse_json(llm('Answer a repeated newOS4 question. Return STRICT JSON only: {"a":"<=400 chars","commands":["cmd"],"sources":["atlas.json#cmd"]}. Only use ALLOWED commands.', f'QUESTION: {q["q"]}\nALLOWED COMMANDS:\n' + ', '.join(sorted(known)), 500))
+ if draft and draft.get('a') and all(c in known for c in draft.get('commands', [])):
+ faq['items'].append({'q': q['q'], 'a': draft['a'][:400], 'commands': draft['commands'],
+ 'sources': draft.get('sources') or ['insights.json'], 'added': day})
+ have.add(q['q']); faq_n += 1
+ else: rejected.append('faq: ' + q['q'][:60])
+ if not dry: save(FAQ, faq)
+ state.update({'seen_commands': sorted(known), 'last_run': now.strftime('%Y-%m-%dT%H:%M:%SZ')})
+ if not dry:
+ save(STATE, state)
+ with open(CHANGELOG, 'a', encoding='utf-8') as f:
+ f.write(json.dumps({'date': day, 'added': added, 'faq': faq_n, 'rejected': rejected,
+ 'atlas': f'{len(known)} commands'}, ensure_ascii=False) + '\n')
+ print('newOS4 Course daily — ' + day)
+ print('atlas: ' + str(len(known)) + ' commands | new: ' + str(len(new)))
+ print('daily lesson: ' + next((i['title'] for i in feed['items'] if i.get('id') == 'daily-' + day), 'none added'))
+ print('faq added: ' + str(faq_n) + ' | feed: ' + str(len(feed['items'])) + '/60')
+ print('rejected: ' + ('; '.join(rejected) if rejected else 'none'))
+ print('needs OK: none')
+
+if __name__ == '__main__':
+ sys.exit(main() or 0)
diff --git a/scripts/course-insights.py b/scripts/course-insights.py
new file mode 100644
index 0000000..0ee891e
--- /dev/null
+++ b/scripts/course-insights.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Aggregate course events -> course/insights.json (counts + capped question text, no sids/IPs)."""
+import json, os, glob, re
+from collections import Counter
+from datetime import datetime, timezone
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DATA = os.path.join(ROOT, 'data', 'course')
+OUT = os.path.join(ROOT, 'course', 'insights.json')
+
+def norm_q(q):
+ q = re.sub(r'https?://\S+', '', q or '')
+ return re.sub(r'\s+', ' ', q).strip().lower()[:200]
+
+def main():
+ opens, completes, fails, cmds, questions = Counter(), Counter(), Counter(), Counter(), Counter()
+ projects = Counter()
+ files = sorted(glob.glob(os.path.join(DATA, 'events-*.jsonl')))[-2:]
+ for path in files:
+ with open(path, encoding='utf-8', errors='ignore') as f:
+ for line in f:
+ try: e = json.loads(line)
+ except Exception: continue
+ t, d = e.get('type'), e.get('detail') or {}
+ lesson = (d.get('lesson') or '')[:40]
+ if t == 'lesson_open' and lesson: opens[lesson] += 1
+ elif t == 'lesson_complete' and lesson: completes[lesson] += 1
+ elif t == 'exercise_fail':
+ if lesson: fails[lesson] += 1
+ c = (d.get('command') or '').strip().split(' ')[0][:40]
+ if c: cmds[c] += 1
+ elif t == 'question':
+ q = norm_q(d.get('q'))
+ if len(q) >= 8: questions[q] += 1
+ elif t in ('project_open', 'project_view'):
+ p = (d.get('project') or '')[:40]
+ if p: projects[p] += 1
+ lessons = {}
+ for lid in set(opens) | set(completes):
+ o, c = opens[lid], completes[lid]
+ lessons[lid] = {'opens': o, 'completes': c, 'failures': fails.get(lid, 0),
+ 'completion_rate': round(c / o, 2) if o else 0.0}
+ payload = {'generated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'window_files': [os.path.basename(p) for p in files],
+ 'lessons': dict(sorted(lessons.items())),
+ 'top_failed_commands': [{'cmd': c, 'n': n} for c, n in cmds.most_common(20)],
+ 'top_questions': [{'q': q, 'n': n} for q, n in questions.most_common(20) if n >= 2],
+ 'projects': dict(projects),
+ 'totals': {'opens': sum(opens.values()), 'completes': sum(completes.values()), 'failures': sum(fails.values())}}
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
+ tmp = OUT + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f: json.dump(payload, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, OUT); os.chmod(OUT, 0o644)
+ print(json.dumps({'lessons': len(lessons), 'questions': len(payload['top_questions']), 'totals': payload['totals']}))
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/course-validate.py b/scripts/course-validate.py
new file mode 100644
index 0000000..9b5dcf4
--- /dev/null
+++ b/scripts/course-validate.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Validate newOS4 course content. Exit 0 = publishable, 1 = errors printed."""
+import json, os, glob, sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+COURSE = os.path.join(ROOT, 'course')
+ATLAS = os.path.join(COURSE, 'atlas.json')
+errors, warnings = [], []
+
+def load(p):
+ try:
+ with open(p, encoding='utf-8') as f:
+ return json.load(f)
+ except Exception as e:
+ errors.append(f'{os.path.relpath(p, ROOT)}: invalid JSON: {e}')
+ return None
+
+def main():
+ manifest = load(os.path.join(COURSE, 'course.json'))
+ if not manifest:
+ return finish()
+ ids, lesson_ids = set(), set()
+ for ch in manifest.get('chapters', []):
+ cid = ch.get('id')
+ if not cid:
+ errors.append('course.json: chapter missing id'); continue
+ if cid in ids:
+ errors.append(f'course.json: duplicate chapter id {cid}')
+ ids.add(cid)
+ path = os.path.join(COURSE, 'chapters', cid + '.json')
+ if not os.path.exists(path):
+ errors.append(f'missing chapter file {cid}.json'); continue
+ chd = load(path)
+ if not chd: continue
+ man_ids = [x.get('id') for x in ch.get('lessons', [])]
+ file_ids = [x.get('id') for x in chd.get('lessons', [])]
+ if man_ids != file_ids:
+ errors.append(f'{cid}: manifest lessons {man_ids} != chapter file lessons {file_ids}')
+ for l in chd.get('lessons', []):
+ lid = l.get('id')
+ if not lid:
+ errors.append(f'{cid}: lesson missing id'); continue
+ if lid in lesson_ids:
+ errors.append(f'duplicate lesson id {lid}')
+ lesson_ids.add(lid)
+ for k in ('title', 'objective'):
+ if not l.get(k): errors.append(f'{lid}: missing {k}')
+ mins = l.get('minutes', 0)
+ if not isinstance(mins, int) or isinstance(mins, bool) or mins < 1:
+ errors.append(f'{lid}: minutes must be >=1')
+ blocks = l.get('blocks') or []
+ if len(blocks) < 3: errors.append(f'{lid}: needs >=3 blocks')
+ for b in blocks:
+ if b.get('type') not in ('text', 'code', 'cmd', 'tip', 'warn', 'table'):
+ errors.append(f'{lid}: bad block type {b.get("type")!r}')
+ ex = l.get('exercise')
+ if ex:
+ if not ex.get('instruction') or not ex.get('accept'):
+ errors.append(f'{lid}: exercise needs instruction+accept')
+ chk = ex.get('check', {})
+ if chk.get('type') not in ('command', 'file', 'output', 'manual'):
+ errors.append(f'{lid}: exercise check.type invalid')
+ if chk.get('type') == 'file' and not chk.get('path'):
+ errors.append(f'{lid}: file check requires path')
+ if chk.get('type') == 'output' and not chk.get('contains'):
+ errors.append(f'{lid}: output check requires contains')
+ if not ex.get('hint'): warnings.append(f'{lid}: exercise without hint')
+ if l.get('generated') and not l.get('sources'):
+ errors.append(f'{lid}: generated lesson needs sources')
+ if os.path.exists(ATLAS):
+ atlas = load(ATLAS) or {}
+ known = set(atlas.get('commands', {}))
+ for f in glob.glob(os.path.join(COURSE, 'chapters', '*.json')):
+ chd = load(f) or {}
+ for l in chd.get('lessons', []):
+ for c in l.get('commands', []):
+ if c not in known:
+ errors.append(f'{l.get("id")}: unknown command {c}')
+ for name, key in (('feed.json', 'items'), ('faq.json', 'items')):
+ p = os.path.join(COURSE, name)
+ if not os.path.exists(p): continue
+ d = load(p) or {}
+ for it in d.get(key, []):
+ if isinstance(it, dict) and it.get('date') is None:
+ warnings.append(f'{name}: item without date')
+ return finish()
+
+def finish():
+ for w in warnings: print(f'WARN {w}')
+ for e in errors: print(f'ERROR {e}')
+ print(f'course-validate: {len(errors)} error(s), {len(warnings)} warning(s)')
+ return 1 if errors else 0
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/gen-course-atlas.py b/scripts/gen-course-atlas.py
new file mode 100644
index 0000000..559fd4c
--- /dev/null
+++ b/scripts/gen-course-atlas.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Build course/atlas.json from newOS4 source. Ground truth for every lesson claim."""
+import json, os, re, glob, hashlib
+from datetime import datetime, timezone
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+OUT = os.path.join(ROOT, 'course', 'atlas.json')
+CORE_DIR, EXT_DIR = os.path.join(ROOT, 'modules', 'core'), os.path.join(ROOT, 'modules', 'ext')
+SKIP = {'core-bundle.js', 'critical-ext-bundle.js', 'sandbox-worker.js'}
+
+CATEGORY = [
+ ('module-fs', 'filesystem'), ('virtual-fs', 'filesystem'), ('module-aiassist', 'ai'), ('module-ollama', 'ai'),
+ ('module-memory-graph', 'ai'), ('module-smartctx', 'ai'), ('module-neural-vfs', 'ai'), ('module-world-model', 'ai'),
+ ('module-sandbox', 'ai'), ('module-searxng', 'web'), ('module-fetcher', 'web'), ('webdav', 'cloud'),
+ ('editor', 'editor'), ('filemgr', 'filesystem'), ('finder', 'filesystem'), ('diff', 'editor'),
+ ('cron', 'automation'), ('kanban', 'automation'), ('shell', 'shell'), ('busybox', 'shell'),
+ ('theme', 'ui'), ('floating', 'ui'), ('voice', 'ui'), ('three', 'ui'), ('console', 'dev'), ('debug-panel', 'dev'),
+ ('wpm', 'system'), ('tutorial', 'learning'), ('skill-pack-core', 'ai'), ('cryptogzip', 'system'),
+ ('module-agent-registry', 'agents'), ('module-orchestrator', 'agents'), ('module-leases', 'agents'),
+ ('module-chatstudio', 'ai'), ('module-servermon', 'system'), ('module-config', 'system'), ('module-system', 'system'),
+ ('course', 'learning'),
+]
+
+def category_for(filename, cmd):
+ for frag, cat in CATEGORY:
+ if frag in filename: return cat
+ if cmd.startswith('webdav'): return 'cloud'
+ if cmd.startswith('cron') or cmd.startswith('kanban'): return 'automation'
+ if cmd.startswith('ai-') or cmd in ('ai', 'ask'): return 'ai'
+ return 'general'
+
+def tier_lists():
+ rebuild = open(os.path.join(ROOT, 'scripts', 'rebuild-bundles.py')).read()
+ def listed(name):
+ m = re.search(name + r"\s*=\s*\[([^\]]+)\]", rebuild, re.S)
+ return set(re.findall(r"([a-z0-9-]+\.js)", m.group(1))) if m else set()
+ loader = open(os.path.join(ROOT, 'modules', 'core', 'module-loader.js')).read()
+ m = re.search(r'extKeys\s*=\s*\[([^\]]+)\]', loader)
+ ext = set(re.findall(r"'([a-z0-9-]+)'", m.group(1))) if m else set()
+ return listed('CORE_FILES'), listed('CRITICAL_EXT_FILES'), ext
+
+CORE_FILES, CRITICAL_EXT_FILES, EXT_KEYS = tier_lists()
+
+def module_tier(filename):
+ base = os.path.basename(filename)
+ if base in CRITICAL_EXT_FILES: return 'critical-ext-bundle'
+ if base in CORE_FILES: return 'core-bundle'
+ if base[:-3] in EXT_KEYS: return 'ext-auto'
+ return 'ext-manual'
+
+def desc_map(text):
+ out = {}
+ for m in re.finditer(r"'([a-z0-9][a-z0-9_-]{1,40})'\s*:\s*'((?:[^'\\]|\\.){8,300})'", text):
+ cmd, desc = m.group(1), m.group(2)
+ if len(desc) < 10 or ' ' not in desc or desc.endswith('.js'):
+ continue
+ out.setdefault(cmd, desc.replace("\\'", "'"))
+ return out
+
+def main():
+ commands, modules = {}, {}
+ files = sorted(glob.glob(os.path.join(CORE_DIR, '*.js')) + glob.glob(os.path.join(EXT_DIR, '*.js')))
+ for path in files:
+ base = os.path.basename(path)
+ if base in SKIP: continue
+ text = open(path, encoding='utf-8', errors='ignore').read()
+ cmds = sorted(set(re.findall(r"commands\['([A-Za-z0-9_-]+)'\]\s*=", text)))
+ if not cmds: continue
+ modname = base[:-3]
+ modules[modname] = {'file': os.path.relpath(path, ROOT), 'tier': module_tier(path),
+ 'commands': cmds, 'lines': text.count('\n') + 1}
+ dm = desc_map(text)
+ for c in cmds:
+ commands[c] = {'module': modname, 'file': os.path.relpath(path, ROOT), 'tier': module_tier(path),
+ 'category': category_for(base, c), 'desc': dm.get(c, ''),
+ 'builtin': False, 'example': c}
+ term = open(os.path.join(ROOT, 'modules', 'core', 'terminal.js'), encoding='utf-8').read()
+ for c in sorted(set(re.findall(r"cmd === '([a-z0-9-]+)'", term))):
+ if c in commands: continue
+ commands[c] = {'module': 'terminal', 'file': 'modules/core/terminal.js', 'tier': 'core-bundle',
+ 'category': 'system', 'desc': '', 'builtin': True, 'example': c}
+ idx_path = os.path.join(ROOT, 'modules-index.json')
+ mod_by_cmd = {}
+ if os.path.exists(idx_path):
+ idx = json.load(open(idx_path))
+ for m in idx.get('modules', {}).values():
+ for c in m.get('commands', []):
+ mod_by_cmd.setdefault(c, m)
+ for c, e in commands.items():
+ if not e['desc']:
+ m = mod_by_cmd.get(c) or {}
+ e['desc'] = (m.get('description') or f'{c} — command from {e["module"]}')[:300]
+ payload = {
+ 'version': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'source_hash': hashlib.sha256(''.join(sorted(commands)).encode()).hexdigest()[:16],
+ 'commands': dict(sorted(commands.items())),
+ 'modules': dict(sorted(modules.items())),
+ 'coverage': {'commands': len(commands),
+ 'described': sum(1 for e in commands.values() if e['desc']),
+ 'modules': len(modules),
+ 'tiers': {t: sum(1 for e in commands.values() if e['tier'] == t)
+ for t in ('core-bundle', 'critical-ext-bundle', 'ext-auto', 'ext-manual')}},
+ }
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
+ tmp = OUT + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(payload, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, OUT)
+ os.chmod(OUT, 0o644)
+ print(json.dumps(payload['coverage'], ensure_ascii=False))
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/newos4-maintain.py b/scripts/newos4-maintain.py
new file mode 100644
index 0000000..6321a3f
--- /dev/null
+++ b/scripts/newos4-maintain.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""newOS4 maintenance: drift, bundles, syntax, perms, data hygiene, live health.
+Safe fixes only with --fix. Never touches nginx, services, sudo, or user content."""
+import glob, json, os, re, subprocess, sys
+from datetime import datetime, timezone
+
+ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
+WEB = 'https://webx11.duckdns.org/spaces/newos4'
+FIX = '--fix' in sys.argv
+DEEP = '--deep' in sys.argv
+issues = []
+def add(l, m): issues.append((l, m))
+def run(cmd, **kw): return subprocess.run(cmd, capture_output=True, text=True, **kw)
+def sh(cmd): return run(cmd, shell=True).stdout.strip()
+
+def check_versions():
+ html = open(os.path.join(ROOT, 'main.html'), encoding='utf-8').read()
+ loader = open(os.path.join(ROOT, 'modules/core/module-loader.js'), encoding='utf-8').read()
+ m = re.search(r"const LATEST_VER\s*=\s*'([^']+)'", loader)
+ ver = m.group(1) if m else '?'
+ n = html.count(ver)
+ add('PASS' if n == 3 else 'FAIL', f'version {ver} x{n} in main.html (expect 3)')
+
+def check_bundles():
+ paths = ('modules/core/core-bundle.js', 'modules/ext/critical-ext-bundle.js')
+ before = {p: sh(f'md5sum {p}') for p in paths}
+ if not FIX:
+ add('PASS', 'bundle check skipped (use --fix)'); return
+ r = run([sys.executable, 'scripts/rebuild-bundles.py'], cwd=ROOT)
+ after = {p: sh(f'md5sum {p}') for p in paths}
+ changed = [p for p in paths if before[p] != after[p]]
+ add('FAIL' if r.returncode else ('WARN' if changed else 'PASS'),
+ 'bundles ' + ('FAILED' if r.returncode else ('rebuilt: ' + ', '.join(changed) if changed else 'clean')))
+
+def check_syntax():
+ bad = []
+ for p in glob.glob(os.path.join(ROOT, 'modules/core/*.js')) + glob.glob(os.path.join(ROOT, 'modules/ext/*.js')):
+ if os.path.basename(p) in ('core-bundle.js', 'critical-ext-bundle.js'): continue
+ if run(['node', '--check', p]).returncode: bad.append(os.path.basename(p))
+ add('PASS' if not bad else 'FAIL', 'syntax ' + ('OK' if not bad else 'fail: ' + ', '.join(bad)))
+
+def check_perms():
+ targets = glob.glob(os.path.join(ROOT, 'course/*.json')) + glob.glob(os.path.join(ROOT, 'course/chapters/*.json')) \
+ + [os.path.join(ROOT, 'modules/ext/course.js'), os.path.join(ROOT, 'course.html'), os.path.join(ROOT, 'api/course/event.php')]
+ bad = []
+ for p in targets:
+ if not os.path.exists(p): continue
+ if (os.stat(p).st_mode & 0o777) != 0o644 or sh(f'stat -c %G {p}') != 'www-data':
+ if FIX:
+ os.chmod(p, 0o644); sh(f'chgrp www-data {p}')
+ else: bad.append(os.path.basename(p))
+ add('PASS' if not bad else ('WARN' if FIX else 'FAIL'), 'perms ' + ('clean' if not bad else ('fixed' if FIX else 'bad: ' + ', '.join(bad[:5]))))
+
+def check_index():
+ idxp = os.path.join(ROOT, 'modules-index.json')
+ atlas = json.load(open(os.path.join(ROOT, 'course/atlas.json')))
+ idx = json.load(open(idxp))
+ cur = len(set(c for m in idx.get('modules', {}).values() for c in m.get('commands', [])))
+ if cur == atlas['coverage']['commands']:
+ add('PASS', f'modules-index {cur} commands'); return
+ add('WARN', f'modules-index stale {cur} vs {atlas["coverage"]["commands"]}')
+ if not FIX: return
+ for name, m in atlas['modules'].items():
+ e = idx['modules'].get(name, {'name': name, 'file': m['file'], 'type': 'core' if m['tier'] == 'core-bundle' else 'ext',
+ 'class': '', 'auto_load': m['tier'] != 'ext-manual', 'description': ''})
+ e.update({'commands': m['commands'], 'file': m['file'], 'tier': m['tier']})
+ idx['modules'][name] = e
+ idx['version'] = datetime.now(timezone.utc).strftime('%Y-%m-%d') + '-atlas'
+ tmp = idxp + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f: json.dump(idx, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, idxp); os.chmod(idxp, 0o644); sh(f'chgrp www-data {idxp}')
+ add('PASS', 'modules-index regenerated')
+
+def check_data():
+ err = os.path.join(ROOT, 'data/debug/errors.json')
+ if os.path.exists(err) and os.path.getsize(err) > 5 * 1024 * 1024:
+ if FIX:
+ arc = os.path.join(ROOT, 'data/debug/archive'); os.makedirs(arc, exist_ok=True)
+ os.replace(err, os.path.join(arc, 'errors-' + datetime.now().strftime('%Y%m') + '.json'))
+ open(err, 'w').write('[]'); os.chmod(err, 0o664); sh(f'chgrp www-data {err}')
+ add('PASS', 'errors.json rotated')
+ else: add('WARN', 'errors.json >5MB')
+ else: add('PASS', 'debug data OK')
+
+def check_live():
+ for url in (f'{WEB}/main.html', f'{WEB}/course.html', f'{WEB}/course/course.json', f'{WEB}/modules/core/core-bundle.js'):
+ code = sh(f"curl -s -o /dev/null -w '%{{http_code}}' '{url}'")
+ add('PASS' if code == '200' else 'FAIL', f'HTTP {code} {url.replace(WEB, "")}')
+
+def check_course():
+ r = run([sys.executable, 'scripts/course-validate.py'], cwd=ROOT)
+ add('PASS' if r.returncode == 0 else 'FAIL', 'course ' + ('valid' if r.returncode == 0 else 'INVALID: ' + r.stdout[-160:]))
+
+def check_links():
+ if not DEEP: return
+ broken = []
+ for page in ('main.html', 'course.html', 'help.html', 'modules.html', 'guide.html'):
+ p = os.path.join(ROOT, page)
+ if not os.path.exists(p): continue
+ for href in re.findall(r'(?:href|src)="([^"#]+)"', open(p, encoding='utf-8').read()):
+ if any(x in href for x in ("'", '+', '{', '}')): continue
+ if href.startswith(('http', '//', 'mailto:', 'javascript:', 'data:', '/api/')): continue
+ target = os.path.normpath(os.path.join(ROOT, href.split('?')[0]))
+ if not os.path.exists(target): broken.append(f'{page}:{href}')
+ add('PASS' if not broken else 'WARN', 'links ' + ('OK' if not broken else f'{len(broken)} broken (first: {broken[0]})'))
+
+def main():
+ os.chdir(ROOT)
+ check_versions(); check_bundles(); check_syntax(); check_perms(); check_index(); check_data(); check_live(); check_course(); check_links()
+ fails = [m for l, m in issues if l == 'FAIL']
+ print('newOS4 maintenance — ' + datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') + (' [fix]' if FIX else '') + (' [deep]' if DEEP else ''))
+ for l, m in issues: print(f'{l}: {m}')
+ print('maintenance result: ' + ('FAIL' if fails else 'OK'))
+ print('needs OK: ' + ('none' if not fails else '; '.join(fails)[:400]))
+ return 1 if fails else 0
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/test-course.mjs b/scripts/test-course.mjs
new file mode 100644
index 0000000..7a64dc8
--- /dev/null
+++ b/scripts/test-course.mjs
@@ -0,0 +1,59 @@
+import fs from 'fs';
+import vm from 'vm';
+const SRC = '/var/www/webx11.duckdns.org/spaces/newos4/modules/ext/course.js';
+const MANIFEST = { version: 'test-1', updated: '2026-10-18', chapters: [
+ { id: '01-orientation', title: 'Orientation', lessons: [{ id: '1.1', title: 'T', objective: 'O', minutes: 5 }] }
+], projects: ['p1'] };
+const CHAPTER = { id: '01-orientation', title: 'Orientation', lessons: [{ id: '1.1', title: 'T', objective: 'O', minutes: 5,
+ blocks: [{ type: 'text', text: 'hello' }, { type: 'cmd', text: 'status' }, { type: 'tip', text: 'tip' }],
+ exercise: { instruction: 'touch ok.txt', accept: ['touch ok.txt'], check: { type: 'file', path: '/ok.txt' }, hint: 'touch ok.txt' } }] };
+let printed = [];
+const terminal = {
+ print(t) { printed.push(String(t)); },
+ fs: { _f: {}, readFile(p) { return Object.prototype.hasOwnProperty.call(this._f, p) ? this._f[p] : null; },
+ writeFile(p, c) { this._f[p] = c; }, mkdirRecursive() {}, fsReadJSON(p) { try { return JSON.parse(this._f[p] || 'null'); } catch (e) { return null; } },
+ fsWriteJSON(p, o) { this._f[p] = JSON.stringify(o); } },
+ _pushPrintCapture(cb) { this._cap = cb; }, _popPrintCapture() { this._cap && this._cap('output'); },
+ ollamaManager: { currentModel: 'test-model' }
+};
+const store = {};
+const sandbox = {
+ window: { webx11: { moduleManager: { registerModule() {} } } },
+ localStorage: { getItem: k => store[k] ?? null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } },
+ fetch: async (url) => {
+ if (url.includes('course.json')) return { ok: true, json: async () => MANIFEST };
+ if (url.includes('atlas.json')) return { ok: true, json: async () => ({ commands: { status: { desc: 'Status', module: 'system', tier: 'core-bundle', category: 'system' }, touch: { desc: 'Touch', module: 'fs', tier: 'core-bundle', category: 'filesystem' }, ls: { desc: 'List', module: 'fs', tier: 'core-bundle', category: 'filesystem' } } }) };
+ if (url.includes('chapters/')) return { ok: true, json: async () => CHAPTER };
+ if (url.includes('event.php')) return { ok: true, json: async () => ({}) };
+ if (url.includes('/ai/')) return { ok: true, json: async () => ({ choices: [{ message: { content: 'answer' } }] }) };
+ return { ok: false, status: 404, json: async () => ({}) };
+ },
+ console, document: { body: {} }, setTimeout, clearTimeout, Date, Math, JSON, RegExp
+};
+sandbox.globalThis = sandbox;
+sandbox.webx11 = sandbox.window.webx11;
+vm.createContext(sandbox);
+vm.runInContext(fs.readFileSync(SRC, 'utf8') + '\n;globalThis.__CourseModule = CourseModule;', sandbox);
+const mod = new sandbox.__CourseModule();
+mod.terminal = terminal;
+let pass = 0, fail = 0;
+const check = (n, c, got) => { if (c) { pass++; console.log('PASS', n); } else { fail++; console.log('FAIL', n, got || ''); } };
+const run = async () => {
+ mod.registerCommands();
+ await mod._warm();
+ await mod.handle(['list']);
+ check('list mentions Orientation', printed.join('\n').includes('Orientation'));
+ await mod.handle(['open', '1.1']);
+ check('lesson printed', printed.join('\n').includes('Exercise'));
+ mod.onCommandStart('touch', ['ok.txt']); terminal.fs.writeFile('/ok.txt', 'x'); mod.onCommandExecuted('touch', ['ok.txt']);
+ check('file exercise completes', !!mod._progress().completed['1.1'], JSON.stringify(mod._progress()));
+ check('xp awarded', mod._progress().xp >= 10);
+ check('match exact', mod._match('ls', 'ls') && !mod._match('ls', 'ls -la'));
+ check('match wildcard', mod._match('touch *', 'touch a.txt'));
+ await mod.handle(['ask', 'what is status?']);
+ check('tutor answered', printed.join('\n').includes('answer'));
+ check('skill installed', !!terminal.fs._f['/skills/course-01-orientation.md']);
+ console.log(`RESULT ${pass} passed, ${fail} failed`);
+ process.exit(fail ? 1 : 0);
+};
+run().catch(e => { console.error(e); process.exit(1); });