Rework DECT relay bundle to ship a pre-built Docker image

The previous packager (scripts/packageDectRelayAgent.js) shipped a
source-only bundle and expected the DC host to build the image with
`docker compose up --build`. That fails hard in corporate DCs with
TLS-intercepted egress: Alpine's apk fetch of dl-cdn.alpinelinux.org
can't verify the intercepted certificate ("apk: TLS: server
certificate not trusted"), and npm install would fail the same way
if apk had succeeded.

New approach: build the image ONCE on the dev machine (where TLS
works), save it as a gzipped tarball, and ship a ZIP whose install
step is `docker load` + `docker compose up -d`. Zero network calls
inside the DC container, ever.

Bundling (dev-machine):
- dect-relay-agent/bundle.sh: build → docker save → gzip → zip.
  Auto-derives version from package.json, records git sha + dirty
  flag + build date into image labels. Cross-arch friendly
  (--platform=linux/amd64 by default; --platform linux/arm64 for
  ARM DCs). Output: dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
  at repo root (typically 40-60MB).
- dect-relay-agent/Dockerfile: multi-stage node:20-alpine build.
  No apk add. No runtime npm install. Non-root `node` user (uid
  1000). Node handles SIGTERM natively via index.js handlers, so
  no tini/dumb-init needed. Designed to build from the REPO ROOT
  (not the agent folder) because the agent imports shared modules
  from ../integrations/cisco-dect and ../utils.
- dect-relay-agent/Dockerfile.dockerignore: per-Dockerfile ignore
  (BuildKit ≥ 23.0) with a whitelist that keeps the build context
  to ~50KB. Older Docker daemons fall through to the repo-root
  .dockerignore, which already excludes secrets — nothing sensitive
  can leak either way.
- package.json: `npm run package:relay` now invokes bundle.sh.

Runtime (DC-host):
- dect-relay-agent/docker-compose.yml: pins IMAGE_TAG from .env
  (install.sh writes it there — never falls back to :latest), reads
  the rest of the config via env_file, restart: unless-stopped,
  host networking (needed to reach 10.x/8 without userland proxy
  translation, and the agent doesn't listen on anything). Hardened:
  read_only: true rootfs with a 16MB /tmp tmpfs, cap_drop: ALL,
  no-new-privileges, log rotation at 10MB × 5 files.
- dect-relay-agent/install.sh: preflight (docker + compose present,
  daemon reachable, bundle files intact), docker load, pin loaded
  tag into .env, validate .env has the three required values not
  still set to placeholder strings, docker compose up -d, tail last
  40 log lines. Idempotent — safe to re-run on upgrades.

Cleanup:
- scripts/packageDectRelayAgent.js: deleted (superseded).
- .gitignore: drops the scripts/* + !packageDectRelayAgent.js dance
  since we no longer need to whitelist that one file; add pattern
  for the datestamped bundle zips + staging dirs at repo root.
- dect-relay-agent/README.md: replaces the deploy section with the
  new dev-machine-build → DC-host-load workflow, plus a
  troubleshooting section keyed on the exact error messages seen
  during the failed in-DC build (TLS cert not trusted, docker perm
  denied, DIGEST_401).

Verified: all 113 existing tests still pass. Docker build itself
requires a Docker daemon (dev machine) so can't be exercised in
this sandbox — the bash scripts pass `bash -n` syntax checks.
This commit is contained in:
Joseph McQueen 2026-07-03 10:05:05 -04:00
parent e8500b4324
commit 4f9ebdb5fb
10 changed files with 573 additions and 496 deletions

22
.gitignore vendored
View file

@ -26,16 +26,7 @@ storage/
# Dev / test artifacts (local only)
characterization-runs/
# NOTE on ordering: `scripts/*` (glob) rather than `scripts/` (dir
# exclusion) because git gitignore semantics forbid re-including a
# file inside an excluded directory. Using `scripts/*` still excludes
# every file inside scripts/ by default, but leaves the door open for
# `!` overrides below.
scripts/*
# Un-ignored: the DECT relay packager is part of the deploy workflow
# and needs to be tracked so anyone with a fresh clone can build the
# data-center bundle via `npm run package:relay`.
!scripts/packageDectRelayAgent.js
scripts/
characterize-*.js
# Backup & temp files
@ -57,11 +48,12 @@ dist/
build/
coverage/
.nyc_output/
# Temporary staging dir created by scripts/packageDectRelayAgent.js.
# The script cleans this up in a finally block, but a SIGKILL can
# leave it behind. Ignoring means an interrupted run doesn't leak
# staged files into future git commits.
.package-relay-tmp/
# DECT relay agent deploy bundles produced by dect-relay-agent/bundle.sh.
# The datestamped zip lands at repo root and shouldn't be committed —
# it's ~40MB (Docker image tarball) and rebuildable on demand.
dect-relay-agent-bundle-*.zip
dect-relay-agent-bundle-*/
# Docker / Misc
docker-compose.override.yml

View file

@ -1,20 +0,0 @@
# Everything the bundle-side Dockerfile does NOT need.
# The bundle produced by scripts/packageDectRelayAgent.js only ever
# contains: workspace/, Dockerfile, docker-compose.yml, .env.example,
# .dockerignore, and README.deploy.md — so this file is mostly
# defensive (belt-and-suspenders against a stray copy or an operator
# running `docker build` in a manually-assembled bundle).
# Never ship secrets or local overrides.
.env
.env.*
!.env.example
# Never ship a local node_modules — the Dockerfile installs fresh.
**/node_modules
# Never ship logs, dev artifacts, or IDE cruft.
**/*.log
**/.DS_Store
**/.git
**/.gitignore

View file

@ -1,78 +1,95 @@
# syntax=docker/dockerfile:1.6
#
# DECT Relay Agent — production container image
# DECT relay agent — production image.
#
# Build context: the bundle produced by scripts/packageDectRelayAgent.js.
# The bundle contains a `workspace/` directory that mirrors just enough of
# the parent repo to satisfy the agent's `../integrations/...` and
# `../utils/...` imports without any source rewriting:
# BUILD CONTEXT: the REPO ROOT (not this folder). The agent imports
# `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js`, so
# we mirror the repo's layout under /workspace/ inside the image and
# the relative paths just work.
#
# workspace/
# dect-relay-agent/ ← WORKDIR at runtime
# package.json
# index.js
# integrations/cisco-dect/{client,probes,statusXml}.js
# utils/httpDigestAuth.js
# BUILD FROM REPO ROOT:
# docker build \
# --platform=linux/amd64 \
# -f dect-relay-agent/Dockerfile \
# -t collabsupport/dect-relay-agent:0.1.0 \
# .
#
# Building this Dockerfile in the raw repo (`docker build dect-relay-agent/`)
# WILL NOT WORK — the shared modules live one directory up and would be
# outside the build context. Always build from a bundle produced by the
# packager.
# Or use bundle.sh which wraps this + `docker save` + zip.
#
# WHY NO `apk add`: corporate DCs commonly TLS-intercept HTTPS. Alpine's
# apk fetch of dl-cdn.alpinelinux.org fails inside the container when
# the CA chain includes a proxy cert the container doesn't trust. We
# avoid the problem entirely by not fetching anything from Alpine at
# build time. Signal handling (SIGTERM / SIGINT / SIGUSR2) is done in
# index.js so we don't need tini/dumb-init.
#
# WHY NO RUNTIME `npm install`: the bundle.sh workflow builds this
# image ONCE outside the DC (where npm registry access works), saves
# it as a tarball, and ships the tarball. The DC only runs
# `docker load` + `docker compose up -d` — zero network calls beyond
# the initial docker load.
# ── Stage 1: install prod deps ──────────────────────────────────────
# node:20-alpine keeps the final image ~55MB. Alpine's musl libc has
# been fine for this agent's plain JS + axios + ws footprint (no
# native modules) but if you ever add one that needs glibc, switch to
# node:20-slim.
FROM node:20-alpine AS deps
WORKDIR /build
# ─── Stage 1: builder ────────────────────────────────────────────────
# Installs prod deps in a full node image (has python/build-essentials
# just in case a native module needs building — currently `ws` ships
# pre-built optional deps for common arches but we keep the option
# open for future deps).
# Only copy the agent's manifest first so this layer caches across
FROM node:20-alpine AS builder
WORKDIR /workspace/dect-relay-agent
# Copy just the package manifest first so this layer caches across
# code-only changes.
COPY workspace/dect-relay-agent/package.json ./package.json
COPY dect-relay-agent/package.json ./package.json
# `npm install --omit=dev` because there's no committed lockfile
# (the agent has three dependencies; every deploy resolving the same
# `^` ranges is acceptable for this operational tool). Add
# --ignore-scripts to refuse arbitrary lifecycle-script execution from
# the registry — none of our current deps use lifecycle scripts.
# Install only production deps. --ignore-scripts because we don't run
# arbitrary postinstall from transitive deps in the container build;
# any needed build steps are pinned in this Dockerfile.
RUN npm install --omit=dev --ignore-scripts \
&& npm cache clean --force
&& npm cache clean --force
# ─── Stage 2: runtime ────────────────────────────────────────────────
# Same base as builder, but only the artifacts we actually need at
# run time (node_modules + agent source + shared integrations + utils).
# ── Stage 2: runtime ────────────────────────────────────────────────
FROM node:20-alpine AS runtime
# tini gives us proper PID-1 signal handling (SIGTERM propagates
# cleanly to node so our graceful shutdown path in index.js actually
# runs on `docker stop`).
RUN apk add --no-cache tini
# node:20-alpine ships a `node` user (uid 1000) that we can just use —
# no need to install anything extra. Running as a non-root user is a
# baseline hardening we get essentially for free.
USER node
# Non-root user. UID/GID pinned so bind-mounted volumes (if any) are
# predictable across hosts.
RUN addgroup -S -g 1500 dect \
&& adduser -S -u 1500 -G dect -H -s /sbin/nologin dect
# Match the repo layout so relative imports (`../integrations/...`)
# resolve exactly as they do in development.
WORKDIR /workspace/dect-relay-agent
WORKDIR /app
# Ship the node_modules we built in stage 1. Ownership goes to `node`
# so the process can read them without needing root.
COPY --from=builder --chown=node:node /workspace/dect-relay-agent/node_modules ./node_modules
# Copy the shared workspace tree — the agent's imports of
# `../integrations/...` and `../utils/...` resolve exactly as they do
# in the source repo. See the bundle layout comment at the top of
# this file.
COPY --chown=dect:dect workspace/ ./
# Agent source + manifest.
COPY --chown=node:node dect-relay-agent/package.json ./package.json
COPY --chown=node:node dect-relay-agent/index.js ./index.js
# Bring in the deps that stage 1 resolved.
COPY --from=deps --chown=dect:dect /build/node_modules ./dect-relay-agent/node_modules
# Shared modules the agent imports from the parent workspace.
COPY --chown=node:node integrations/cisco-dect /workspace/integrations/cisco-dect
COPY --chown=node:node utils/httpDigestAuth.js /workspace/utils/httpDigestAuth.js
USER dect
WORKDIR /app/dect-relay-agent
# Optional metadata that shows up in `docker inspect` output — useful
# in the DC for "which build am I running?" without needing to poke
# inside the container.
ARG AGENT_VERSION=dev
ARG BUILD_DATE
ARG GIT_COMMIT
LABEL org.opencontainers.image.title="dect-relay-agent" \
org.opencontainers.image.description="Data-center-resident WSS bridge from CollabSupport bot (cloud) to Cisco DBS-210 DECT base stations on 10.x/8" \
org.opencontainers.image.version="${AGENT_VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
org.opencontainers.image.source="https://git.joesjavajoint.com/jmcqueen/collabSupport"
# Runtime config comes from `docker compose` (--env-file .env) or
# `docker run --env-file ...`. Never bake secrets into the image.
# The agent process itself validates required vars and exits 1 if any
# are missing (see assertConfig() in index.js).
# tini reaps zombies + forwards SIGTERM. `-g` puts tini in the same
# process group as node so `docker stop` also delivers SIGTERM to
# child processes if any are spawned in the future.
ENTRYPOINT ["/sbin/tini", "-g", "--"]
CMD ["node", "index.js"]
# Node handles SIGTERM natively when the process installs handlers
# (which we do in index.js). --enable-source-maps improves stack
# traces if something crashes at runtime — cheap and always-on.
CMD ["node", "--enable-source-maps", "index.js"]

View file

@ -0,0 +1,30 @@
# Per-Dockerfile ignore, picked up by BuildKit ≥ 23.0 when this
# Dockerfile is used (see https://docs.docker.com/build/concepts/context/#filename-and-location).
# For older Docker daemons, the repo-root /.dockerignore is used
# instead (it already excludes node_modules, .env*, logs/, etc., so
# nothing sensitive would leak — this file is a size/speed win, not
# a security requirement).
#
# The build context is the REPO ROOT. We whitelist only the paths
# the Dockerfile actually COPYs. That keeps the transferred context
# tiny (a few dozen KB instead of the whole repo) and makes builds
# noticeably faster on slow disks / VPN uplinks.
*
# ─── Whitelist (paths the Dockerfile needs) ─────────────────────────
!dect-relay-agent/package.json
!dect-relay-agent/index.js
!integrations/cisco-dect/**
!utils/httpDigestAuth.js
# ─── Never-ship, even inside whitelisted trees ──────────────────────
**/node_modules
**/.env
**/.env.*
!**/.env.example
**/*.log
**/logs
**/.git
**/.DS_Store
**/coverage

View file

@ -21,17 +21,48 @@ There are two ways to run this. Pick one based on where you're deploying.
### 1. Docker container in the data center (recommended for production)
Run the packager on your dev machine to produce a self-contained zip that includes the shared modules the agent imports:
The DC host makes **zero network calls** during install — the image is built on your dev machine, saved as a tarball, and shipped inside a self-contained ZIP. This sidesteps the TLS-interception problem that breaks `apk add` and `npm install` inside containers on corporate networks.
**On your dev machine (with Docker Desktop / internet access):**
```bash
# From the parent repo root:
npm run package:relay
# → writes dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
# From the repo root — script is self-locating:
./dect-relay-agent/bundle.sh
# → writes dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip (~40-60MB)
```
Transfer the zip to the DC host and follow the bundle's own `README.md` — three commands (`unzip`, edit `.env`, `docker compose up -d --build`).
Optional overrides:
If your DC host can't reach the npm registry, see the "No-internet DC option" section in the bundle's README — you can build the image on an internet-connected machine, `docker save` it to a tarball, and ship that instead.
```bash
./dect-relay-agent/bundle.sh --tag 0.2.0 # override version
./dect-relay-agent/bundle.sh --platform linux/arm64 # if the DC is ARM
```
**On the DC host** (once you've transferred the ZIP):
```bash
unzip dect-relay-agent-bundle-*.zip
cd dect-relay-agent-bundle-*
cp .env.example .env
$EDITOR .env # set BOT_URL + AGENT_TOKEN + ADMIN_PASSWORD
./install.sh
```
`install.sh` is idempotent — re-run it after transferring a newer bundle to upgrade. It:
1. `docker load`s the image tarball
2. Pins the loaded tag into `.env` (so compose never falls back to a stale local image)
3. Validates required env values are set (not still placeholder strings)
4. `docker compose up -d`
5. Tails the last 40 log lines so you can see the "Connected — sending hello" message
**What the runtime container looks like:**
- Non-root `node` user (uid 1000)
- Read-only root filesystem, 16MB tmpfs at `/tmp`
- All Linux capabilities dropped, `no-new-privileges`
- Host networking (so it can reach `10.x/8` without userland proxy translation)
- No listening ports — outbound-only WSS to the bot
- Log rotation: 10MB × 5 files max
### 2. Direct node process (dev + local iteration)
@ -132,8 +163,30 @@ The bot terminates the socket if no `pong` arrives within 90s; the agent auto-re
| `package.json` | Deps: `ws`, `axios`, `dotenv` |
| `.env.example` | Annotated env template |
| `README.md` | This file |
| `Dockerfile` | Multi-stage alpine build used by the deploy bundle |
| `docker-compose.yml` | One-command deploy on the DC host |
| `.dockerignore` | Defensive; the packager already excludes cruft |
| `Dockerfile` | Multi-stage Alpine build. No `apk add`, no runtime `npm install`. |
| `Dockerfile.dockerignore` | Per-Dockerfile ignore (BuildKit ≥ 23.0). Whitelist-based; keeps build context ~50KB. |
| `docker-compose.yml` | DC-side runtime shape (read-only rootfs, host net, capability drop, log rotation). |
| `bundle.sh` | Dev-machine packager: `docker build``docker save``zip`. Runs on your machine. |
| `install.sh` | DC-host installer: `docker load` → validate `.env``docker compose up -d`. Ships inside the bundle. |
The Dockerfile is intentionally NOT designed to be built from this folder directly (`docker build dect-relay-agent/` will fail — the shared modules live one level up and are outside the build context). Always build from a bundle produced by `npm run package:relay`, which assembles a `workspace/` tree that makes the shared imports resolvable inside the build context.
**Important**: the `Dockerfile` is designed to be built from the **repo root**, not from this folder, because it needs `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js` in the build context. `bundle.sh` does this correctly:
```bash
docker build -f dect-relay-agent/Dockerfile -t ... . # note the trailing `.`
```
Building with `docker build dect-relay-agent/` will fail (missing shared modules) — always use `bundle.sh`, or invoke `docker build` from the repo root with `-f dect-relay-agent/Dockerfile`.
## Troubleshooting
**`WARNING: fetching … TLS: server certificate not trusted` during build**
You're building inside a TLS-intercepting corporate network. Don't — build on your dev machine and ship the tarball via `bundle.sh`. That's the whole point of this workflow.
**`docker: permission denied while trying to connect to the Docker daemon socket`**
The user running `install.sh` needs to be in the `docker` group. Either `sudo usermod -aG docker $USER` (log out/in after) or `sudo ./install.sh`.
**Agent connects then immediately disconnects with 401**
Bearer token mismatch. `DECT_RELAY_AGENT_TOKEN` on the bot side must match the agent's `.env` exactly. Rotate both together.
**Agent connects but every `collect` returns `DIGEST_401`**
DBS-210 admin password is wrong. Verify in Control Hub → Calling → Features → DECT Networks → Manage → Manage DECT serviceability password, update `DECT_ADMIN_PASSWORD` in `.env`, then `docker compose restart dect-relay-agent`.

204
dect-relay-agent/bundle.sh Executable file
View file

@ -0,0 +1,204 @@
#!/usr/bin/env bash
# ────────────────────────────────────────────────────────────────────
# DECT relay agent — dev-machine bundler.
#
# Builds the Docker image for linux/amd64, saves it as a gzipped
# tarball, and zips it up with the compose file + .env template +
# install script. Output is a self-contained ZIP the DC operator
# can transfer over any file-copy channel (email, S3, USB, git-lfs)
# and install with a single `./install.sh` invocation.
#
# Requirements on the dev machine:
# - Docker Desktop / Docker Engine
# - Internet access to pull node:20-alpine + npm registry
# - `zip` (macOS + most Linux distros already have it; if not,
# `apt install zip` / `brew install zip`)
#
# Requirements on the DC host:
# - Docker + Docker Compose v2 (v1 also works)
# - Ability to `docker load` (i.e. member of the docker group or
# root)
# - Outbound HTTPS to the bot + 10.0.0.0/8 on TCP 443
# - That's it. No npm, no python, no Alpine mirrors.
#
# Usage (from repo root OR from this folder — the script figures it out):
# ./dect-relay-agent/bundle.sh # uses version from package.json
# ./dect-relay-agent/bundle.sh --tag 0.2.0 # override version
# ./dect-relay-agent/bundle.sh --platform linux/arm64
# ────────────────────────────────────────────────────────────────────
set -euo pipefail
# ─── Argument parsing ─────────────────────────────────────────────
TAG_OVERRIDE=""
PLATFORM="linux/amd64" # standard x86_64 Linux server. Override if DC is arm.
while [[ $# -gt 0 ]]; do
case "$1" in
--tag) TAG_OVERRIDE="$2"; shift 2 ;;
--platform) PLATFORM="$2"; shift 2 ;;
-h|--help)
grep -E '^# ' "$0" | sed 's/^# \?//'
exit 0
;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# ─── Locate paths (works from repo root or agent dir) ─────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
AGENT_DIR="$SCRIPT_DIR"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Sanity: the Dockerfile expects to build from the repo root.
[[ -d "$REPO_ROOT/integrations/cisco-dect" ]] || {
echo "ERROR: $REPO_ROOT does not look like the collabSupport repo root (no integrations/cisco-dect/)" >&2
exit 1
}
# ─── Determine version tag ────────────────────────────────────────
if [[ -n "$TAG_OVERRIDE" ]]; then
VERSION="$TAG_OVERRIDE"
else
# Pull version from agent's package.json without needing jq. The
# regex is deliberately tolerant of trailing commas / whitespace.
VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$AGENT_DIR/package.json" | head -1)"
[[ -n "$VERSION" ]] || { echo "ERROR: could not read version from package.json" >&2; exit 1; }
fi
# Try to record the git commit into the image labels — useful in
# production for "which build am I running?". Missing git is fine.
GIT_COMMIT="unknown"
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --short HEAD >/dev/null 2>&1; then
GIT_COMMIT="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
# Mark as dirty if the working tree has uncommitted changes —
# catches "I built from local edits" surprises in production.
if ! git -C "$REPO_ROOT" diff --quiet 2>/dev/null || \
! git -C "$REPO_ROOT" diff --cached --quiet 2>/dev/null; then
GIT_COMMIT="${GIT_COMMIT}-dirty"
fi
fi
BUILD_DATE="$(date -u +%FT%TZ)"
IMAGE_TAG="collabsupport/dect-relay-agent:${VERSION}"
BUNDLE_STAMP="$(date +%Y%m%d-%H%M%S)"
BUNDLE_DIR="dect-relay-agent-bundle-${BUNDLE_STAMP}"
BUNDLE_ZIP="${BUNDLE_DIR}.zip"
echo "════════════════════════════════════════════════════════════════"
echo " Building DECT relay agent bundle"
echo "────────────────────────────────────────────────────────────────"
echo " Version: ${VERSION}"
echo " Image tag: ${IMAGE_TAG}"
echo " Platform: ${PLATFORM}"
echo " Git commit: ${GIT_COMMIT}"
echo " Build date: ${BUILD_DATE}"
echo " Bundle dir: ${BUNDLE_DIR}/"
echo " Bundle ZIP: ${BUNDLE_ZIP}"
echo "════════════════════════════════════════════════════════════════"
# ─── Docker build ─────────────────────────────────────────────────
# --platform pins the arch so building on Apple Silicon still
# produces an x86_64 image the DC can run. Docker uses QEMU to
# emulate cross-arch — slower than native but Just Works.
echo
echo "[1/4] Building image..."
docker build \
--platform="${PLATFORM}" \
--file "${AGENT_DIR}/Dockerfile" \
--tag "${IMAGE_TAG}" \
--build-arg "AGENT_VERSION=${VERSION}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "GIT_COMMIT=${GIT_COMMIT}" \
"${REPO_ROOT}"
# ─── Stage the bundle ─────────────────────────────────────────────
# Work in the parent of the agent dir so the resulting ZIP + dir
# both land somewhere obvious (the repo root by convention).
echo
echo "[2/4] Staging bundle in ${REPO_ROOT}/${BUNDLE_DIR}/"
rm -rf "${REPO_ROOT:?}/${BUNDLE_DIR}"
mkdir -p "${REPO_ROOT}/${BUNDLE_DIR}"
# docker save streams a tarball to stdout; pipe through gzip to
# shrink it substantially (typically ~40% smaller for Node images).
echo
echo "[3/4] Saving image to ${BUNDLE_DIR}/image.tar.gz (this can take a minute)"
docker save "${IMAGE_TAG}" | gzip -9 > "${REPO_ROOT}/${BUNDLE_DIR}/image.tar.gz"
# Copy the operator-facing files. We do NOT copy Dockerfile / bundle.sh
# — those are dev-machine concerns.
cp "${AGENT_DIR}/docker-compose.yml" "${REPO_ROOT}/${BUNDLE_DIR}/"
cp "${AGENT_DIR}/.env.example" "${REPO_ROOT}/${BUNDLE_DIR}/"
cp "${AGENT_DIR}/install.sh" "${REPO_ROOT}/${BUNDLE_DIR}/"
cp "${AGENT_DIR}/README.md" "${REPO_ROOT}/${BUNDLE_DIR}/AGENT-README.md"
# Write a bundle-specific README that's short and tells the operator
# what to do in this exact folder. Keeps AGENT-README.md as the deep
# reference without cluttering the top-of-bundle experience.
cat > "${REPO_ROOT}/${BUNDLE_DIR}/README.txt" <<EOF
DECT Relay Agent — deployment bundle
====================================
Version: ${VERSION}
Image tag: ${IMAGE_TAG}
Built: ${BUILD_DATE}
Git commit: ${GIT_COMMIT}
Platform: ${PLATFORM}
To install on this data-center host:
1. cp .env.example .env
2. Edit .env — set:
- DECT_RELAY_BOT_URL (wss:// URL to the bot)
- DECT_RELAY_AGENT_TOKEN (shared bearer, same as bot's env)
- DECT_ADMIN_PASSWORD (DECT serviceability password)
3. ./install.sh
4. Watch it come up: docker compose logs -f dect-relay-agent
Files in this bundle:
image.tar.gz Prebuilt Docker image (gzipped, ~40MB)
docker-compose.yml Compose file — read-only rootfs, host network,
log rotation. Loaded by install.sh.
.env.example Config template.
install.sh Runs 'docker load' then 'docker compose up -d'.
Safe to re-run for upgrades.
AGENT-README.md Full agent docs — wire protocol, safety model,
run instructions.
README.txt This file.
No internet access required after the image is loaded. The container
runs with a read-only root filesystem, drops all Linux capabilities,
and uses the 'node' non-root user.
Upgrading:
Unzip the new bundle in a new folder, or overwrite this one, and
re-run ./install.sh. install.sh pins the new image tag into .env
automatically.
EOF
# ─── Zip it up ────────────────────────────────────────────────────
echo
echo "[4/4] Zipping bundle → ${BUNDLE_ZIP}"
(
cd "${REPO_ROOT}"
# -r recursive, -X strip Mac resource forks so we don't ship
# __MACOSX/ folders that confuse Linux operators.
zip -rqX "${BUNDLE_ZIP}" "${BUNDLE_DIR}"
)
# Cleanup: keep the ZIP, remove the staging dir. Operator only wants
# the ZIP to transfer.
rm -rf "${REPO_ROOT:?}/${BUNDLE_DIR}"
SIZE="$(du -h "${REPO_ROOT}/${BUNDLE_ZIP}" | cut -f1)"
echo
echo "════════════════════════════════════════════════════════════════"
echo " Bundle ready: ${REPO_ROOT}/${BUNDLE_ZIP} (${SIZE})"
echo "────────────────────────────────────────────────────────────────"
echo " Transfer to the DC and:"
echo " unzip ${BUNDLE_ZIP}"
echo " cd ${BUNDLE_DIR}"
echo " cp .env.example .env && \$EDITOR .env"
echo " ./install.sh"
echo "════════════════════════════════════════════════════════════════"

View file

@ -1,63 +1,80 @@
# DECT Relay Agent — one-command deploy for the data center.
# DECT relay agent — data-center deployment.
#
# Deploy flow:
# 1. Unzip the bundle produced by scripts/packageDectRelayAgent.js.
# 2. `cp .env.example .env` and fill it in (bot URL, shared token,
# DECT serviceability password). The .env file lives NEXT TO
# this compose file and is git-ignored.
# 3. `docker compose up -d --build`
# 4. `docker compose logs -f` and confirm you see the bot log
# "Agent connected from ..." on the other side.
# This compose file assumes the image has already been loaded from
# the shipped tarball (see install.sh: `docker load < image.tar.gz`).
# It does NOT build — build happens on the dev machine via bundle.sh
# so no npm-registry or Alpine-CDN traffic is needed inside the DC.
#
# The agent is an OUTBOUND client (it dials the bot at
# DECT_RELAY_BOT_URL). No ports are exposed and no inbound firewall
# rules are needed on the DC host — only egress to:
# - the bot's public HTTPS/WSS URL
# - every DBS-210 base station on 10.0.0.0/8 (TCP 443)
# Usage in the DC:
# 1. cp .env.example .env && $EDITOR .env
# 2. ./install.sh
# (or manually: docker load < image.tar.gz && docker compose up -d)
# 3. docker compose logs -f dect-relay-agent
#
# The IMAGE_TAG env var lets install.sh pin whatever tag the bundle
# ships (the bundle writes it into .env on install). Falls back to
# the current default so `docker compose up` still works standalone.
services:
dect-relay-agent:
build:
context: .
dockerfile: Dockerfile
image: dect-relay-agent:latest
# Tag comes from the image tarball shipped in the bundle; install.sh
# sets IMAGE_TAG in .env to whatever was baked in. Never falls back
# to :latest — that would silently swap in whatever's cached on the
# DC host if the tarball didn't load correctly.
image: ${IMAGE_TAG:-collabsupport/dect-relay-agent:0.1.0}
container_name: dect-relay-agent
# Restart on any exit (crash, host reboot, `docker stop` doesn't
# count). Matches how the rest of the collabSupport stack is run.
restart: unless-stopped
# All runtime config comes from .env — never bake secrets into
# the image. .env is created by the operator from .env.example
# and is git-ignored by convention.
# Read all config (bot URL, shared bearer, DBS-210 admin creds)
# from the operator's .env in this same directory. Compose does
# NOT auto-load .env into the container by default — env_file
# is the explicit opt-in.
env_file:
- .env
# No `ports:` block on purpose — see header comment.
# Restart on crash or reboot. `unless-stopped` respects an
# operator `docker compose stop` (so it doesn't come back until
# they say so) while surviving host reboots.
restart: unless-stopped
# Cap log volume so a chatty reconnect loop can't fill /var/log.
# 10MB * 5 files = 50MB per container is plenty for
# troubleshooting a week's worth of activity at info level.
# Host networking so the agent can reach 10.x/8 without needing
# docker userland proxy translation. The agent doesn't LISTEN on
# anything — it dials outbound WSS to the bot — so this doesn't
# expose any port to the host's network.
#
# If your DC prefers bridge networking, remove this line. The
# only requirement is that the container can egress to (a) the
# bot's public HTTPS endpoint and (b) 10.0.0.0/8 on TCP 443.
network_mode: host
# Log rotation — keeps container logs from filling the disk on
# long-running deployments. 10 MB × 5 files = 50 MB max per agent.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
# Health check hits the agent's own process. Since the agent
# doesn't expose an HTTP port, we probe by looking for the node
# process — sufficient to catch crashes that the restart policy
# will then fix. A future v2 could expose a tiny /healthz on a
# localhost-only port with connection-state details.
healthcheck:
test: ["CMD-SHELL", "pgrep -f 'node index.js' > /dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# Read-only root filesystem + a small writable /tmp. The agent
# writes nothing to disk (all logs go to stdout / stderr), so
# this is essentially free defense-in-depth.
read_only: true
tmpfs:
- /tmp:size=16M
# Optional: uncomment if your DC network requires host networking
# for the agent to reach the 10.x bases (e.g. because a routed
# docker bridge isn't set up). Adding host mode means the agent
# inherits the host's routing table and IP stack directly.
# network_mode: host
# Minimal capabilities — the agent is just outbound HTTP client
# traffic, no need for NET_RAW / SYS_ADMIN / etc.
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# Basic health check: the agent process being alive is a good
# proxy for "we're at least trying to reconnect". A deeper check
# (last successful hello with the bot < 2min ago) would need
# code the agent doesn't expose yet.
healthcheck:
test: ["CMD", "node", "-e", "process.exit(0)"]
interval: 60s
timeout: 5s
start_period: 10s
retries: 3

130
dect-relay-agent/install.sh Executable file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env bash
# ────────────────────────────────────────────────────────────────────
# DECT relay agent — data-center install / upgrade helper.
#
# Run this inside the unzipped bundle directory on the DC host. It:
# 1. Sanity-checks that Docker + Compose are installed.
# 2. Loads the shipped image tarball into the local Docker daemon.
# 3. Pins IMAGE_TAG in .env to whatever tag was baked into the
# tarball (so compose can never fall back to a stale local
# cache without you noticing).
# 4. Verifies .env exists and has the required keys populated.
# 5. Runs `docker compose up -d` and tails the last 40 lines.
#
# Safe to run repeatedly — it's a straightforward upgrade too:
# unzip -o new-bundle.zip -d dect-relay-agent-bundle
# cd dect-relay-agent-bundle
# ./install.sh
# ────────────────────────────────────────────────────────────────────
set -euo pipefail
# Colors, only if stdout is a TTY. Corporate SSH sessions often are;
# CI / pipe-to-file are not.
if [[ -t 1 ]]; then
BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'
YELLOW=$'\033[33m'; RESET=$'\033[0m'
else
BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW=''; RESET=''
fi
log() { echo "${BOLD}[install]${RESET} $*"; }
die() { echo "${RED}[install] ERROR:${RESET} $*" >&2; exit 1; }
# Ensure we're running from the bundle dir (compose file must be here).
cd "$(dirname "$0")"
# ─── 1. Preflight ──────────────────────────────────────────────────
log "Preflight checks"
command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH"
# Compose v2 is `docker compose` (space); v1 is `docker-compose` (dash).
# Prefer v2. bail if neither is available.
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
echo "${YELLOW}[install] Using legacy docker-compose v1. Consider upgrading to Compose v2.${RESET}"
else
die "docker compose (v2) not found and docker-compose (v1) not on PATH"
fi
if ! docker info >/dev/null 2>&1; then
die "docker daemon is not reachable. Are you in the 'docker' group, or should you re-run with sudo?"
fi
[[ -f image.tar.gz ]] || die "image.tar.gz not found in $(pwd) — is the bundle complete?"
[[ -f docker-compose.yml ]] || die "docker-compose.yml not found — is the bundle complete?"
# ─── 2. Load image ─────────────────────────────────────────────────
log "Loading Docker image from image.tar.gz (this is the only step that touches the docker daemon's image store)"
# `docker load` prints "Loaded image: <tag>" for each tag in the archive.
# We tee to stderr so the operator sees it, and grep the tag out for
# use in the .env pin step below.
LOAD_OUTPUT="$(gunzip -c image.tar.gz | docker load)"
echo "$LOAD_OUTPUT"
LOADED_TAG="$(echo "$LOAD_OUTPUT" | awk -F': ' '/Loaded image/ {print $2; exit}')"
[[ -n "$LOADED_TAG" ]] || die "docker load did not report a loaded image tag"
log "Loaded image: ${GREEN}${LOADED_TAG}${RESET}"
# ─── 3. .env setup ─────────────────────────────────────────────────
if [[ ! -f .env ]]; then
cp .env.example .env
echo "${YELLOW}[install] Created .env from .env.example. Edit it now with real values, then re-run this script.${RESET}"
echo " Required: DECT_RELAY_BOT_URL, DECT_RELAY_AGENT_TOKEN, DECT_ADMIN_PASSWORD"
exit 2
fi
# Pin IMAGE_TAG in .env to the tag we just loaded. Idempotent —
# rewrites the line each run so upgrades to a new tarball tag Just
# Work without operator intervention.
if grep -q '^IMAGE_TAG=' .env; then
# Portable in-place sed (works on both GNU sed and BSD sed on macOS).
# The `.bak` tempfile is removed at end.
sed -i.bak "s|^IMAGE_TAG=.*|IMAGE_TAG=${LOADED_TAG}|" .env
rm -f .env.bak
else
printf '\n# Pinned automatically by install.sh on %s\nIMAGE_TAG=%s\n' \
"$(date -u +%FT%TZ)" "$LOADED_TAG" >> .env
fi
log "Pinned IMAGE_TAG=${LOADED_TAG} in .env"
# Validate the operator has actually filled in the required values —
# .env.example ships with placeholders that would blow up at runtime
# with a less friendly error.
MISSING=()
required_var() {
local key="$1" val
val="$(grep -E "^${key}=" .env | tail -1 | cut -d= -f2-)"
# Strip surrounding quotes and whitespace so both bare and quoted
# values validate the same.
val="${val#\"}"; val="${val%\"}"
val="${val#\'}"; val="${val%\'}"
val="${val## }"; val="${val%% }"
if [[ -z "$val" ]] || [[ "$val" == "replace-with-shared-secret" ]] \
|| [[ "$val" == "replace-with-dect-serviceability-password" ]] \
|| [[ "$val" == "wss://your-bot-host.example.com/dect-relay/ws" ]]; then
MISSING+=("$key")
fi
}
required_var DECT_RELAY_BOT_URL
required_var DECT_RELAY_AGENT_TOKEN
required_var DECT_ADMIN_PASSWORD
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "${RED}[install] .env is missing required values or still has placeholder text:${RESET}"
for k in "${MISSING[@]}"; do echo " - $k"; done
echo " Edit .env and re-run this script."
exit 2
fi
# ─── 4. Compose up ─────────────────────────────────────────────────
log "Starting container via ${COMPOSE} up -d"
$COMPOSE up -d
log "Container started. Recent logs:"
sleep 2
$COMPOSE logs --tail=40 dect-relay-agent || true
echo
log "${GREEN}Done.${RESET} Follow live logs with: ${DIM}${COMPOSE} logs -f dect-relay-agent${RESET}"
log "Stop the agent with: ${DIM}${COMPOSE} down${RESET}"

View file

@ -14,7 +14,7 @@
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f",
"package:relay": "node scripts/packageDectRelayAgent.js",
"package:relay": "bash dect-relay-agent/bundle.sh",
"test": "node --test tests/*.test.js"
},
"dependencies": {

View file

@ -1,346 +0,0 @@
#!/usr/bin/env node
/**
* Assemble the DECT relay agent into a Docker-ready deploy bundle
* and zip it up for transfer into the data center.
*
* Why a packager instead of `docker build` in the repo:
* The agent's index.js imports the shared cisco-dect + httpDigestAuth
* modules via relative paths (`../integrations/cisco-dect/...`,
* `../utils/httpDigestAuth.js`). A raw `docker build dect-relay-agent/`
* would fail because the shared files live OUTSIDE the build context.
* This packager copies them into a self-contained `workspace/` tree
* inside the bundle so the container build sees them as local paths
* without any source rewriting.
*
* What ends up in the bundle:
* dect-relay-agent-bundle/
* Dockerfile (from dect-relay-agent/Dockerfile)
* docker-compose.yml (from dect-relay-agent/docker-compose.yml)
* .dockerignore (from dect-relay-agent/.dockerignore)
* .env.example (from dect-relay-agent/.env.example)
* README.md (deploy-focused; generated below)
* BUNDLE_INFO.txt (build metadata: git sha, timestamp, sizes)
* workspace/
* dect-relay-agent/
* package.json
* index.js
* integrations/cisco-dect/{client,probes,statusXml}.js
* utils/httpDigestAuth.js
*
* Output:
* dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
*
* Usage:
* npm run package:relay
* node scripts/packageDectRelayAgent.js [--out dist] [--name my-bundle.zip]
*
* Requirements:
* - Node 20+
* - `zip` on the PATH (macOS + every mainstream Linux distro ship it)
*/
import { spawnSync, execSync } from 'node:child_process';
import { mkdirSync, cpSync, writeFileSync, rmSync, existsSync, statSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// ─── CLI parsing (tiny) ────────────────────────────────────────────
const args = process.argv.slice(2);
function argVal(flag, fallback) {
const i = args.indexOf(flag);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
}
const outDir = path.resolve(REPO_ROOT, argVal('--out', 'dist'));
const explicitZipName = argVal('--name', null);
// ─── Sources to copy into the bundle ───────────────────────────────
//
// Each entry is `[repo-relative source, bundle-relative destination]`.
// Kept as a plain list rather than a glob so it's obvious what's
// shipped — an accidental include of secrets or the entire repo
// would be a review-visible diff here.
const AGENT_FILES = [
['dect-relay-agent/package.json', 'workspace/dect-relay-agent/package.json'],
['dect-relay-agent/index.js', 'workspace/dect-relay-agent/index.js'],
];
const SHARED_FILES = [
['integrations/cisco-dect/client.js', 'workspace/integrations/cisco-dect/client.js'],
['integrations/cisco-dect/probes.js', 'workspace/integrations/cisco-dect/probes.js'],
['integrations/cisco-dect/statusXml.js', 'workspace/integrations/cisco-dect/statusXml.js'],
['utils/httpDigestAuth.js', 'workspace/utils/httpDigestAuth.js'],
];
// Docker artifacts + operator-facing files live NEXT TO the workspace
// (not inside it) because Dockerfile's `COPY workspace/ ./` treats
// workspace as the entire in-container /app tree.
const DEPLOY_FILES = [
['dect-relay-agent/Dockerfile', 'Dockerfile'],
['dect-relay-agent/docker-compose.yml', 'docker-compose.yml'],
['dect-relay-agent/.dockerignore', '.dockerignore'],
['dect-relay-agent/.env.example', '.env.example'],
];
// ─── Helpers ───────────────────────────────────────────────────────
function log(msg) { process.stdout.write(`[package-relay] ${msg}\n`); }
function err(msg) { process.stderr.write(`[package-relay] ${msg}\n`); }
function stamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` +
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
}
function gitSha() {
try {
return execSync('git rev-parse --short HEAD', { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'] })
.toString().trim();
} catch { return 'unknown'; }
}
function gitDirty() {
try {
const out = execSync('git status --porcelain', { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'] })
.toString().trim();
return out.length > 0;
} catch { return false; }
}
function copyMany(pairs, bundleDir) {
for (const [src, dst] of pairs) {
const absSrc = path.join(REPO_ROOT, src);
const absDst = path.join(bundleDir, dst);
if (!existsSync(absSrc)) {
throw new Error(`Missing required source file: ${src}`);
}
mkdirSync(path.dirname(absDst), { recursive: true });
cpSync(absSrc, absDst);
}
}
function humanBytes(n) {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0, v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
function dirSize(dir) {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) total += dirSize(p);
else if (entry.isFile()) total += statSync(p).size;
}
return total;
}
// ─── Bundle README ─────────────────────────────────────────────────
//
// Generated fresh at package time so it can reference the actual bundle
// version + build sha. Kept short — the operator's whole workflow is
// three commands.
function renderBundleReadme({ version, sha, dirty }) {
return `# DECT Relay Agent — deploy bundle
**Version:** \`${version}\`
**Source commit:** \`${sha}\`${dirty ? ' _(uncommitted changes present at build time)_' : ''}
This zip contains everything needed to run the DECT relay agent as a
Docker container inside the data center. The bot side (public cloud)
must already have \`DECT_RELAY_AGENT_TOKEN\` set to the same value
you'll use in \`.env\` below.
## Deploy
\`\`\`bash
# 1. On the DC host unzip somewhere sensible
unzip dect-relay-agent-bundle-${version}.zip
cd dect-relay-agent-bundle-${version}
# 2. Configure never commit this .env
cp .env.example .env
\${EDITOR:-vi} .env
# Required:
# DECT_RELAY_BOT_URL (wss://<your-bot-host>/dect-relay/ws)
# DECT_RELAY_AGENT_TOKEN (same value as the bot's .env)
# DECT_ADMIN_PASSWORD (DBS-210 serviceability password)
# 3. Build + start
docker compose up -d --build
# 4. Verify you should see "Connected — sending hello"
docker compose logs -f
\`\`\`
On the bot side, look for these log lines to confirm the socket is up:
\`\`\`
[dect:relay-hub] Agent connected from <dc-ip>
[dect:relay-hub] Agent hello: version=0.1.0 host=<hostname> caps=collect,reboot,...
\`\`\`
## Ongoing operations
| Task | Command |
|---|---|
| View live logs | \`docker compose logs -f\` |
| Restart agent | \`docker compose restart\` |
| Stop agent | \`docker compose down\` |
| Upgrade | Unzip the new bundle over the existing dir, then \`docker compose up -d --build\` |
| Rotate token | Change \`DECT_RELAY_AGENT_TOKEN\` in both \`.env\` files (agent + bot); \`docker compose restart\` on the agent side, restart the bot; expect ~1 reconnect gap |
## What's in this bundle
\`\`\`
Dockerfile multi-stage alpine build, non-root user, tini entrypoint
docker-compose.yml restart:unless-stopped, JSON log rotation, health check
.dockerignore defensive; the bundle already excludes cruft
.env.example annotated template copy to .env
README.md this file
BUNDLE_INFO.txt build metadata (git sha, timestamp, file sizes)
workspace/ in-container /app tree
dect-relay-agent/ the agent's own code + package.json
integrations/ shared cisco-dect modules (parity with the bot)
utils/ shared HTTP Digest auth utility
\`\`\`
## No-internet DC option
If the DC host can't reach the npm registry to install dependencies
during \`docker build\`, build the image on a machine WITH internet
access and ship the image tarball:
\`\`\`bash
# On the internet-connected machine:
docker compose build
docker save dect-relay-agent:latest | gzip > dect-relay-agent-image.tar.gz
# Transfer dect-relay-agent-image.tar.gz + docker-compose.yml + .env to
# the DC host, then:
gunzip -c dect-relay-agent-image.tar.gz | docker load
docker compose up -d # skips build; uses loaded image
\`\`\`
## Troubleshooting
**\`RELAY_NOT_CONNECTED\` on the bot side.** Agent isn't reaching the
bot. Check \`docker compose logs\` — most common causes: wrong
\`DECT_RELAY_BOT_URL\`, wrong \`DECT_RELAY_AGENT_TOKEN\` (bot logs
\`Unauthorized upgrade attempt\`), or corporate proxy blocking
outbound wss. Test with \`docker compose exec dect-relay-agent wget -S -O- https://<bot-host>/health\`.
**Agent reconnects in a loop.** Check the agent's logs for a specific
\`error\` line. If it says the bot returned 401, the token is wrong.
If TLS errors, the bot's cert chain isn't trusted inside the
container mount \`/etc/ssl/certs\` from the DC host into the
container.
**\`DIGEST_401\` in agent logs when servicing a \`collect\`.** Wrong
\`DECT_ADMIN_PASSWORD\`. Confirm against Control Hub → Calling →
Features DECT Networks Manage Manage DECT serviceability
password.
`;
}
// ─── Main ──────────────────────────────────────────────────────────
async function main() {
const version = stamp();
const sha = gitSha();
const dirty = gitDirty();
const stagingRoot = path.join(REPO_ROOT, '.package-relay-tmp');
const bundleName = `dect-relay-agent-bundle-${version}`;
const bundleDir = path.join(stagingRoot, bundleName);
const zipName = explicitZipName || `${bundleName}.zip`;
const zipPath = path.join(outDir, zipName);
// Fresh staging tree every run.
if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true, force: true });
mkdirSync(bundleDir, { recursive: true });
mkdirSync(outDir, { recursive: true });
log(`Building bundle "${bundleName}"`);
log(` git sha: ${sha}${dirty ? ' (dirty tree)' : ''}`);
log(` staging: ${bundleDir}`);
log(` output: ${zipPath}`);
try {
log('Copying agent sources into workspace/');
copyMany(AGENT_FILES, bundleDir);
log('Copying shared cisco-dect + digest-auth modules into workspace/');
copyMany(SHARED_FILES, bundleDir);
log('Copying deploy artifacts (Dockerfile, compose, .env.example)');
copyMany(DEPLOY_FILES, bundleDir);
log('Rendering deploy README.md + BUNDLE_INFO.txt');
writeFileSync(
path.join(bundleDir, 'README.md'),
renderBundleReadme({ version, sha, dirty }),
'utf8',
);
writeFileSync(
path.join(bundleDir, 'BUNDLE_INFO.txt'),
[
`bundle: ${bundleName}`,
`built: ${new Date().toISOString()}`,
`commit: ${sha}${dirty ? ' (dirty)' : ''}`,
`staged bytes: ${humanBytes(dirSize(bundleDir))}`,
].join('\n') + '\n',
'utf8',
);
log(`Staged ${humanBytes(dirSize(bundleDir))} across ${AGENT_FILES.length + SHARED_FILES.length + DEPLOY_FILES.length + 2} files`);
if (existsSync(zipPath)) {
log(`Removing pre-existing ${zipName}`);
rmSync(zipPath);
}
log('Creating zip archive (running `zip -r`)');
const zipResult = spawnSync(
'zip',
// -r recursive, -q quiet, -X strip extra attrs for smaller cross-OS zip.
// Run inside the staging root so paths inside the zip start with the
// bundle-name folder (rather than absolute-repo-path prefix).
['-r', '-q', '-X', zipPath, bundleName],
{ cwd: stagingRoot, stdio: 'inherit' },
);
if (zipResult.error) throw zipResult.error;
if (zipResult.status !== 0) {
throw new Error(`zip exited with code ${zipResult.status} — is the \`zip\` binary installed?`);
}
} finally {
// Always clean up the staging tree, even on failure — leaving a
// partial bundle around is confusing next run. The zip in dist/
// survives.
if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true, force: true });
}
const finalBytes = statSync(zipPath).size;
log('─────────────────────────────────────────────────────');
log(`✔ Bundle written: ${path.relative(REPO_ROOT, zipPath)}`);
log(` ${humanBytes(finalBytes)} on disk`);
log('');
log('Next steps:');
log(` 1. Transfer ${zipName} to the DC host (scp / rsync / etc.)`);
log(` 2. On the DC host: unzip ${zipName} && cd ${bundleName}`);
log(` 3. Fill in .env from .env.example`);
log(` 4. docker compose up -d --build`);
log(` 5. docker compose logs -f # look for "Connected — sending hello"`);
log('─────────────────────────────────────────────────────');
}
main().catch((e) => {
err(`FAILED: ${e.message}`);
if (process.env.DEBUG) err(e.stack || '');
process.exit(1);
});