The Resi -> ProPresenter bridge, its installer and site kit, and the Companion module resi-studio-cues, as running at North Attleboro. Bridge (resi_to_propresenter.py) - Follows a Resi decoder's playhead and keeps ProPresenter's NextCue timer counting down to the next cue; optional cue name as a stage message, sent once per cue (ProPresenter flashes it on every send) and off by default. - Playhead estimate no longer learns a cadence from the partial first gap after a start, so a 30s Resi doesn't read as paused half the time. - Backs off on 429, holds 2 min before retrying a failed startup login, and logs Resi's actual reason (wrong password, USER_LOCKED, rate limit). - --setup asks for the login, decoder, ProPresenter and timer. install_bridge.sh installs it as a login service (outside ~/Documents, which launchd may not read); make_kit.sh builds the kit for another site. Companion module resi-studio-cues 0.1.4 (source/, package committed) - Same playhead fix; login and request hold-off (15s doubling to 5 min); a 401/403 on a fresh session is a permission, not a reason to sign in again; init never fails on a Resi problem, so Companion can't restart it into a login loop. Tests: test_playhead.py, test_stage_message.py, test_rate_limit.py and source/backoff_test.js run offline; test_end_to_end.py drives a real ProPresenter and refuses to run without an explicit flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
4.7 KiB
Python
117 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Walk every .js in a package and check that each require()/import target resolves,
|
|
following Node's resolution rules against the vendored node_modules tree."""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.abspath(sys.argv[1])
|
|
BUILTIN = set("""assert async_hooks buffer child_process cluster console constants crypto dgram
|
|
diagnostics_channel dns domain events fs http http2 https inspector module net os path
|
|
perf_hooks process punycode querystring readline repl stream string_decoder sys timers tls
|
|
trace_events tty url util v8 vm wasi worker_threads zlib""".split())
|
|
|
|
SPEC = re.compile(r"""(?:require\(|(?:^|[\s;{}])(?:import|export)[^;\n]*?\bfrom\s*|import\()\s*['"]([^'"]+)['"]""", re.M)
|
|
DYNAMIC = re.compile(r"require\(\s*[^'\")\s]")
|
|
COMMENTS = re.compile(r"/\*.*?\*/|^\s*//.*$", re.S | re.M)
|
|
# Optional at runtime: loaded only under a config or platform this module never uses.
|
|
OPTIONAL = {"re2", "expo-random", "tinybench", "uri-js", "neostandard"}
|
|
SKIP_DIRS = ("test", "benchmark", "benchmarks", "example", "examples", ".github")
|
|
|
|
|
|
def load_json(p):
|
|
try:
|
|
with open(p) as fh:
|
|
return json.load(fh)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def resolve_file(base):
|
|
for cand in (base, base + ".js", base + ".json", base + ".cjs", base + ".mjs", base + ".node"):
|
|
if os.path.isfile(cand):
|
|
return cand
|
|
if os.path.isdir(base):
|
|
pj = load_json(os.path.join(base, "package.json"))
|
|
for key in ("main", "module"):
|
|
if isinstance(pj.get(key), str):
|
|
hit = resolve_file(os.path.join(base, pj[key]))
|
|
if hit:
|
|
return hit
|
|
if pj.get("exports"):
|
|
return base # exports map: trust it, too complex to model here
|
|
for idx in ("index.js", "index.json", "index.cjs", "index.node"):
|
|
if os.path.isfile(os.path.join(base, idx)):
|
|
return os.path.join(base, idx)
|
|
return None
|
|
|
|
|
|
def resolve_bare(spec, from_dir):
|
|
d = from_dir
|
|
while True:
|
|
hit = resolve_file(os.path.join(d, "node_modules", *spec.split("/")))
|
|
if hit:
|
|
return hit
|
|
# scoped or deep import into a package that exists
|
|
parts = spec.split("/")
|
|
pkg = "/".join(parts[:2]) if spec.startswith("@") else parts[0]
|
|
pkg_dir = os.path.join(d, "node_modules", *pkg.split("/"))
|
|
if os.path.isdir(pkg_dir) and len(parts) > (2 if spec.startswith("@") else 1):
|
|
sub = resolve_file(os.path.join(pkg_dir, *parts[(2 if spec.startswith("@") else 1):]))
|
|
if sub:
|
|
return sub
|
|
if load_json(os.path.join(pkg_dir, "package.json")).get("exports"):
|
|
return pkg_dir
|
|
if os.path.dirname(d) == d or len(d) <= len(ROOT) - 1:
|
|
return None
|
|
d = os.path.dirname(d)
|
|
|
|
|
|
missing, dynamic, files, optional_hits = [], [], 0, set()
|
|
for dirpath, dirnames, filenames in os.walk(ROOT):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
if dirpath.endswith((os.sep + "browser", os.sep + "types")):
|
|
continue
|
|
for fn in filenames:
|
|
if not fn.endswith((".js", ".cjs", ".mjs")):
|
|
continue
|
|
if fn.endswith((".native.js", ".browser.js")) or fn.startswith("eslint.config"):
|
|
continue
|
|
path = os.path.join(dirpath, fn)
|
|
files += 1
|
|
try:
|
|
src = COMMENTS.sub("", open(path, encoding="utf-8", errors="replace").read())
|
|
except OSError:
|
|
continue
|
|
if DYNAMIC.search(src):
|
|
dynamic.append(os.path.relpath(path, ROOT))
|
|
for spec in set(SPEC.findall(src)):
|
|
if spec.startswith("node:") or spec.split("/")[0] in BUILTIN:
|
|
continue
|
|
if spec in OPTIONAL:
|
|
optional_hits.add((os.path.relpath(path, ROOT), spec))
|
|
continue
|
|
if spec.startswith("."):
|
|
hit = resolve_file(os.path.normpath(os.path.join(dirpath, spec)))
|
|
else:
|
|
hit = resolve_bare(spec, dirpath)
|
|
if not hit:
|
|
missing.append((os.path.relpath(path, ROOT), spec))
|
|
|
|
print("scanned %d js files" % files)
|
|
if dynamic:
|
|
print("\n%d file(s) use a computed require() (not statically checkable):" % len(dynamic))
|
|
for d in sorted(dynamic)[:12]:
|
|
print(" ", d)
|
|
if optional_hits:
|
|
print("\n%d optional dep(s), not loaded on this platform/config:" % len(optional_hits))
|
|
for f, spec in sorted(optional_hits):
|
|
print(" %-64s -> %s" % (f, spec))
|
|
if missing:
|
|
print("\nUNRESOLVED (%d):" % len(missing))
|
|
for f, s in sorted(set(missing)):
|
|
print(" %-70s -> %s" % (f, s))
|
|
sys.exit(1)
|
|
print("\nevery static require/import resolves")
|