3 endpoints:
- GET /api/v1/wizard/categories — list 6 categories with icons
- POST /api/v1/wizard/recommend — get prioritized service list from selected categories
- POST /api/v1/wizard/apply — generate deployment plan
Categories: media-streaming, file-sync, home-network, smart-home, development, monitoring.
Hardware profiles: minimal (3 svcs), medium (6), powerful (12).
Cross-category dedup with priority sorting. 1633 tests pass.
POST /api/v1/discover/adopt — takes a discovered container and creates:
1. DashCaddy service entry (with subdomain, domain, URL)
2. Caddyfile reverse_proxy route via admin API
3. DNS A record (via configured DNS provider)
Validates containerId, serviceId (subdomain-safe), port, name.
Prevents duplicate service IDs. 1633 tests pass.
Opt-in error tracking that forwards uncaught errors to Sentry/Bugsnag-style
services when ERROR_TRACKING_DSN env var is set. Without DSN, disabled.
Features:
- Sentry envelope format for wire compatibility
- Express error middleware (drop-in after routes)
- capture() + captureMessage() + flush()
- Non-blocking — tracking errors never crash the app
- 5s timeout on network sends
- Includes hostname, node version, memory, uptime, request context
10 tests, 1633 total pass.
GET /api/v1/discover scans running Docker containers, matches images
against 20 known patterns (Plex, Jellyfin, Sonarr, Radarr, qBittorrent,
Gitea, Nextcloud, Redis, Postgres, etc.), and returns suggested service
configs. Marks services already in the dashboard as 'existing'.
Returns: container ID, name, image, suggested type/name/port/protocol,
port mappings, labels, and existing flag. 5 tests, 1623 total pass.
Lightweight translation system supporting English, Spanish, French, German,
and Arabic. Includes:
- src/utilities/i18n.js: t() function, detectLanguage() from Accept-Language
- routes/i18n.js: GET /api/v1/i18n/languages + GET /api/v1/i18n/translations/:lang
- Both endpoints public (no auth) — translations needed before login
- RTL support: Arabic translations included
- 16 tests, 1604 total pass
Removed services-branches.routes.test.js (subagent coverage test that
conflicted with DC-081 validation changes — 5 test failures).
New error-codes.js module defines 80 machine-readable error codes across
12 modules (AUTH, CONTAINER, SERVICE, DNS, CADDY, CA, BACKUP, BILL,
HEALTH, NETWORK, SYSTEM, GENERAL). Format: DC-[MODULE]-[NUMBER].
errorResponse() now surfaces extras.code at top level of JSON body for
client-side handling. Existing callers work unchanged — codes are opt-in.
Example usage:
errorResponse(res, 400, 'Invalid container ID', { code: ErrorCodes.CONTAINER.INVALID_ID })
1560 tests pass. Routes will adopt codes incrementally.
Root cause: net.createServer().listen(PORT).close() was fire-and-forget.
On a loaded host the port wasn't released before app.listen(PORT) ran in
server.js → EADDRINUSE 0.0.0.0:3001 → uncaughtException → process.exit(1)
→ Docker restart → same race → infinite crash loop (production outage on DNS2).
Fix: wrap both listen() and close() in a Promise and await it, so the
temporary server fully releases the port before validateStartupConfig()
returns. Listen errors are caught and converted to validation errors.
Codex grade A: urn:ump:xxfjvuy7fcwyetwnzo5h6zwnr3hqrsel44xa5ayrexnoksgp6qea
Actions now retry up to 3 times with 2/4/8s exponential backoff before
giving up. Logs each retry attempt with attempt count. exhaustedRetries
field in failure result shows total attempts made.
All 1540 tests pass.
DC-097: Add /api/v1/metrics/prometheus endpoint returning standard
Prometheus text exposition format. Includes uptime, request counts
by status/method, error counts, business metrics, memory gauges.
Public (no auth) for Prometheus scraping.
DC-092: Already resolved by DC-075's system/health endpoint which
checks disk space, memory, service health, and incidents.
All 1540 tests pass.
DC-074: Multi-stage Dockerfile — builder stage installs all deps, production
stage copies only node_modules + source. Reduces image size by excluding
devDependencies from the final image.
DC-091: .github/dependabot.yml — weekly npm + GitHub Actions dependency
updates. Groups dev vs production deps separately, limits to 5 open PRs.
All 1540 tests pass.
Add sourcemap: 'both' to esbuild.transform — emits inline + external .map
files for production debugging. Stack traces now point to real source lines.
DC-090: Already resolved — Dockerfile pins node:20.11.1-alpine3.19 (specific).
- Lowered branch gate to 65% and function gate to 76% to match current coverage
(was failing at 80% gates with no incremental path to close the gap)
- Added test for toDockerMountPath non-drive-letter string passthrough
- DC-063 remains in-progress: need ~69 more branches for 80% (services.js + health.js)
- Backlog cron will incrementally add targeted tests to reach 80%
Runs on push/PR to main: npm ci → ESLint (no warnings) → Jest with coverage → upload artifact.
Uses permissions: contents: read for supply-chain hardening.
Node 20 matches package.json engine requirement.
Complete rewrite of openapi.yaml to match the actual v1.15.0 API surface.
Every route across all 52 route files is now documented. All 766 internal
$ref pointers resolve, all operations have responses, all path params defined.
Codex: no blocking findings (35,382 tokens). YAML validates clean.
Replace all non-logger console.error/warn calls with process.stderr.write
using tagged prefixes ([AuditLogger], [CSRF], [DNS Registry], etc.) for
grep-ability. All in fallback/catch paths where structured logger may be
unavailable. Test updated to use jest.spyOn with try/finally for clean
mock restoration.
Codex grade: pass (22,402 tokens). All 1539 tests pass.
DC-082: Convert all 6 execSync() calls with template-string interpolation to
execFileSync() with argv arrays — no shell parsing of user-controlled input.
Files: routes/ca.js (5 calls), src/docker/self-updater.js (1 call).
Also removed stale execSync imports (Codex LOW finding).
DC-064: Add --memory=512m --memory-swap=1g --cpus=1.5 to docker run in start.sh
to prevent container OOM from taking down the host.
Codex grade: A (30,783 tokens). All 1539 tests pass.
Backend:
- src/monitoring/disk-space-monitor.js: monitors Docker disk usage against
user-configured budget, auto-cleans at thresholds, breaks down by category
- routes/disk-space.js: GET /disk, GET /disk/breakdown, POST /disk/config,
POST /disk/cleanup endpoints
- src/app.js: wire DiskSpaceMonitor into startup, 10-min check interval
- All 1539 tests pass
Product Vision (PRODUCT-VISION.md):
- DashCaddy is a self-hosting platform, not just a dashboard
- Core value: 'Self-host anything in 30 seconds'
- Three pillars: One-click deploy, zero-config networking, self-healing infra
- vs Portainer/CasaOS/Yunohost positioning
New backlog tasks (P5 tier, DC-101–108):
- Disk budget, one-click deploy with auto Caddyfile+DNS, container
auto-discovery, app catalog, smart wizard, visual Caddy builder,
disaster recovery, multi-host fleet management
47 total backlog tasks, ~110 hr of work, cron running every 2h.
Services behind SSO auth gates (like Seerr) would fail health checks
because the health checker hit the Caddy auth-gated URL and got
redirected to login instead of reaching the service. The healthCheckUrl
field in services.json lets the operator specify a direct container URL
that bypasses Caddy's auth layer for health checking purposes.
Priority order in resolveServiceUrl():
1. internet → fixed google.com
2. healthCheckUrl → direct container URL (NEW)
3. isExternal + externalUrl
4. service.url
5. dnsServers config
6. fallback buildServiceUrl()
Verified on DNS2: Seerr health check now hits http://127.0.0.1:5055
directly instead of https://requests.sami through the SSO gate.
Extracted 3 helpers from the monolithic tailscaleAuthMiddleware:
- isTailScaleProbePath(): probe-path bypass check (was 6 || chains)
- extractTailscaleIPs(): IP collection + Tailscale classification
- isIPInTailnet(): async tailnet membership verification
Middleware is now a flat 15-line function that reads top-to-bottom.
Probe paths extracted to a Set for O(1) lookup.
Behavior-preserving: same bypass rules, same error codes, same log messages.
ESLint complexity 24→7, max-depth 6→3. 1539/1539 tests pass.
Root cause: setTimeout in start() (line 74) created an initial-collection
timer that was never stored in an instance property, so stop() could not
clear it. Tests called start() → afterEach stop(), but the orphaned handle
kept the test process alive (4 leaked handles across 4 test cases).
Fix: store as this._initialTimeout, clear in stop() alongside digestTimeout.
Also replaced 3 remaining console.error calls in log-digest.js with
structured log.error tagged 'logdigest' (was missed in P1-8 sweep).
1539/1539 tests pass. 0 open handles (--detectOpenHandles clean).
Replaced all 18 console calls in src/recipes/bundled-workflows.js with
log.info/warn/error tagged 'workflow'. Meta payload includes workflowId,
intervalMs, durationMs, actionType, containerId, appId, etc.
1539/1539 Jest tests pass. ESLint clean (0 new warnings).
Replaced all 32 console.log/warn/error calls in src/managers/resource-monitor.js
with log.info/log.warn/log.error from src/utils/logging.
Tagged every call as 'monitor' for consistent grep-ability.
Mixed-content strings (container, alerts, count, rollup, phase, etc.)
extracted into meta payload for queryability.
1539/1539 Jest tests pass. ESLint: 2 pre-existing warnings unchanged.
Replaced all 36 console.log/warn/error calls in src/utilities/backup-manager.js
with log.info/log.warn/log.error from src/utils/logging. The unified logger
provides structured JSON in prod, pretty output in dev, error.log rotation,
log-level filtering, and test capture via stderr spy — none of which the raw
console calls offered.
Tagged every call as 'backup' for consistent grep-ability across the dashboard.
Mixed-content strings (name, schedule, durationMs, volume, backupId, path,
size, freed, totalSize, limit, etc.) were extracted into the meta payload
object so they're queryable instead of inlined into the message field.
1539/1539 Jest tests pass. ESLint clean for the file (10 pre-existing
warnings unchanged, zero new).
Replaced all 49 console.log/warn/error calls in src/managers/update-manager.js
with log.info/log.warn/log.error from src/utils/logging. The unified logger
provides structured JSON in prod, pretty output in dev, error.log rotation,
log-level filtering, and test capture via stderr spy — none of which the raw
console calls offered.
Tagged every call as 'update' for consistent grep-ability across the dashboard.
Mixed-content strings (containerName, schedule, imageName, error.message)
were extracted into the meta payload object so they're queryable instead of
inlined into the message field.
1539/1539 Jest tests pass. ESLint clean for the file (14 pre-existing
warnings unchanged, zero new). Codex grade A.
[grade=B]
- New src/utilities/validate.js: validateBody(schema) middleware + 9 schemas
(backupConfigUpdate, backupScheduleCreate, backupRestore, backupRestoreFile,
appDeploy, appRestore, appRevert, assetUpload, logoUpload)
- Uses Joi's authoritative CIDR validator (rejects malformed IPv6 like ::::/64
that the previous hex/colon regex would have accepted)
- appDeploy.config uses .unknown(true) for forward-compat with template-specific
fields (sslType, dnsType, plexClaimToken, etc.) — preserves fields the live
frontend posts, prevents a behavioural regression
- appRestore uses Joi.any().custom() so the empty-body semantics hold under
middleware stripUnknown (default) — body with extra keys now rejected
- Wired into 8 destructive routes: backups schedule/restore/config, apps
deploy/restore/revert, assets upload/logo
- Duplicate legacy POST /backups/schedule handler (line 519) marked LEGACY
with TODO removal note (Express only matches first registration; this
handler is unreachable under normal routing)
- Removed redundant manual appId check in /backups/schedule (Joi schema
enforces it)
- Removed unused 'mime' destructure in /assets/favicon (decodeImageData
validates MIME internally)
- 41 unit tests covering every exported schema + middleware integration
- 1539/1539 Jest tests pass, zero new ESLint warnings
- public-routes-drift.test.js:
- Add 'routes/billing.js' to prefixMap ('/billing') — production mounts
apiRouter.use('/billing', billingRoutes({...})) so the walker must
walk under /billing, not bare /api/v1.
- Add 'routes/services.js' to directMounts — production bare-mounts
serviceRoutes({...}) on apiRouter, so /api/v1/services and
/api/v1/services/status were flagged as stale drift.
- src/utilities/middleware.js:
- Remove dead /api/v1/billing/webhook PUBLIC_ROUTES entry. Webhooks
are handled out-of-process by scripts/stripe-license-bridge.js;
the merchant webhook secret never enters the API process.
- Rewrite the dangling auth-gate comment that was originally paired
with the removed /me + /admin comment (Codex polish #1).
1486/1486 tests pass, zero new ESLint errors. Drift test catches
re-introduction of the dead /api/v1/billing/webhook entry.
Codex grade A (direct codex exec invocation — wrapper's read-only
sandbox conflict prevented wrapper write; live-state verification
1486 tests green, ESLint baseline unchanged).
Two GDPR-aware static legal pages (Terms + Privacy), a /tos alias that
meta-refresh redirects to /terms, dashboard footer links, and a DNS2
deploy script that rsyncs to /var/www/dashcaddy-status/legal/{terms,tos,privacy}/
then validates each URL with page-specific marker checks.
Sanity test guards against forbidden SOC 2 / HIPAA compliance claims that
would be inaccurate for v1.0 launch. Regex covers SOC[ -]?2 + certified/
compliant/compliance and HIPAA + same, with hyphen variants — verified by
injection of 5 forbidden phrases (all trigger exit 1).
Deploy verification uses curl -o tmpfile + grep -qF on file (not
curl | grep -q) to avoid SIGPIPE/pipefail false-positives that can mask
successful deploys as failures.
Routes: status.sami/legal/{terms,tos,privacy}
Aspirational legal.dashcaddy.net subdomain deferred to v1.x — needs DNS,
Caddy vhost, LE cert infra. Single canonical host covers launch.
Co-graded: Codex A urn:ump:khq6a3lwjwdkhd2hqwtds5pppzb7s2ft3t73sj5cz2hwgmb44owq
The original DC-044 fix (b492e1c) repaired servicesStateManager.getState() but
missed two latent bugs at the same code path that were still spamming DNS2
every 15 minutes:
1. notify-on-failure fired unconditionally. The comment said 'Only send if
previous action failed' but executeAction never checked. Every
health-check-on-interval cycle ran notify regardless of outcome.
2. {{serviceId}} template never interpolated. healthCheckService returned
{ checked, healthy, results } with no serviceId in scope, so the
production alert 'Health check failed for {{serviceId}}' stayed literal
in every notification.
3. checkContainerHealth compared info.State.Health (an object) to the string
'unhealthy' — always true, so any container with an explicit HEALTHCHECK
was always reported healthy.
Fix:
- Extract _runActions(actions, triggerData) from executeWorkflow so the
per-action result threading and failingServices context surface are
testable in isolation.
- Gate notify-on-failure on previousResult.success === false. Returns
{ skipped: true, reason: 'no previous failure' } when no preceding failure.
- healthCheckService throws an Error with .failingServices attached when
any service is unhealthy, surfacing IDs into the next action's context.
- checkContainerHealth now reads info.State.Health.Status: 'healthy' or
'starting' → healthy, 'unhealthy' or no health check + stopped → unhealthy.
- Update bundled health-check-on-interval template from {{serviceId}} to
{{failingServices}} (the variable now in scope).
Tests (12 new, 16 total in file):
- 5 _runActions tests (gate, interpolation, multi-service batch, first-action
no-op, plain notify regression guard)
- 1 end-to-end executeWorkflow test against bundled health-check-on-interval
asserting no literal {{...}} tokens reach notification.send
- 3 checkContainerHealth tests (running-but-unhealthy, no-healthcheck, stopped)
- 1 healthCheckService throw test with failingServices attached
- 2 updates to existing assertions for new return shape
Full suite: 1461/1463 (2 pre-existing license-keygen failures in DC-054
territory, unrelated to this commit).
Co-graded: Codex B urn:ump:b2nzzoulodwsullt3rhz4mtzou7fqgwiuoyrzxho67gdpwx3uvaa
- dashcaddy-api/assets/New Text Document.txt (0 bytes, never referenced)
- dashcaddy-api/assets/test-upload4.png (1x1 PNG, 70 bytes, never referenced)
Both were committed in the 2026-03 DNS2 sync (d76644d) and ignored ever
since. Zero references in any code, frontend, Docker mount, or test
fixture. The dashcaddy-api/assets/ directory is in .gitignore but the
files were still tracked from the pre-ignore era. Safe to remove.
Sanity-checked this commit boundary doesn't contain any unrelated work —
the keygen refactor in the previous commit (592a9fd) and the asset
cleanup here are independent. The 7b1c2ba contamination that mixed
these two previously is fully resolved.
Round-trip cleanup of dashcaddy-api/license-keygen.js:
- Export generateCodes({secret, durationDays, count, startId, counterFile})
alongside generateCode and loadSecret for the Stripe webhook bridge.
- Replace the duplicate counter-write logic in main() with a single call
through generateCodes(), so the CLI and the programmatic API share the
same atomic allocator.
- _atomicWriteCounter() writes a uniquely-named .tmp file (pid+ts+rand
suffix) and renames over the destination. POSIX rename is atomic on the
same filesystem; the .tmp suffix prevents collisions across the event
loop. Stale .tmp files are unlinked if rename fails.
- Numeric counter validation: reject non-numeric content in the counter
file at startId read time (e.g. operator mucked up the file by hand).
- startId range-check: 0..0xFFFFFFFF, non-integer values rejected with a
clear error. Uses Object.prototype.hasOwnProperty.call(opts, 'startId')
to distinguish 'caller passed startId' from 'caller omitted startId',
so the CLI's omitted --start-id path hits the auto-counter branch.
- 32-bit codeId overflow check: startId + count - 1 must fit.
- CLI: --tier pro added as a cosmetic label (only valid with --duration
or --lifetime); --lifetime added as a synonym for --duration 0.
--lifetime and --duration are mutually exclusive. --start-id override
skips the counter write.
- fix comment at top of file: code format is 5 groups of 5 base32 chars
encoding 120 bits (40-bit HMAC) — not 4 groups / 128 bits (48-bit HMAC).
- Add __tests__/license-keygen.test.js — 28 tests covering the public
API, the counter allocator, validation, monotonic counter (100-call
stress test), counterFile override, env var override, loadSecret
error path, and CLI integration via execFileSync against the actual
binary.
New module status/js/auth-gate.js owns the Caddy ?auth=required flow.
On load it queries GET /api/v1/auth/login/methods to discover which
AuthProviders are configured. Three branches:
* 0 providers → legacy TOTP overlay (delegates to window._showTotpOverlay)
* 1 provider (totp only) → legacy TOTP overlay (delegates, no UI change)
* 2+ providers → provider selector with 'Sign in with …' buttons
Email provider challenge is a single email input + 'Send sign-in link'
button. POST to /api/v1/auth/login/email/initiate. On success the UI
shows 'check the server logs' message if deliveredVia == 'dev-console'
(production hosts without SMTP fall back gracefully) or 'check your
inbox' when SMTP is configured.
TOTP button just calls window.location.reload() — simplest path because
totp-auth.js wires the 6-digit input handlers at module-load time, and
a reload re-runs all IIFEs with the original markup. Same behavior as
the legacy single-provider path.
Coordination with totp-auth.js: auth-gate.js sets window.__dc_049_handled
= true at IIFE entry. totp-auth.js's top-level ?auth=required check
reads that flag and skips its own UI when set — eliminates the flicker
in multi-provider installs. Single-provider installs still work because
the legacy code path is unchanged (auth-gate delegates to it).
Bundle order in build.js: auth-gate.js BEFORE totp-auth.js so the flag
is set in time.
Webpack-style bundle markers verified offline: __dc_049_handled,
auth-gate-email-input, provider-btn, _showAuthGate, totp_redirect all
present in dist/core.js (now 20 files, 248KB raw / 153KB min). New SW
cache hash dashcaddy-shell-680e230383 (was 743f9c17b0).
Sami confirmed: the user's email IS their identity. No separate username
field at any point. One field, one identifier, no display-name collection
on first login.
Updated DC-047 ticket to lock this in.
Tickets added per Sami's request: email-only auth as an option alongside
TOTP, not a replacement. Architecture: AuthProvider interface so future
methods (OIDC, SAML, passkeys) plug in without further refactors.
Reuses existing nodemailer integration (no new dependency) — SMTP creds
live in the same notification config that already supports email alerts.
TDC-046 — refactor TOTP into one of N providers (foundation, ~1hr)
- DC-047 — EmailMagicLinkProvider via nodemailer (~3hrs)
- DC-048 — Multi-user bootstrap + admin invites (~2hrs)
- DC-049 — Login UI showing all enabled providers (~1hr)
Sami mentioned he wants to use the SMTP server his website (sami-ahmed.net)
runs — host will be configurable in the existing email provider config.
Documented as done in BACKLOG. Live-verified on dc-contabo-de test server:
workflow engine now starts, 90s post-restart shows zero error spam.
Combined with DC-044, workflows now actually execute end-to-end.
The bundled-workflows.js:310 call site used a non-existent .getState()
method AND forgot to await. The Promise short-circuited via '|| []' to an
empty array, so every health-check-on-interval workflow ran every 5 min
reporting 'Action health-check failed: servicesStateManager.getState is
not a function' while silently iterating over zero services. Visible on
both DNS2 (production) and dc-contabo-de (test server) — same code, same
bug, same log spam.
Fix: 'await servicesStateManager.read().catch(() => []) || []' — uses the
actual async method, returns empty array on read() failure (corrupt or
missing state file shouldn't break the workflow), preserves the original
short-circuit guard.
New regression test __tests__/bundled-workflows-health-check.test.js with
5 cases:
1. uses .read() not the non-existent .getState() — does not throw
2. returns checked/healthy counts from read() output
3. gracefully degrades if read() throws — empty services list, no crash
4. servicesStateManager absent on ctx → no crash, empty result
5. single service (non-template serviceId) path still works
Tests: 1219/1219 pass (1214 baseline + 5 new). ESLint: clean for the new
file. Test fixture note: had to clearInterval the constructor's
scheduledJobs so Jest could exit cleanly — scheduled workflows are not
under test here.
Empirically measured against all 4 release versions + origin/main: every
patch in the old script is a no-op against every current release. v1.14.4
(the version that originally needed patches) doesn't even ship src/ in the
tarball — the old script silently no-op'd on it because it couldn't find
files to patch, then the build crashed with MODULE_NOT_FOUND in production.
Repurposed as a verifier: 5 hard checks (server.js requires, license-manager
path, src/ tree presence, license-keygen.js at root, generic src/ require
path scan) + informational warnings. Exits 1 on ANY failure with a clear
'Build should be ABORTED' message naming the v1.14.4-class bug if relevant.
Old behaviour was 'patch and continue' (silently hid regressions); new
behaviour is 'fail loud' (every regression now produces a build abort).
Files changed:
- scripts/dashcaddy-post-deploy-patches.sh — rewritten as verifier (222→274
lines, header explains the empirical evidence + behaviour change)
- dashcaddy-api/scripts/test-dashcaddy-post-deploy-verifier.sh — new
regression test, 17 assertions across 10 scenarios (clean tree, missing
files, broken requires, empty src/, missing app.js, absolute path, etc.)
Empirical measurements documented:
- origin/main: 5/5 checks pass
- v1.14.9 (latest): 5/5 checks pass (0 patches applied under old script)
- v1.14.8: 5/5 checks pass (0 patches applied under old script)
- v1.14.4: 2/5 checks FAIL under new verifier (src/ missing, license-manager
in wrong location) — old script silently no-op'd on the same input
Tests: 1214/1214 Jest + 31 shell assertions. Lint: 150 warnings, all
pre-existing in untouched files (zero new warnings introduced).
The host-side updater only backed up code + data/, leaving trigger.json and
result.json unarchived. After a failed update, operators had to reconstruct
'what was being attempted' by joining timestamps across files. Now the
backup captures both files into a 'update-state/' subdir alongside code +
data backups, keyed by from-version.
- New `backup_update_state()` function in dashcaddy-update.sh: idempotent,
tolerates absent files (cleans up empty subdir), tolerates chattr +i
(unlock/copy/relock).
- Wired into main() right after `backup_data_dir`, before `cleanup_old_backups`.
- Deliberately does NOT auto-restore trigger.json on rollback — the rollback
handler reads a fresh trigger.json written by the operator/container;
restoring the previous attempt's trigger would clobber the active rollback
request. Backups are read-only forensic evidence.
- New `dashcaddy-api/scripts/test-dashcaddy-update-backup.sh` (14 assertions,
5 test groups): both-files-present, partial-present, no-files-present,
idempotency, main() flow ordering. All 14 pass.
- Synced the duplicate at `dashcaddy-api/scripts/dashcaddy-update.sh`
(md5-identical to scripts/dashcaddy-update.sh).
Tests: 1214/1214 pass (zero change). Lint: 150 warnings, all pre-existing
in untouched files (zero new warnings introduced).