From e8500b43243858b25ce1f8b7d2041ddff2a23040 Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Fri, 3 Jul 2026 09:32:26 -0400 Subject: [PATCH] Package dect-relay-agent as a Docker deploy bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a one-command packager (`npm run package:relay`) that produces a self-contained zip ready to transfer into the data center and start with `docker compose up -d --build`. Three commands on the DC host: unzip, edit .env, docker compose up. Why a packager instead of `docker build` in the repo: The agent's index.js imports the shared cisco-dect + httpDigestAuth modules via `../integrations/...` paths, so a naive `docker build dect-relay-agent/` would fail because those files live outside the build context. The packager copies them into a `workspace/` tree inside the bundle so the Dockerfile sees them as local paths without any source rewriting. Docker artifacts (in dect-relay-agent/): - Dockerfile: multi-stage node:20-alpine build (~55MB final image), non-root `dect` user (UID/GID 1500), tini as PID 1 for clean SIGTERM propagation to node's graceful-shutdown path, `npm install --omit=dev --ignore-scripts` in the deps stage. - docker-compose.yml: restart:unless-stopped, JSON log rotation (10MB × 5 files), pgrep-based health check. No `ports:` block because the agent is outbound-only (dials the bot). - .dockerignore: defensive — the bundle already excludes cruft, but this hardens against a stray manual build. Packager (scripts/packageDectRelayAgent.js): - Assembles agent code + shared modules + deploy artifacts into a timestamped staging dir (.package-relay-tmp/, git-ignored). - Generates a bundle README with three-command deploy instructions, ongoing-ops table, no-internet-DC fallback (docker save/load), and troubleshooting for the most common failure modes. - Generates BUNDLE_INFO.txt with build metadata (git sha + dirty flag + timestamp + size) so the DC operator can trace deployed bundles back to source. - Emits `dist/dect-relay-agent-bundle-.zip` (30KB). - Cleans staging in a finally block so failed runs don't leak. Bundle layout (matches Dockerfile expectations): dect-relay-agent-bundle-/ Dockerfile, docker-compose.yml, .dockerignore .env.example, README.md, BUNDLE_INFO.txt workspace/dect-relay-agent/{package.json, index.js} workspace/integrations/cisco-dect/{client,probes,statusXml}.js workspace/utils/httpDigestAuth.js Wiring: - package.json: new `package:relay` and `test` npm scripts. - .gitignore: `scripts/` changed to `scripts/*` so `!scripts/ packageDectRelayAgent.js` can re-include just the packager (git forbids re-including files under a fully-excluded directory, hence the glob form). - dect-relay-agent/README.md: rewrites deployment section to show the Docker path as the recommended production route, with the node-directly path kept for local dev. Verified end-to-end: `npm run package:relay` produces a valid zip that unpacks to the expected layout in <2s. All 113 existing tests still pass. --- .gitignore | 16 +- dect-relay-agent/.dockerignore | 20 ++ dect-relay-agent/Dockerfile | 78 +++++++ dect-relay-agent/README.md | 47 ++-- dect-relay-agent/docker-compose.yml | 63 +++++ package.json | 4 +- scripts/packageDectRelayAgent.js | 346 ++++++++++++++++++++++++++++ 7 files changed, 555 insertions(+), 19 deletions(-) create mode 100644 dect-relay-agent/.dockerignore create mode 100644 dect-relay-agent/Dockerfile create mode 100644 dect-relay-agent/docker-compose.yml create mode 100755 scripts/packageDectRelayAgent.js diff --git a/.gitignore b/.gitignore index 57665a9..5accf54 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,16 @@ storage/ # Dev / test artifacts (local only) characterization-runs/ -scripts/ +# 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 characterize-*.js # Backup & temp files @@ -48,6 +57,11 @@ 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/ # Docker / Misc docker-compose.override.yml diff --git a/dect-relay-agent/.dockerignore b/dect-relay-agent/.dockerignore new file mode 100644 index 0000000..ed65481 --- /dev/null +++ b/dect-relay-agent/.dockerignore @@ -0,0 +1,20 @@ +# 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 diff --git a/dect-relay-agent/Dockerfile b/dect-relay-agent/Dockerfile new file mode 100644 index 0000000..44a7553 --- /dev/null +++ b/dect-relay-agent/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1.6 +# +# DECT Relay Agent — production container 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: +# +# workspace/ +# dect-relay-agent/ ← WORKDIR at runtime +# package.json +# index.js +# integrations/cisco-dect/{client,probes,statusXml}.js +# utils/httpDigestAuth.js +# +# 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. + +# ── 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 + +# Only copy the agent's manifest first so this layer caches across +# code-only changes. +COPY workspace/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. +RUN npm install --omit=dev --ignore-scripts \ + && npm cache clean --force + +# ── 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 + +# 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 + +WORKDIR /app + +# 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/ ./ + +# Bring in the deps that stage 1 resolved. +COPY --from=deps --chown=dect:dect /build/node_modules ./dect-relay-agent/node_modules + +USER dect +WORKDIR /app/dect-relay-agent + +# 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"] diff --git a/dect-relay-agent/README.md b/dect-relay-agent/README.md index 2d9818b..6464ea9 100644 --- a/dect-relay-agent/README.md +++ b/dect-relay-agent/README.md @@ -15,7 +15,25 @@ Only one agent is expected to run at a time. If a second agent connects, the bot - Route from the agent host to the bot's public HTTPS endpoint - The DECT serviceability password (Control Hub → Calling → Features → DECT Networks → Manage → Manage DECT serviceability password) -## Install +## Deploy paths + +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: + +```bash +# From the parent repo root: +npm run package:relay +# → writes dist/dect-relay-agent-bundle-.zip +``` + +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`). + +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. + +### 2. Direct node process (dev + local iteration) ```bash cd dect-relay-agent @@ -106,21 +124,16 @@ The bot terminates the socket if no `pong` arrives within 90s; the agent auto-re - The agent enforces no policy — the bot decides who can reboot what. See the bot's audit log for the full record of actions taken (`igmp:audit` style scopes in daily log files). - The agent quarantines mutating actions from probes via the exact same safety model as the CLI tool (`integrations/cisco-dect/probes.js` — GET-triggered actions are only reachable via explicit `trigger*` helpers, never via a generic path fetcher). -## Deploying as a container +## What's in this folder -A `Dockerfile` isn't included yet — production deployment shape is TBD. Minimum viable: +| File | Purpose | +|---|---| +| `index.js` | Agent entrypoint — WSS client + command dispatcher | +| `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 -FROM node:20-alpine -WORKDIR /app -# The agent imports from ../integrations/cisco-dect/, so copy the -# whole workspace (or at least these two paths). -COPY package.json package-lock.json ./ -COPY dect-relay-agent ./dect-relay-agent -COPY integrations/cisco-dect ./integrations/cisco-dect -COPY utils/httpDigestAuth.js ./utils/httpDigestAuth.js -RUN cd dect-relay-agent && npm ci --omit=dev -CMD ["node", "dect-relay-agent/index.js"] -``` - -Set the env vars from `.env.example` via your orchestrator's secret store, not baked into the image. +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. diff --git a/dect-relay-agent/docker-compose.yml b/dect-relay-agent/docker-compose.yml new file mode 100644 index 0000000..e9aae90 --- /dev/null +++ b/dect-relay-agent/docker-compose.yml @@ -0,0 +1,63 @@ +# DECT Relay Agent — one-command deploy for the data center. +# +# 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. +# +# 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) + +services: + dect-relay-agent: + build: + context: . + dockerfile: Dockerfile + image: dect-relay-agent:latest + 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. + env_file: + - .env + + # No `ports:` block on purpose — see header comment. + + # 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. + 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 + + # 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 diff --git a/package.json b/package.json index 2aa917e..265a0bf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "docker:build": "docker compose build", "docker:up": "docker compose up -d", "docker:down": "docker compose down", - "docker:logs": "docker compose logs -f" + "docker:logs": "docker compose logs -f", + "package:relay": "node scripts/packageDectRelayAgent.js", + "test": "node --test tests/*.test.js" }, "dependencies": { "async-mutex": "^0.5.0", diff --git a/scripts/packageDectRelayAgent.js b/scripts/packageDectRelayAgent.js new file mode 100755 index 0000000..f1c1d9c --- /dev/null +++ b/scripts/packageDectRelayAgent.js @@ -0,0 +1,346 @@ +#!/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-.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:///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 +[dect:relay-hub] Agent hello: version=0.1.0 host= 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:///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); +});