screentinker/scripts/build-server-zip.sh
screentinker 9a1a82a100
Run the ScreenTinker server on the player it serves (#288)
* Make a BrightSign say what it is running, and what it is plugged into

A panel on a wall could not answer three questions an operator asks first:
which version am I, which page am I running, and which screen is that. All
three had answers already travelling over the socket; nothing was reading them.

VERSION. device_info.app_version was the literal '1.1.0-web' for every web
player, BrightSign included — the same string as PLAYER_VERSION, which already
travels separately as client_version. So the column carried no information at
all: a panel provisioned this morning and one running a year-old host reported
identically. app_version is now the ON-DEVICE host package, the artifact OTA
replaces and the only one here that can be stale, and PLAYER_VERSION is stamped
at serve time from VERSION rather than being a constant nobody bumped for the
whole 1.x line. No '-web' suffix: client_version is only compared for equality
today, but X.Y.Z-web is a semver PRERELEASE that sorts BELOW X.Y.Z, and this
project has been bitten by exactly that before.

The host version arrives asynchronously and can land after the page registers,
so register sends what it has and the heartbeat corrects the record — which also
catches the version changing under a live page, which is what a self-update is.

THE CARD SHOWED FOR NOBODY. The Info tab's version card sat inside the block
gated on android_version && !startsWith('Web/'). A BrightSign registers as
"Web/<ua>", so the panel that most needed a version never displayed one.

THE PAD THAT COULD NOT BE CLICKED. System View was gated on tier === 2. tier is
an Android device-owner concept, NOT NULL DEFAULT 0, written only by the APK —
so a BrightSign or Tizen panel sat at 0 forever and rendered HOME, BACK, POWER,
the D-pad and OK permanently pointer-events:none, for keys those players
genuinely handle. Greying an Android gate over a working control is the "button
that cannot work" the capability system exists to prevent, inverted. Only
Recents (KEYCODE_APP_SWITCH) and Settings are truly Android-only; those are now
the only things hidden.

THE PACKAGE POINTED AT THE WRONG SERVER. autorun.zip carried the committed
default, so a player self-updating from alpha or a self-hosted box was handed a
config pointing at screentinker.com — which surfaces as a pairing bug, miles
from the packaging code that caused it. It is now stamped with the URL it was
fetched from. The bytes therefore vary per origin, so the cache is keyed by
origin and BOTH routes derive it identically: the manifest checksum and the
served bytes must come from one buffer or every player downloads, fails
verification and retries forever.

EDID. getEdidIdentity() answers seven questions and cannot answer any others —
manufacturer, EDID version, physical size, gamma and the mode lists exist only
in the raw block, which getEdid() returns as 2048 bytes. The player ships those
on the register (identity, not a reading: it changes when someone swaps the
screen) and the SERVER parses them. That split is the point: a new field becomes
a server deploy instead of a bridge update behind a 4h CDN plus an OTA for the
host. Verified against real hardware — an XT245 with a CX101 decodes to RTK /
0x1010 / serial 1 / 2020w26 / 22x13cm, preferred 1920x1200@62, matching the
player's own DWS field for field. The odd-looking 62 is right: 168.5MHz over
2200 x 1245 is 61.5Hz, and rounding it to a nicer 60 would contradict the panel.

Also corrects two comments that had outgrown their reasoning: the BrightSign
capability baseline still explained its exclusions with "a canvas cannot read
the video plane", which native capture made obsolete, and player-parity.md
claimed the bridge is "always current" when a zone-wide Cloudflare Browser Cache
TTL had been rewriting its no-cache to max-age=14400 for months.

Every new guard is mutation-tested — the fix was reverted in the source and each
test confirmed to fail. 1676 -> 1714 tests, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

* Run the ScreenTinker server on the player it serves

A BrightSign XT245 now downloads, installs and runs the server itself, with
the display showing what it is doing until it is up.

WHY IT NEEDED A NEW SHAPE

BrightSignOS cannot open a large autorun.zip. The 73MB build failed at boot
with "ZipArchive error at line 91", and the OS renamed it autorun.zip_invalid
- which is how a device that had already unpacked once came back up with no
autorun at all. The identical package cut to 32KB and five files boots fine;
paths (182 chars) and depth (8) are unremarkable, so the limit is in the
boot-time reader, not the archive. BrightSign's own notes acknowledge package
size as a problem and point at webpack; that route needs the dynamic requires
in scripts/ removed first, so instead autorun.zip carries only what starts the
process and the payload arrives over HTTP into a Node that has no such limit.
The payload can also be updated without re-provisioning the device.

WHY roNodeJs AND NOT THE WIDGET

The first version ran the server inside an roHtmlWidget with nodejs_enabled.
That is a Node context inside an Electron renderer, and it is not Node. Four
separate boot failures came out of it, each invisible to a local test because
a local test runs on real Node:

  - shebangs are not stripped, so any `#!/usr/bin/env node` file dies with
    "Failed to construct 'ContextifyScript': Invalid or unexpected token".
    Note it names no token - "#" is not one. An ESM file compiled as CJS says
    "Unexpected token 'export'" instead, which is how the two are told apart.
  - require() of an ESM-only package is unsupported, which plain Node 24
    handles. uuid 14 is ESM-only and 21 files import it.
  - setInterval is the DOM's and returns a NUMBER, so setInterval(...).unref()
    throws. Two call sites were unguarded; sixteen more were written
    defensively and had been silently not unreffing.
  - worker_threads cannot create a thread at all.

BrightSign's dev-cookbook is explicit: roNodeJs "for long running processes
like ... running a web server", roHtmlWidget "for browser-based apps". Their
cra-template examples do exactly this - server in roNodeJs, widget pointed at
localhost. It also fixes the lifecycle problem that was the original argument
against a server on this hardware: in a widget the server dies with the page,
taking an open SQLite WAL with it.

The shims for the first three are kept in the packager for now rather than
removed in the same change that moves the container, so that if something
breaks it is the move and not four simultaneous removals.

CHANGES THAT ARE NOT BRIGHTSIGN-SPECIFIC

  db/database.js, routes/status.js  fs.copyFileSync does not merely copy
    bytes: it fchmods the destination to match the source. exFAT has no
    permission bits, so the pre-migration snapshot failed with EPERM and the
    failure path called process.exit(1) - which inside a widget also killed
    the page, leaving a black screen and no diagnostic. The guard was right;
    the copy was wrong. lib/fsutil.js copies without touching mode.

  db/wal-checkpointer.js  the module already degraded correctly when its
    worker died or could not be respawned, but the FIRST spawn was not
    wrapped, so a host that cannot make threads lost the whole server rather
    than falling back to inline autocheckpoint.

  db/sqlite-compat.js  a better-sqlite3 facade over node:sqlite. With it the
    bundle contains no native code at all, which is what lets an x86_64
    laptop build a package for an aarch64 player. 1719/1719 tests pass on
    Node 24 through this shim.

The packager refuses to build if a source file is untracked (git ls-files
decides what ships, and lib/fsutil.js reached a player without shipping
alongside the code that required it), if any .node binary is present, if a
shebang survives, or if a database, upload, cert or .env is staged - the first
build of this package swept up a real 33MB database and 105MB of uploads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
2026-08-18 15:16:09 -05:00

351 lines
18 KiB
Bash
Executable file

#!/bin/bash
# Build brightsign/autorun-server.zip — the ScreenTinker SERVER, packaged to run ON a player.
#
# scripts/build-server-zip.sh [-o path/to/autorun-server.zip]
#
# Drop it on the storage root of a BrightSign running OS 10 (Node 24) and power-cycle. autozip.brs
# unpacks it in place and autorun.brs launches the server with roNodeJs, painting a diagnostic
# screen: URL, uptime, memory, disk, database size and a tail of the server's own console.
#
# ⚠️ THE BUNDLE MUST CONTAIN NO NATIVE CODE. That is the whole reason this is buildable on an
# x86_64 laptop for an aarch64 player. The server reaches SQLite through node:sqlite — built into
# the Node that BrightSignOS 10 already ships — via server/db/sqlite-compat.js. better-sqlite3 was
# the last native dependency; with it gone there is nothing to cross-compile, no ABI to match and
# no node-gyp to fail on a device with three slow cores. This script VERIFIES that rather than
# trusting it: any .node binary in the staged tree is a hard error, because the failure it would
# otherwise cause happens on the player, at boot, with no console.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=""
PAYLOAD_ONLY=0
while [ $# -gt 0 ]; do
case "$1" in
# Build the PAYLOAD half: everything except the boot files, for BrightSignOS 10 where a large
# autorun.zip cannot be opened by the boot-time zip reader. scripts/build-server-boot-zip.sh
# builds the other half, and bs-payload-install.js fetches this one onto the device.
--payload) PAYLOAD_ONLY=1; shift ;;
-o|--out) OUT="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
[ -n "$OUT" ] || { [ "$PAYLOAD_ONLY" = 1 ] && OUT="brightsign/server-payload.zip" || OUT="brightsign/autorun-server.zip"; }
command -v zip >/dev/null || { echo "ERROR: 'zip' is not installed." >&2; exit 1; }
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# ⚠️ UNTRACKED FILES DO NOT SHIP, AND THE FAILURE LANDS ON THE PLAYER.
#
# Staging is `git ls-files`, which is the right call - it keeps databases, uploads and .env out by
# construction. The cost is that a NEW file that has not been `git add`ed is silently omitted while
# every file that requires it ships happily. That is not hypothetical twice over: it happened to
# db/sqlite-compat.js, and then to lib/fsutil.js, which reached a player as
#
# Cannot find module '../lib/fsutil'
# Require stack: /storage/ssd/server/db/database.js
#
# after a 73MB download and a two-minute extraction. The specific guard below for sqlite-compat.js
# was written after the first one; this is the general form, so there is no third.
UNTRACKED="$(git ls-files --others --exclude-standard -- server frontend scripts docs shared brightsign \
| grep -E '\.(js|json|html|brs|css|sql)$' || true)"
if [ -n "$UNTRACKED" ]; then
echo "ERROR: these source files are not tracked by git and would NOT be packaged:" >&2
echo "$UNTRACKED" | sed 's/^/ /' >&2
echo " git add them (or remove them) before building." >&2
exit 1
fi
echo " staging server..."
mkdir -p "$STAGE/server"
# ⚠️ GIT DECIDES WHAT SHIPS. Not a hand-written exclude list.
#
# The first version of this used --exclude and it staged 291MB: server/db/remote_display.db (a real
# 33MB database), server/uploads (105MB of customer content), server/certs, and .env. Every one of
# those is already in .gitignore — the knowledge existed, the packager just was not using it. A
# manual list is also the wrong shape: it has to be updated every time someone adds a secret, and
# the failure mode is silent and shipped.
#
# `git ls-files` yields exactly the tracked source, so anything gitignored is excluded by
# construction and stays excluded as the repo grows. node_modules is installed fresh below rather
# than copied, so a developer's x86_64 better-sqlite3 cannot ride along either.
#
# THE SERVER IS NOT SELF-CONTAINED, so the package reproduces the repo layout rather than just
# server/. Discovered the hard way: it resolves ../frontend for the dashboard (config.js:34),
# ../scripts for the multi-tenancy migration, ../VERSION, ../brightsign to build the PLAYER package
# it serves at /api/brightsign/package, and ../docs. Ship server/ alone and it boots, migrates, and
# then dies on `Cannot find module '../../scripts/migrate-multitenancy'`.
#
# Deliberately NOT shipped: android/ (228MB of APK build), video/, Examples/, audit/, tizen/ —
# none of which the server reads at runtime.
for p in server frontend scripts docs shared brightsign VERSION package.json; do
git ls-files -z -- "$p" \
| grep -zZv -E '^server/(test|node_modules)/' \
| while IFS= read -r -d '' f; do
mkdir -p "$STAGE/$(dirname "$f")"
cp "$f" "$STAGE/$f"
done
done
if [ "$PAYLOAD_ONLY" = 0 ]; then
cp brightsign/server/autorun.brs "$STAGE/autorun.brs"
cp brightsign/server/bs-server-boot.js "$STAGE/bs-server-boot.js"
cp brightsign/server/server.env.example "$STAGE/server.env.example"
cp brightsign/server/node-server.html "$STAGE/node-server.html"
cp brightsign/autozip.brs "$STAGE/autozip.brs"
fi
# Point the server at the built-in driver. The shim is API-compatible, so no call site changes —
# this rewires the two places that construct a Database and drops the dependency entirely.
echo " switching to node:sqlite..."
python3 - "$STAGE" <<'PY'
import json, os, sys
stage = sys.argv[1]
root = os.path.join(stage, 'server')
# The rewire is worthless without the shim itself. It is a TRACKED source file, so if it is missing
# the staging step never saw it — which happened once because it had been written but not `git
# add`ed, and the only symptom was the server refusing to start with "Cannot find module
# './sqlite-compat.js'".
shim = os.path.join(root, 'db', 'sqlite-compat.js')
if not os.path.exists(shim):
sys.exit("ERROR: server/db/sqlite-compat.js is not in the staged tree — is it committed to git?")
# NOTHING IS REWRITTEN. The shim is installed UNDER THE NAME better-sqlite3 after npm runs (see
# below), so every require resolves to it by construction.
#
# Rewriting the requires textually was the obvious approach and it does not work: scripts/ reaches
# for the module three DYNAMIC ways —
# require(resolveFromServer('better-sqlite3'))
# require(require.resolve('better-sqlite3', { paths: [SERVER_DIR] }))
# require(path.join(__dirname, '..', 'server', 'node_modules', 'better-sqlite3'))
# — none of which a string sweep can see. The first is in migrate-multitenancy.js, which runs during
# first boot, so the miss surfaced as "Migration FAILED" with a half-migrated database long after
# the server appeared to start cleanly.
pkg = os.path.join(root, 'package.json')
d = json.load(open(pkg))
if 'better-sqlite3' in d.get('dependencies', {}):
del d['dependencies']['better-sqlite3']
print(" dropped better-sqlite3 from dependencies")
# The player has Node 24; say so, so an accidental install on anything older fails loudly.
d['engines'] = {'node': '>=24.0.0'}
json.dump(d, open(pkg, 'w'), indent=2)
PY
echo " installing production dependencies..."
( cd "$STAGE/server" && rm -f package-lock.json && npm install --omit=dev --no-audit --no-fund --silent )
# THE INVARIANT. A single .node file here means the package cannot run on the player, and the way
# you would find out is a boot loop on a box with no console.
# Install the façade under the real package's name. Done AFTER npm so nothing can overwrite it.
echo " installing the node:sqlite shim as better-sqlite3..."
mkdir -p "$STAGE/server/node_modules/better-sqlite3"
printf '%s\n' \
'{' \
' "name": "better-sqlite3",' \
' "version": "0.0.0-node-sqlite-shim",' \
' "description": "Not the real better-sqlite3 - a facade over node:sqlite so this bundle carries no native code.",' \
' "main": "index.js"' \
'}' > "$STAGE/server/node_modules/better-sqlite3/package.json"
printf '%s\n' \
'// Every require of better-sqlite3 in this bundle lands here, in whatever form it was written:' \
"// plain, require.resolve with paths, or an absolute path into node_modules." \
"module.exports = require('../../db/sqlite-compat.js');" \
> "$STAGE/server/node_modules/better-sqlite3/index.js"
echo " stub installed"
# ⚠️ ESM-ONLY PACKAGES DO NOT WORK INSIDE THE WIDGET.
#
# The player runs the server inside an Electron roHtmlWidget, and Electron's module loader does NOT
# implement require(ESM) - plain Node 24 does, which is why this only ever fails on hardware and
# never in a local test. An ESM-only dependency therefore gets compiled as CommonJS and dies on its
# first `export` keyword:
#
# [boot] Migration FAILED: Failed to construct 'ContextifyScript': Invalid or unexpected token
#
# uuid 14 is the one that bites immediately (21 files import it, including the multi-tenancy
# migration that runs on first boot), and it is trivially replaceable: only `v4` is used anywhere,
# and crypto.randomUUID() produces exactly that. So it gets the same treatment as better-sqlite3 -
# a CommonJS package installed under the real name, after npm, so every require resolves to it.
# ⚠️ STRIP SHEBANGS. Electron's module loader does not remove them; Node's does.
#
# Node's CJS loader strips a leading #! before wrapping a file in the module function, precisely
# because a shebang is not valid JavaScript. Electron's loader does not, so the wrapper it compiles
# is "(function(){#!/usr/bin/env node ...})" and V8 refuses it:
#
# [boot] Migration FAILED: Failed to construct 'ContextifyScript': Invalid or unexpected token
#
# Note the error names no token - "#" is not one. An ESM file compiled as CJS reports
# "Unexpected token 'export'" instead, which is how these two are told apart; chasing the ESM
# explanation for this error cost a full boot cycle.
#
# scripts/migrate-multitenancy.js is required during first boot, so it fails every time. Eight other
# shipped scripts carry the same line and would fail whenever something requires them.
#
# Done HERE and not in the source: those files are meant to be executable (./scripts/reset-admin.js),
# and the shebang is correct everywhere except inside this widget. The line is replaced by a comment
# of the same length rather than deleted, so line numbers in stack traces still match the source.
echo " stripping shebangs (Electron does not strip them; Node does)..."
python3 - "$STAGE" <<'SHEBANG'
import io, os, sys
stage = sys.argv[1]
fixed = []
for root, dirs, files in os.walk(stage):
if 'node_modules' in root.split(os.sep):
continue
for name in files:
if not name.endswith('.js'):
continue
path = os.path.join(root, name)
with io.open(path, 'rb') as fh:
head = fh.read(2)
if head != b'#!':
continue
rest = fh.read()
data = b'#!' + rest
nl = data.index(b'\n') if b'\n' in data else len(data)
# '//' + the rest of the shebang keeps the byte count and the line count identical.
with io.open(path, 'wb') as fh:
fh.write(b'//' + data[2:nl] + data[nl:])
fixed.append(os.path.relpath(path, stage))
for f in sorted(fixed):
print(" " + f)
print(" %d file(s)" % len(fixed))
SHEBANG
echo " installing a CommonJS uuid shim (Electron cannot require ESM)..."
mkdir -p "$STAGE/server/node_modules/uuid"
printf '%s\n' \
'{' \
' "name": "uuid",' \
' "version": "0.0.0-cjs-shim",' \
' "description": "Not the real uuid - a CommonJS facade over crypto.randomUUID, because Electron cannot require the ESM-only uuid 14.",' \
' "main": "index.js"' \
'}' > "$STAGE/server/node_modules/uuid/package.json"
printf '%s\n' \
"const { randomUUID } = require('crypto');" \
'// crypto.randomUUID() IS a v4 UUID (RFC 4122, random). Only v4 is used in this codebase; the' \
'// others are present so an accidental import fails loudly rather than silently returning undefined.' \
'const v4 = () => randomUUID();' \
"const notImplemented = (name) => () => { throw new Error('uuid.' + name + ' is not available in the BrightSign build (CJS shim provides v4 only)'); };" \
'module.exports = { v4, default: { v4 },' \
" v1: notImplemented('v1'), v3: notImplemented('v3'), v5: notImplemented('v5')," \
" v6: notImplemented('v6'), v7: notImplemented('v7')," \
" validate: (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(s)) };" \
> "$STAGE/server/node_modules/uuid/index.js"
node -e "const u=require('$STAGE/server/node_modules/uuid'); const v=u.v4();
if(!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)) { console.error('ERROR: uuid shim does not produce a v4 UUID: '+v); process.exit(1); }
if(!u.validate(v)) { console.error('ERROR: uuid shim validate() rejects its own output'); process.exit(1); }
console.log(' shim produces valid v4 UUIDs (' + v + ')');"
# What is still ESM-only, and therefore still a landmine on this platform? Report it rather than
# pretend it is handled - these fail only when their code path is first exercised on the player.
echo " remaining ESM-only packages (fine under Node, FAIL inside the widget when first required):"
# Fed by heredoc, not `node -e '...'`: the scan needs both quote characters and nesting them
# inside a single-quoted shell argument mangles them.
node - "$STAGE" <<'ESMSCAN'
const fs = require("fs"), path = require("path");
const root = path.join(process.argv[2], "server", "node_modules");
const out = [];
const scan = (dir, depth) => {
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
for (const d of entries) {
if (!d.isDirectory()) continue;
if (d.name.startsWith("@") && depth === 0) { scan(path.join(dir, d.name), 1); continue; }
const pj = path.join(dir, d.name, "package.json");
if (!fs.existsSync(pj)) continue;
let j;
try { j = JSON.parse(fs.readFileSync(pj, "utf8")); } catch (e) { continue; }
if (j.type !== "module") continue;
// A CommonJS entry point, or a "require" condition in exports, means require() still works.
if (j.main && !String(j.main).endsWith(".mjs")) continue;
if (JSON.stringify(j.exports || "").includes(JSON.stringify("require"))) continue;
out.push((j.name || d.name) + "@" + (j.version || "?"));
}
};
scan(root, 0);
if (!out.length) console.log(" none");
else out.sort().forEach((n) => console.log(" " + n));
ESMSCAN
echo " verifying the bundle is architecture-independent..."
if find "$STAGE" -name '*.node' -print -quit | grep -q .; then
echo "ERROR: native binaries in the bundle — this cannot run on the player:" >&2
find "$STAGE" -name '*.node' | sed 's/^/ /' >&2
exit 1
fi
if ! grep -q "node-sqlite-shim" "$STAGE/server/node_modules/better-sqlite3/package.json" 2>/dev/null; then
echo "ERROR: better-sqlite3 is the REAL package, not the shim — npm must have reinstalled it." >&2
exit 1
fi
echo " no .node binaries, better-sqlite3 is the shim — portable"
# THE SECOND INVARIANT, and the one that matters more. This package gets copied onto hardware that
# leaves the building. The first build of it contained a real 33MB customer database, 105MB of
# uploads, the TLS certs and .env — because the staging step used an exclude list instead of git.
# Verified rather than trusted, because "we removed it" is not a property, it is a memory.
# Verified, not assumed: one surviving shebang is one boot failure with a misleading error.
LEFTOVER="$(find "$STAGE" -name '*.js' -not -path '*/node_modules/*' -exec sh -c 'head -c2 "$1" | grep -q "#!" && echo "$1"' _ {} \; )"
if [ -n "$LEFTOVER" ]; then
echo "ERROR: shebangs remain; these will fail to compile inside the widget:" >&2
echo "$LEFTOVER" | sed 's/^/ /' >&2
exit 1
fi
echo " no shebangs remain in shipped scripts"
echo " verifying no data or secrets are bundled..."
LEAKS="$(find "$STAGE" \( -name '*.db' -o -name '*.db-wal' -o -name '*.db-shm' -o -name '.env' \
-o -name '.env.*' -o -name '*.pem' -o -name '*.key' -o -name '*.devbak' \) \
-not -path '*/node_modules/*' -print)"
if [ -n "$LEAKS" ]; then
echo "ERROR: the bundle contains data or secrets:" >&2
echo "$LEAKS" | sed 's/^/ /' >&2
exit 1
fi
for d in uploads certs data; do
if [ -d "$STAGE/server/$d" ] && [ -n "$(ls -A "$STAGE/server/$d" 2>/dev/null)" ]; then
echo "ERROR: server/$d is non-empty in the bundle — that is runtime state, not source." >&2
exit 1
fi
done
echo " no databases, uploads, certs or .env"
mkdir -p "$(dirname "$OUT")"
rm -f "$OUT"
ABS_OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
# -0 = STORED, for the same reason as the player package: BrightSign's deployment reported our
# first DEFLATED archive as invalid, and roBrightPackage documents "no compression" as the
# universally safe option. A compressed archive copies across perfectly and then fails to open.
( cd "$STAGE" && zip -q -r -X -0 "$ABS_OUT" . )
echo " built $OUT ($(du -h "$ABS_OUT" | cut -f1))"
# Listed ONCE into a variable. `unzip -l | grep -q` looks obvious and is a trap here: grep -q exits
# at the first match, unzip dies of SIGPIPE, and under `set -o pipefail` the pipeline reports
# failure — so a file that IS present is reported missing. With 9000+ entries it fires every time.
LISTING="$(unzip -l "$OUT")"
echo "$LISTING" | tail -1 | sed 's/^/ /'
REQUIRED="autorun.brs autozip.brs bs-server-boot.js node-server.html"
# The payload is verified on the thing the installer actually looks for before it commits the
# extraction. An archive that unpacks perfectly and lacks this is the failure worth catching here.
[ "$PAYLOAD_ONLY" = 1 ] && REQUIRED="server/server.js"
for required in $REQUIRED; do
case "$LISTING" in
*" $required"*) ;;
*) echo "ERROR: $required missing from the archive" >&2; exit 1 ;;
esac
done
COMPRESSED="$(unzip -v "$OUT" | awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[A-Za-z]/ && $2 != "Stored" {print $2}' | head -1)"
if [ -n "$COMPRESSED" ]; then
echo "ERROR: archive contains compressed members ($COMPRESSED); BrightSign needs it stored." >&2
exit 1
fi
echo " root-level layout verified, all members stored"