npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zincapp/znvault-plugin-webdeploy

v0.3.0

Published

Generic web-app deployment plugin for znvault CLI (rsync/PM2/nginx over SSH-CA)

Readme

@zincapp/znvault-plugin-webdeploy

0.3.0: reviewed nginx ownership

run <name> --config-file <path> consumes a reviewed repository configuration without updating the named config store; relative paths resolve from the working directory. The name still identifies the lock and summary.

Optional nginx.config accepts localPath, remotePath (one sites-available file), enabledPath (the enabled site), upstream and optional previousSha256. Without a predecessor hash it verifies exact bytes only. An explicit reviewed predecessor permits replacement; all other drift is fatal. Before any transfer, all hosts must pass hash, enabled-site, effective include and syntax checks. After app reload, all configured upstreams must accept TCP connections. Managed upstreams deliberately support only explicit IPv4 loopback servers, least_conn and keepalive; unsupported directives fail closed rather than being guessed.

Approved file changes use stdin, unique staging and backup files, double readback, syntax check and graceful reload. Apply/reload/post-host-health failure restores that site's predecessor unless concurrent edits make rollback unsafe. Application rollback is separate, and incomplete rollouts retain old assets. This is not a provisioning command: packages, site files, symlinks and host permissions must already exist. Do not put private keys or secrets in this versioned configuration.

Use requireAll: true on PM2 and ports health specs to make partial failures blocking. Transport failures always fail health checks. TCP reachability and PM2 online are not substitutes for an authenticated application/PDF acceptance.

Existing configs without nginx.config retain the reload-only path. Publishing, installation and each production rollout are separate authorization boundaries; a package release is not evidence that a particular fleet was commissioned.

Generic web-app deployment plugin for the znvault CLI. It deploys static assets and/or a Node app to one or more hosts over rsync + SSH, using short-lived SSH-CA certificates minted by ZnVault instead of long-lived keys, and resolves every secret it needs (API tokens, .env values, rendered files) from the vault at run time via alias: references — nothing sensitive is ever stored in a config file on disk.

It is a CLI-only plugin (no agent-side component), a sibling of znvault-plugin-payara, and is registered with znvault through the same plugin-loader mechanism.

What it does

A single znvault webdeploy run <config> invocation:

  1. Loads a named deploy config and resolves all alias: secret references from ZnVault (in memory only).
  2. Ensures a usable SSH certificate exists (signs a new short-TTL one via POST /v1/ssh/sign if the cached one is missing/expiring/missing the right principal).
  3. For each host, in order, gated:
    • rsyncs the app directory (if app is configured),
    • renders .env and any extra app.files from resolved secrets and writes them to the remote host over SSH stdin (mode 0600),
    • runs corepack use yarn@<version> && yarn install --immutable (if app.yarnVersion is set), skipped if the remote package.json + yarn.lock hash matches the previous stamp file AND node_modules exists — fail-safe, any missing/corrupt stamp/hash forces a fresh install, while any attempted lockfile rewrite fails the deploy,
    • rsyncs static assets in two phases: a new versioned directory first (with scope-limited chown/chmod -R to the new dir only), then an atomic, --delay-updates switchover of shared/HTML files (with scope-limited chown/chmod non-recursive to the webroot itself only),
    • reloads (or first-starts) the app under PM2 with a zero-downtime pm2 reload, polls pm2 describe every 250ms up to a 2-second ceiling instead of a fixed sleep,
    • reloads nginx,
    • runs the configured health checks via one remote shell script that emits idx|STATUS|detail lines (one per check), parsed locally into the same result strings as before — if they fail, the command fails; when more hosts remain, they are skipped (gated rolling deploy).
  4. Once at least one host deployed successfully: purges the Cloudflare CDN cache, waits for propagation, then polls GET /version every 250ms up to a 5-second ceiling instead of a blind 3-second sleep, verifies the served version on every successfully deployed host.
  5. Only after every host, health check, CDN purge and version check succeeds, cleans up old versioned build directories (retention count). Help sync and webhook notification remain best effort.

If any blocking post-deploy gate fails after a host changed, the command exits non-zero, marks the summary recoveryRequired, and retains old versioned assets for recovery. See Exit codes.

Install / registration

The package is published on npm. A local-path registration is still useful for testing an unreleased build:

cd znvault-plugin-webdeploy
npm install
npm run build

The znvault CLI reads its config from ~/Library/Preferences/znvault-nodejs/config.json on macOS (not ~/.znvault/config.json — that directory holds unrelated files such as deploy-configs.json and this plugin's own webdeploy/configs.json store). Add (or create) a top-level plugins array and append a path entry pointing at the built CLI entrypoint:

{
  "plugins": [
    { "path": "/absolute/path/to/znvault-plugin-webdeploy/dist/cli.js" }
  ]
}

Back up the file first — it typically holds live profile credentials. Verify with:

znvault webdeploy --help
znvault plugin list

The registry-backed entry is:

{ "plugins": [{ "package": "@zincapp/znvault-plugin-webdeploy" }] }

Peer dependency: @zincapp/znvault-cli >= 2.11.0 (optional at install time — the host CLI provides commander and the plugin context at runtime).

Requirements

  • Node >= 18.
  • System OpenSSH (ssh) on PATH.
  • GNU rsync >= 3.1.0 on PATH. macOS ships openrsync under the rsync name, which this plugin explicitly rejects (checked by znvault webdeploy check and before every run). Install a real rsync:
    brew install rsync
  • The resolved SSH private key and its certificate must live at paths with no whitespace — rsync's -e transport string is whitespace-split with no quoting support, so a spaced path would silently corrupt the SSH command line. The plugin fails fast with a clear error if either path contains whitespace.

Configuration

Deploy configs are named and stored locally at ~/.znvault/webdeploy/configs.json, written with mode 0600. Manage them with the config subcommands (below) — there is no need to hand-edit the store file directly.

Schema

Addresses, SSH users and alias paths below are placeholders: the addresses are RFC 5737 documentation ranges (192.0.2.0/24), and the alias: paths are illustrative. Substitute your own — the real values live in your vault and your local config store, never in this repo.

{
  // Required. Hosts are deployed to in array order (gated: a host is
  // skipped once an earlier host fails or fails its health gate. A health
  // failure on the last host still makes the whole command fail).
  "hosts": ["192.0.2.1", "192.0.2.2"],

  // Required.
  "ssh": {
    "user": "ops",
    "port": 22,               // optional, default 22
    "principal": "deploy",    // optional, default "deploy"
    "ttlSeconds": 3600        // optional, default 3600 (1h)
  },

  // Required. Local file whose trimmed content is the build/version number
  // used for the versioned static directory and post-deploy verification
  // (e.g. a "shared/version" file written by your build).
  "versionFile": "shared/version",

  // Optional — Node app deployment. Omit if this host only serves static assets.
  "app": {
    "localPath": "deploy",          // local staged dir to rsync (staging it is the caller's job)
    "remotePath": "zincapp-ts",     // remote path, relative to the SSH user's home
    "pm2App": "www",                // PM2 app name (ecosystem.config.js entry)
    "exclude": ["*.log"],           // optional rsync --exclude patterns
    // Rendered to <remotePath>/.env. Values are `alias:` refs or plain
    // non-secret literals (e.g. NODE_ENV=production). Keys that LOOK secret
    // (KEY/TOKEN/SECRET/PASSWORD/PASSWD/CREDENTIAL, case-insensitive) MUST
    // be alias: refs — config-validate rejects a literal there.
    "env": {
      "API_KEY": "alias:webapp/prod/remote-api.key",
      "NODE_ENV": "production"
    },
    // Extra rendered files: remote-relative path -> alias: ref for the
    // WHOLE file content. Every value here must be an alias: ref (no
    // exceptions — file contents are always secret-designated).
    "files": {
      ".yarnrc.yml": "alias:webapp/prod/yarnrc.content"
    },
    "yarnVersion": "4.9.1"   // corepack pin; omit to skip corepack/yarn install entirely
  },

  // Optional — static asset deployment. Omit if this is an app-only host.
  // localPath/remotePath MUST end with '/' (validated).
  "static": {
    "localPath": "public/",
    "remotePath": "/var/www/",
    "retentionCount": 50     // optional, default 50 — old versioned dirs kept
  },

  "nginx": { "reload": true },  // optional; defaults to true whenever `static` is present

  // Optional health checks, run after each host deploys (and used
  // standalone by `check`). Any of 7 spec types, any number, any mix:
  "healthChecks": [
    { "type": "systemd", "unit": "myapp.service" },
    { "type": "http", "url": "http://localhost:3000/health", "expectStatus": 200 },
    { "type": "pm2", "app": "www" },
    { "type": "ports", "ports": [3000, 3001] },
    { "type": "file", "path": "/var/www/current/index.html" },
    { "type": "disk", "warnAt": 80, "failAt": 90 },
    { "type": "memory", "warnAt": 80, "failAt": 90 }
  ],

  // Optional Cloudflare cache purge, run once after the last successful host.
  "cdn": {
    "provider": "cloudflare",
    "zoneId": "alias:webapp/prod/cloudflare.zoneId",   // alias: ref or plain
    "apiToken": "alias:webapp/prod/cloudflare.token",  // alias: ref ONLY
    "purge": "everything"   // or an array of specific URLs
  },

  // Optional post-deploy version verification (also powers `status`).
  "verify": {
    "versionPath": "/version",
    "hostHeader": "app.example.com"   // optional Host header override
  },

  // Optional post-deploy notifications. Both are best-effort / non-blocking.
  "notify": {
    "webhook": "alias:webapp/prod/slack.webhook",   // alias: ref ONLY
    "helpSync": {
      "url": "https://chatbot.example.com/help-sync",
      "key": "alias:webapp/prod/help-sync.key",     // alias: ref ONLY
      "contentDir": "help-content"                  // local dir of *.md files
    }
  }
}

Secret-designated fields (alias-only)

The following fields are rejected by config-validate if given as a plain literal — they must be alias: references:

  • cdn.apiToken
  • app.env.<key> for any key matching /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL/i (case-insensitive) — plain values are still allowed for non-secret-looking keys, plus an explicit allowlist (production, development, staging, test, true, false)
  • app.files.<path> — every file value, no exceptions (whole-file content is always secret-designated)
  • notify.webhook
  • notify.helpSync.key

cdn.zoneId may be either an alias: reference or a plain value (it is not generally secret, just tenant-specific).

Secret-reference convention

Two forms are accepted anywhere a config value is described as taking a "secret ref":

  • alias:<path>.<field> — resolves via the vault: GET /v1/secrets/alias/{alias} to find the secret's ID, then POST /v1/secrets/{id}/decrypt to fetch its data. The alias/field split happens on the last dot of the last path segment (matching the znvault-plugin-payara convention), so alias:webapp/prod/cloudflare.token resolves alias webapp/prod/cloudflare, field token. A ref with no dot in its last segment (alias:webapp/prod/webhook) uses the secret's data.value (or, for binary/file-style secrets, base64-decodes data.content).
  • literal:<value> or a plain string — the literal: prefix (if present) is stripped and the remainder used; a plain string (not prefixed) is also used as-is. For example, literal:production → production, or production → production. (Any string not starting with alias: is treated as plain.)

Resolution happens once, in memory, at the start of a run — a resolved config is never persisted to disk. Every resolved secret value is registered in a Redactor for the duration of the run; the redactor masks all registered values (longest match first, so a value that's a substring of another can't leak the longer one) in every line the plugin logs, in --json summaries, and in dry-run plan output. Secret values are designed to never reach stdout, stderr, argv, or disk on the machine running znvault.

SSH-CA requirements

This plugin authenticates over SSH certificates, not static keys. It expects your ZnVault tenant's SSH CA to already be configured — see the full guide: ../docs/SSH_CA_GUIDE.md.

Minimally, before running a deploy you need:

  1. Principal mapping — your SSO group (or the caller's identity) maps to a principal, by default "deploy" (overridable per-config via ssh.principal).
  2. A server group / access rule granting that principal SSH access to the target Linux user (ssh.user in the config) on the deploy hosts.
  3. Each target host trusts the CA: TrustedUserCAKeys in sshd_config points at the tenant's CA public key, and AuthorizedPrincipalsFile maps the target user to the principals allowed to log in as it.

At run time the plugin:

  • Looks for an SSH key at ~/.ssh/id_ed25519 (falling back to id_ecdsa, then id_rsa).
  • Checks for a cached certificate at <key>-webdeploy-cert.pub — a dedicated path distinct from the interactive <key>-cert.pub used by znvault ssh connect, so this plugin never clobbers (or is clobbered by) your interactive certificate.
  • Re-signs via POST /v1/ssh/sign whenever the cached cert is missing, unparsable, missing the configured principal, or expiring within 5 minutes — using ssh.ttlSeconds (default 3600s / 1h) and the configured principal.
  • Uses the resulting -i <key> -o CertificateFile=<cert> pair for every ssh/rsync -e invocation for the run.

Commands

znvault webdeploy run <config>       Run a gated rolling deploy
znvault webdeploy check <config>     Preflight: vault auth, secrets, cert, host reachability, health
znvault webdeploy status <config>    Show the served version on each host
znvault webdeploy config list                    List stored config names
znvault webdeploy config show <name>             Print a stored config (JSON)
znvault webdeploy config validate <name>         Validate a stored config
znvault webdeploy config import <name> <file>    Import + validate a config from a JSON file

run

znvault webdeploy run <config> [--json] [--dry-run] [--skip-purge]
  • --dry-run — resolves secrets, signs/validates the SSH cert, and prints the deploy plan (hosts, app remote path/PM2 app, static config, CDN purge mode) without touching any host. All secret values are redacted from the printed plan.
  • --json — prints the machine-readable RunSummary instead of the human-readable summary lines.
  • --skip-purge — drops the cdn section for this run (no Cloudflare purge).

A run acquires a per-config lock (see Locking) before touching any host, so two overlapping runs against the same config name cannot race each other.

check

Proves, without deploying: rsync version is acceptable, the config is valid and every configured secret resolves, an SSH certificate is available (or gets signed), every host is SSH-reachable, and every configured health check passes. Exits non-zero if any of those fail.

status

Fetches verify.versionPath from every host (with an optional Host header override) and prints the served version, or the HTTP status/error if the fetch fails. Requires verify to be configured; otherwise prints a warning and does nothing.

Version probes use a raw node:http request so the hostHeader override genuinely reaches the server (Node's fetch would strip it in favor of the URL's hostname, making vhost-routed version checks impossible).

Exit codes

run exits zero only when every configured host deployment and health gate succeeds, the configured CDN purge succeeds, and every configured version probe serves the requested build. If at least one host changed before a gate failed, the JSON summary sets recoveryRequired: true; old versioned assets are retained and the human summary says recovery is required. Cleanup errors and optional notification/help-sync failures remain non-blocking.

check exits non-zero when config validation, version, secret resolution/cert signing, SSH reachability, or a health check itself fails. Soft disk/memory threshold warnings do not fail check.

Locking

run takes an exclusive, per-config-name lock file at <tmpdir>/znvault-webdeploy-<config>.lock (created with O_CREAT|O_EXCL, so two concurrent runs can't both "win"). The lock file records {pid, user, since, host}. If a lock already exists:

  • If the recorded PID is still alive (process.kill(pid, 0) doesn't throw, or throws EPERM because it belongs to another user), the lock is held and the new run fails immediately with LockHeldError — it is never stolen from a live process, even one owned by a different user.
  • Only an ESRCH probe result (process definitely doesn't exist) — or an unreadable/corrupt lock file — is treated as stale, in which case the stale lock is removed and a fresh one acquired.

The lock is released on normal completion and on SIGINT/SIGTERM (process.exit(130)/143 respectively after cleanup).

Performance improvements (v0.2.0)

The plugin includes four non-breaking performance optimizations:

  • QW3 (scoped permissions): chown/chmod operations during static asset deployment are now scoped to written paths only. Phase A (versioned build directory) runs with -R recursively on the new dir; Phase B (webroot shared files) runs without -R, touching only the top-level directory itself. This avoids expensive recursive permission changes over dozens of pre-existing build directories. Safe because rsync runs as an unprivileged SSH user with --delay-updates (directory write suffices).

  • QW4 (install gate): Dependency installation now skips corepack use and yarn install --immutable entirely when the remote manifest hash (sha256 of package.json + yarn.lock) matches the stamp file <remotePath>/.deploy-install-stamp AND node_modules exists on the remote. Any doubt (missing/corrupt stamp or node_modules) forces a fresh install — fail-safe. The immutable install refuses dependency resolution that would alter the accepted lockfile, so equal build labels cannot hide site-specific dependency drift.

  • QW5 (poll instead of sleep): PM2 settle and version verification no longer use fixed-duration sleeps. reloadOrStartPm2 polls pm2 describe every 250ms up to a 2-second ceiling; version verification polls GET /version every 250ms up to a 5-second ceiling, terminating early on success. Result strings and exit semantics are byte-identical to before.

  • QW6 (batched health): Health checks now run as a single remote shell script (one per check, emitting idx|STATUS|detail lines) instead of ~10 separate SSH calls per host. The output is parsed locally and rendered into the same result strings as before. Result semantics and warnings are identical.

None of these changes alter the security model (SSH-CA certs, vault secrets, redaction, stdin-piped file writes) or the result contract (warnings vs. failures, exit codes, third-party integrations).

Security model

  • No secrets at rest in configs. Stored configs (~/.znvault/webdeploy/configs.json, mode 0600) only ever contain alias: references or non-secret plain values — config-validate enforces this on import/config validate.
  • In-memory resolution only. Secrets are fetched from the vault once, at the start of run/check, and only live in process memory for that invocation.
  • Universal redaction. Every resolved secret value is registered in a Redactor and masked out of all logged output, --json summaries, and dry-run plans for the run.
  • Remote file writes never touch argv. .env and app.files content is piped to the remote host over SSH stdin (writeRemoteFileIfChanged uses umask 077 && cat > tmp && chmod 600 tmp && mv tmp <path>), so rendered secret content never appears in a remote process list or shell history, and files land with mode 0600.
  • Idempotent writes. Before writing, the remote file's current sha256sum is compared to the local content's hash; unchanged files are left untouched (no needless remote writes or PM2/nginx churn).
  • Short-lived SSH credentials. Certificates are signed for ssh.ttlSeconds (default 1 hour) and re-signed automatically once they're within 5 minutes of expiry — no long-lived key material is deployed to or trusted by target hosts beyond the CA's own trust anchor.

Local development

npm install
npm run typecheck   # tsc --noEmit
npm test            # vitest run
npm run build        # tsc

All business logic (SSH/rsync execution, PM2, health checks, CDN purge, notifications, locking) is dependency-injected, so the full suite runs without any real SSH connection, host, or vault instance.