#!/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); });