name: jsexec description: Use whenever you need to RUN Synchronet JavaScript — executing inline JS expressions, running scripts from the install's exec/ directory, testing JS modules, or validating changes to .js/.ssjs/.xjs files against a live install. This skill covers the jsexec runner itself: invocation modes, flags, output capture, crash tracing, and (on Windows) running a freshly-built debug binary. Trigger on "run a Synchronet script", "test this JS against Synchronet", "check what jsexec does", or any one-off JavaScript probe of a live install. For the JavaScript LANGUAGE and host API (how MsgBase/User/FileBase behave, SpiderMonkey dialect, the object model, writing tests, stock exec/*.js) see the javascript skill.
Synchronet jsexec (the JavaScript runner)
jsexec is the standalone runner for Synchronet's JavaScript engine
(SpiderMonkey 1.8.5). It loads the full Synchronet runtime — config, message
bases, file bases, sockets, the system/server/js globals — without
requiring a BBS session. It's the fastest way to run a script, probe an object,
or validate a code change against a real install.
This skill is about driving jsexec. For the JavaScript dialect, the host
object model, and how the APIs behave (MsgBase, User, FileBase, …), see the
javascript skill. For low-level SMB file repair use
smbutils; to build a debug binary use synchronet-build.
References:
- Official jsexec docs: https://wiki.synchro.net/util:jsexec
- JS objects available in jsexec: https://nix.synchro.net/jsexecobjs.html
Locating the install
Path conventions here are relative to the Synchronet install root. Use the
$SBBSCTRL environment variable to find a live install — it points to the
ctrl/ subdirectory, and the install root is its parent:
SBBS="$(dirname "$SBBSCTRL")" # install root
"<sbbs>/exec/jsexec" ... # binary
"<sbbs>/exec/foo.js" # a stock module
"<sbbs>/data/logs/" # log files land here
On a host where <sbbs>/exec isn't on PATH, prepend "<sbbs>/" or cd "<sbbs>"
first. To target a different install than $SBBSCTRL points at, use
-c <other-ctrl-dir>. (On Windows with a freshly-built debug binary there's an
extra wrinkle — see "Windows / debug-build invocation" below.)
Two invocation modes
Inline expression (-r)
Use -r for one-shot expressions. The flag is -r, not -e.
jsexec -r "print(system.version_notice);"
jsexec -r "var u = new User(1); print(u.alias);"
The expression runs as a single statement-list in the JS global scope. Multi-statement code works — separate with semicolons. For anything beyond a few lines, write a temp script instead; quoting hell is not worth it.
Script file
jsexec path/to/script.js [args...]
jsexec -c "$SBBSCTRL" exec/foo.js
A bare module[.js] resolves to the Synchronet exec directory (<sbbs>/exec);
the .js extension is appended if omitted. To run a script elsewhere, pass an
absolute path or one containing /. Extra positional args are exposed to the
script as argv. The -i flag affects only load() lookups inside the script,
not how the script argument itself is resolved.
Flags worth remembering
| Flag | Use when |
|---|---|
-r <expr> |
inline JS — primary mode for probes |
-c <dir> |
point at a non-default CTRL dir (e.g. a test install's ctrl/) |
-U |
tolerate config load failures (probing a half-installed system) |
-C |
don't chdir into CTRL dir — keep relative paths working |
-q |
send all stdout (including print()) to /dev/null — rarely what you want |
-n |
suppress status messages |
-A |
merge stderr (status, log() output) into stdout — single capture stream |
-L <n> |
log level (default 6 = info; 4 = warning; 7 = debug) |
-D |
load into the interactive JS debugger |
-v |
print version + build info (and build date — handy for confirming a debug build) |
For clean, parseable output from an inline probe, use -n and print() what
you want. Do not add -q — it redirects all stdout (including print()) to
/dev/null, so you'll see nothing.
jsexec -n -r "print(JSON.stringify({v: system.version, nodes: system.node_list.length}));"
What the jsexec environment provides (vs a BBS session)
jsexec runs outside a user session, so the global surface differs from what
scripts see under bbs.exec() / a logon flow. The classes themselves behave
identically; what changes is which globals exist. (Full class model: the
javascript skill and https://nix.synchro.net/jsexecobjs.html.)
Available under jsexec: system, js, server, conio; msg_area,
file_area, xtrn_area; User, MsgBase, FileBase, File, Archive,
Queue; Socket & friends, MQTT, COM; the Crypt* classes; and uifc
(jsexec-only full-screen UI).
NOT available under jsexec (session/terminal-bound — referencing them throws
ReferenceError):
bbs— the per-user session object (menus, prompts, user-state mutators)console— the user's terminal I/O (console.print,console.getkey, …); use plainprint()/writeln()/log()insteadclient— the connecting client's socket/identityuser— the current session's logged-on user (lowercase, distinct from theUserconstructor class which IS available). In the C++ codebase the same object is calleduseron; the JS binding renamed it touser. Inside a BBS session this is the caller; under jsexec there's no session so the global is undefined.
Porting a BBS script to a jsexec probe: replace console.print(x) with
print(x) and strip any bbs.* interactivity. (Which output/input function maps
to which context is tabulated in the javascript skill.)
Checking access against a specific user under jsexec
A common need in indexing / batch scripts is: would user X be able to read
sub Y / download dir Z? The natural-looking accessors sub.can_read,
dir.can_access, dir.can_download evaluate against the current session
user — and under jsexec there isn't one, so they return true for
everything (no restrictions to compare against). Likewise user = new User(N) doesn't override the implicit session user — the global isn't
writable that way.
The reliable pattern is to instantiate a User and call its compare_ars
method directly against the ARS strings you find on the target object.
Walk the ownership chain because access is conjunctive (group ARS AND sub
ARS AND read-specific ARS, etc.):
var u = new User(4); // pick the user whose perms you want to check
function user_can_read_sub(u, sub) {
var grp = msg_area.grp_list[sub.grp_index];
return u.compare_ars(grp.ars || '')
&& u.compare_ars(sub.ars || '')
&& u.compare_ars(sub.read_ars || '');
}
function user_can_download_dir(u, dir) {
var lib = file_area.lib_list[dir.lib_index];
return u.compare_ars(lib.ars || '')
&& u.compare_ars(dir.ars || '')
&& u.compare_ars(dir.download_ars || '');
}
Note: it's u.compare_ars(...) on the User object itself, NOT
u.security.compare_ars(...) — security holds the level / flags / etc.
fields but the evaluator hangs off the User.
A minimal probe
jsexec -n -r '
var mb = new MsgBase("mail");
if (!mb.open()) { print("open failed: " + mb.last_error); exit(1); }
var hdrs = mb.get_all_msg_headers();
for (var i in hdrs) { var h = hdrs[i]; print(h.number + " " + h.from + " -> " + h.to); }
mb.close();
'
For the API details these probes exercise — and the MsgBase
get_all_msg_headers() lazy-field gotcha (to_ext/from_ext/etc. read
undefined unless you touch a non-NULL field first) — see the
javascript skill before writing anything that filters headers.
Debugging crashes / step-tagged tracing
When a script crashes (SIGSEGV, abort, hang) and you need to find which call did
it, interleave log("step N: <what>") between candidate calls and run with -A:
jsexec -A -r '
log("step1: ctor"); var mb = new MsgBase(path, true);
log("step2: open"); mb.open();
log("step3: save"); mb.save_msg({to:"x", to_ext:"1", from:"y", subject:"z"}, "body");
log("step4: close"); mb.close();
log("step5: done");
'
The last step N you see is the call before the crash; the next call is the
culprit. Use log() not print(): print() is stdout (line-buffered; a crash
can swallow the last line), while log() goes to stderr as a logged event and
flushes more aggressively, so it survives crashes that swallow print(). -A
merges stderr into stdout so you see log() inline.
Once narrowed to a call, switch to a C-side debugger or printf-trace in the
.cpp after a single-file rebuild (see synchronet-build). Per the project's
rules, a crash in any Synchronet binary is a real defect to root-cause, not work
around.
When to use jsexec vs. other tools
- Run JS against the live config / message bases / users → jsexec. It's the only tool that gives you the real runtime.
- Manipulate message-base files at the storage layer →
smbutil/chksmb/fixsmb(smbutils). Lower level, no JS. - Just syntax-check a script →
jsexec -r 'load("path/to/file.js");'compile-checks via load, but a real run is more informative.
jsexec runtime constraints
- Default working directory after launch is the CTRL dir (
$SBBSCTRL) unless-Cis passed; relative paths resolve from there. - Default CTRL dir is compile-time (typically what
$SBBSCTRLpoints at); use-c <ctrl-dir>to target a different install. - Heap defaults to 16 MB (
-m) and time limit to 10 days (-t); adjust if a probe needs it. - jsexec writes log messages to stderr, not to
<sbbs>/data/logs/. Capture with2>file, merge into stdout with-A, or send to a file with-e <file>. - The engine is SpiderMonkey 1.8.5 (ES3-ish). Language do's and don'ts are in
the
javascriptskill.
stdout / stderr / stdin are global File instances (with a gotcha)
jsexec exposes the three standard streams as global File objects: stdin,
stdout, stderr. They're created at startup via js_CreateFileObject() in
src/sbbs3/jsexec.cpp:940-952 and have the full File method surface —
including .flush() (wraps fflush() on the underlying FILE*).
stdout.writeln("hi"); // write via the File's FILE* buffer
stderr.writeln("warn"); // bypasses Synchronet's log() formatter
stdout.flush(); // fflush() the File's FILE*
Why this matters: print() is block-buffered when stdout is redirected.
When stdout goes to a TTY, C stdio uses line buffering and you see output
as it's emitted. When stdout is redirected to a file or pipe (`jsexec foo.js
out.log
), C stdio switches to *block* buffering (~4KB chunks), andprint()calls accumulate in the buffer until either the buffer fills or the process exits. A long-running script can look completely stalled intail -f` even though it's making progress.
Gotcha: stdout.flush() does NOT flush print()
The JS stdout File object wraps a separately dup()'d FILE*, not libc's
stdout FILE*. From js_CreateFileObject() in js_file.cpp:
int newfd = dup(fd); // duplicate the fd
fp = fdopen(newfd, mode); // open a NEW FILE* on the duped fd
p->fp = fp; // File object's private FILE*
So print() writes through libc's stdout FILE* (one buffer), and
stdout.writeln() / stdout.flush() operate on the JS File's FILE*
(a different buffer that just happens to point at the same fd). Calling
stdout.flush() after print() flushes nothing useful — print()'s
data is still sitting in libc's buffer.
To make output watchable via tail -f, route ALL output through
stdout.writeln() and flush the same File:
/* Make `print` route to the JS File so flushes are effective. */
print = function (s) { stdout.writeln(String(s == null ? '' : s)); };
for (var i = 0; i < cases.length; i++) {
run_one_case(cases[i]);
print('---- case ' + i + ' done ----');
stdout.flush(); // NOW tail -f sees it
}
Don't mix print() and stdout.writeln() in the same script if you care
about real-time visibility — output from each goes into a different buffer
with no ordering guarantee in the receiving file. Replace print globally
(as above) and you're consistent.
log(LOG_INFO, msg) is an alternative for crash-survivable trace output —
it goes to stderr via Synchronet's log facility which flushes more
aggressively per message. Side effects: log() output is formatted
(timestamp prefix, log-level tag) and goes to stderr (capture with -A or
-e <file>).
Interactive input: use prompt(), not a raw stdin read
For a script that reads lines typed at an interactive terminal, use the
global prompt(label) — NOT stdin.readln() / new File('/dev/stdin').
jsexec puts the controlling terminal into raw / no-echo mode for its own
console handling, so a raw stdin read consumes keystrokes without echoing
them and without line-editing — the user ends up typing blind. prompt()
manages the terminal itself (echo + line edit), prints label followed by
": ", and returns:
- the entered string,
""on a blank line,nullon EOF (Ctrl-D) — use that to terminate a read loop.
var line;
while ((line = prompt('query')) !== null) { // echoes; null on Ctrl-D
line = line.replace(/^\s+|\s+$/g, '');
if (!line) continue; // blank line
if (line === '/quit') break;
handle(line);
}
This only matters for a real TTY. Reading piped / redirected input
(echo … | jsexec foo.js, jsexec foo.js < input) has no terminal and no
echo concern, so reading lines off the stdin File is fine there.
Pitfalls
-eis not the inline-expression flag — that's-r.-e<filename>means "send error messages to file."- Quoting: bash single-quotes beat escaping double-quotes inside
-r. If the expression itself contains single quotes, write a temp.js. - An inline
exit(N)returns N as the process exit status — handy for pipelines. - Running as a non-
sbbsuser can create log/data files with wrong ownership; prefersudo -u sbbs jsexec ...or stick to read-only probes.
Windows / debug-build invocation
On Windows, the installed jsexec.exe (under <install>\exec\, typically
a release build that shipped with the install) is frequently stale relative
to your source tree. For development/testing, prefer the freshly-built binary
under <sbbs-src>\src\sbbs3\msvc.win32.exe.<config>\ over the installed one
when you're validating recent source changes.
MSBuild puts its outputs in per-config sibling directories under src/sbbs3/:
<sbbs-src>\src\sbbs3\msvc.win32.exe.debug\jsexec.exe(and the other utility exes)<sbbs-src>\src\sbbs3\msvc.win32.dll.debug\sbbs.dll(the runtime — where theMsgBase/User/etc. bindings live)- Same layout for the
.releaseconfigurations.
These are not copied into <install>\exec\. The key gotcha: Windows' DLL
search order checks the current directory before PATH, so to load the
freshly-built sbbs.dll the dll output dir must be the cwd (or on PATH) —
otherwise jsexec.exe falls through to whichever sbbs.dll is on PATH or
under the install, and your C++ changes have no effect. Run the exe by
absolute path from the dll dir, in a single command (the shell may reset
cwd between calls):
# adjust drive letters and paths to your actual layout
cd <sbbs-src>/src/sbbs3/msvc.win32.dll.debug && \
<sbbs-src>/src/sbbs3/msvc.win32.exe.debug/jsexec.exe \
-c <install>/ctrl/ -n -r 'print(system.version + system.revision);'
Confirm which DLL you loaded — print the build banner/date, or md5sum sbbs.dll
in the cwd and compare against msvc.win32.dll.debug\sbbs.dll. If results look
wrong (e.g. a recent C++ fix seems absent), suspect a stale/PATH sbbs.dll
before doubting the code. (A debug sbbs.dll is locked while loaded — see the
next section.)
When the live BBS is running the build you want to rebuild
A common situation: the live BBS on the same machine is running the debug
build (or whichever build you're trying to rebuild), and you want to
instrument that same binary or test a candidate C++ fix. The Windows file
system locks sbbs.dll and the running exes while the BBS holds them open —
link fails with LNK1104 / a sharing violation — so you can't overwrite
them in place. Options, in increasing disruptiveness:
Switch to the other configuration. If the live BBS is on debug, build and run the release variant instead, and vice versa. Both layouts live side-by-side under
src/sbbs3/msvc.win32.{exe,dll}.{debug,release}\— pick the one that's not currently loaded by the live server:cd <sbbs-src>/src/sbbs3/msvc.win32.dll.release && \ <sbbs-src>/src/sbbs3/msvc.win32.exe.release/jsexec.exe \ -c <install>/ctrl/ -r '...'Build release with
build.bat /p:Configuration=Release(orrelease.bat) — seesynchronet-build. This is the right default when your probe is read-only and you don't need debug symbols on the running BBS's binary.Build in an isolated git worktree. Same source revision (or a variant), but its own
msvc.win32.*output dirs that the running BBS doesn't hold open. Seesynchronet-buildfor the worktree recipe.Stop the BBS briefly, rebuild, restart. Last resort — affects users. For coordinated downtime see
control(ctrl/recycle/ctrl/shutdownsemfiles, MQTThost/+/pausefor graceful drains).
Don't retry-loop a debug rebuild against the lock, and don't reach for stopping the live BBS just to compile.
total_text (X) != TOTAL_TEXT (Y) at startup
This means the running sbbs.dll and the calling exe (or the install's
ctrl/text.dat) were built from different text.h / text_defaults.c.
Either rebuild both halves from the same tree, or point -c at a ctrl/
whose text.dat matches the binary's compile-time TOTAL_TEXT. -U does
not reliably mask this check. See synchronet-build.
Other Windows notes
- Binary name from Git Bash / POSIX shells: invoke
jsexec.exe, not barejsexec(same forsmbutil.exe,chksmb.exe, …). - Path separators: forward slashes work in arguments —
-c <install>/ctrl/is fine on Windows.