Package dect-relay-agent as a Docker deploy bundle

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-<YYYYMMDD-HHMMSS>.zip` (30KB).
- Cleans staging in a finally block so failed runs don't leak.

Bundle layout (matches Dockerfile expectations):
  dect-relay-agent-bundle-<version>/
    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.
This commit is contained in:
Joseph McQueen 2026-07-03 09:32:26 -04:00
parent 96b26a5aca
commit e8500b4324
7 changed files with 555 additions and 19 deletions

16
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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-<YYYYMMDD-HHMMSS>.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.

View file

@ -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

View file

@ -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",

346
scripts/packageDectRelayAgent.js Executable file
View file

@ -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-<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);
});