diff --git a/.changeset/feat-cvmb-bundling.md b/.changeset/feat-cvmb-bundling.md new file mode 100644 index 000000000..832192628 --- /dev/null +++ b/.changeset/feat-cvmb-bundling.md @@ -0,0 +1,5 @@ +--- +'cvmi': minor +--- + +Added `.cvmb` (CVM Bundle) support: a new `cvmi pack` command packages an MCP server into a signed `.cvmb` bundle (ZIP archive with a `manifest.json`), and `cvmi serve ` extracts, verifies, and runs it. Bundles support both `stdio` (Gateway-wrapped) and `cvm` (native Nostr transport) modes, typed `user_config` with secrets handling, Merkle-tree `content_hash` integrity binding, and Nostr Schnorr manifest signatures via `nostr-tools`. diff --git a/AGENTS.md b/AGENTS.md index e34869d12..fc3751a6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,15 @@ This file provides guidance to AI coding agents working on the `cvmi` CLI codeba ## Commands -| Command | Description | -| -------------------- | --------------------------------------------------- | -| `cvmi` | Show banner with available commands | -| `cvmi add ` | Install skills from git repos, URLs, or local paths | -| `cvmi check` | Check for available skill updates | -| `cvmi update` | Update all skills to latest versions | -| `cvmi pn` / `cn` | Compile a server to TypeScript code | -| `cvmi generate-lock` | Match installed skills to sources via API | +| Command | Description | +| -------------------- | --------------------------------------------------------- | +| `cvmi` | Show banner with available commands | +| `cvmi add ` | Install skills from git repos, URLs, or local paths | +| `cvmi pack` | Package an MCP server into a distributable `.cvmb` bundle | +| `cvmi check` | Check for available skill updates | +| `cvmi update` | Update all skills to latest versions | +| `cvmi pn` / `cn` | Compile a server to TypeScript code | +| `cvmi generate-lock` | Match installed skills to sources via API | Aliases: `cvmi a`, `cvmi i`, `cvmi install` all work for `add`. @@ -25,6 +26,8 @@ Aliases: `cvmi a`, `cvmi i`, `cvmi install` all work for `add`. src/ ├── cli.ts # Main entry point, command routing, init/check/update ├── cli.test.ts # CLI tests +├── pack.ts # Pack command implementation +├── pack/ # Pack utilities (extract, cvm-manifest, pack-init) ├── add.ts # Core add command logic ├── add.test.ts # Add command tests ├── cn/ # Client generation (ctxcn) module diff --git a/cvmb-bundling.md b/cvmb-bundling.md new file mode 100644 index 000000000..6ee4fec0a --- /dev/null +++ b/cvmb-bundling.md @@ -0,0 +1,507 @@ +# CVM Bundle Format (.cvmb) + +**Status**: Draft + +## Summary + +Defines the `.cvmb` (CVM Bundle) packaging format for distributing ContextVM MCP servers. A `.cvmb` file is a ZIP archive containing server code, dependencies, and a `manifest.json`. The format is inspired by the [MCPB specification](https://github.com/modelcontextprotocol/mcpb) but is not bound by MCPB host compatibility — `.cvmb` bundles are designed for the ContextVM ecosystem and are consumed by `cvmi serve`. + +## Key Points + +- ZIP archive with `manifest.json` at the root; file extension `.cvmb` +- Two transport modes: `stdio` (Gateway-wrapped) and `cvm` (native Nostr transport) +- Reuses `user_config` and `mcp_config.env` for typed configuration and environment injection +- Nostr Schnorr signatures (`_sig`) for authorship verification — no external PKI required +- Merkle-tree content hashing (`content_hash`) binds all bundle files to the manifest +- RFC 8785 canonicalization for deterministic signing and verification +- Secrets are never shipped in plaintext; declared via `user_config` with `sensitive: true` +- Docker support as a first-class server type with image references (not bundled images) +- Fully offline-operable: signing, verification, and hashing require no network access + +--- + +## File Format + +A `.cvmb` bundle is a ZIP archive (maximum compression) containing: + +``` +my-server.cvmb +├── manifest.json # Required: bundle metadata and configuration +├── server/ # Server code (for node, python, uv, binary types) +│ └── ... +├── node_modules/ # Bundled dependencies (node type) +├── pyproject.toml # Dependencies declaration (uv type) +├── docker-compose.yml # Multi-container orchestration (docker type, optional) +└── assets/ # Icons, screenshots, etc. +``` + +Excluded from the bundle: `.git`, `.cache`, `.DS_Store`, `.env`, `node_modules/.cache`, and any existing `.cvmb` or `.mcpb` files. + +--- + +## Manifest Schema + +### Required Fields + +| Field | Type | Description | +| ------------------ | ------ | ------------------------------------------------------------------------- | +| `manifest_version` | string | Spec version this manifest conforms to (e.g., `"0.3"`) | +| `name` | string | Machine-readable name (used for CLI, APIs) | +| `version` | string | Semantic version (semver) | +| `description` | string | Brief description of the server | +| `author` | object | Author information with required `name` field; optional `email` and `url` | +| `server` | object | Server configuration (see below) | + +### Optional Fields + +| Field | Type | Description | +| ------------------- | -------- | ----------------------------------------------------------------- | +| `display_name` | string | Human-friendly name for UI display | +| `long_description` | string | Detailed markdown description | +| `repository` | object | Source code repository (`type` and `url`) | +| `homepage` | string | Project homepage URL | +| `documentation` | string | Documentation URL | +| `support` | string | Support/issues URL | +| `icon` | string | Path to a PNG icon file | +| `icons` | array | Array of icon descriptors (`src`, `size`, optional `theme`) | +| `screenshots` | string[] | Array of screenshot paths | +| `tools` | array | Declared tools the server provides | +| `tools_generated` | boolean | Server generates additional tools at runtime (default: `false`) | +| `prompts` | array | Declared prompts the server provides | +| `prompts_generated` | boolean | Server generates additional prompts at runtime (default: `false`) | +| `keywords` | string[] | Search keywords | +| `license` | string | License identifier (e.g., `"MIT"`) | +| `privacy_policies` | string[] | URLs to privacy policies for external services | +| `compatibility` | object | Platform and runtime requirements | +| `user_config` | object | User-configurable options (see User Configuration) | +| `_meta` | object | Reverse-DNS namespaced metadata (see CVM Metadata) | +| `_sig` | object | Nostr Schnorr signature (see Signing and Verification) | + +### Server Configuration + +The `server` object defines how to run the MCP server: + +```json +{ + "server": { + "type": "node", + "entry_point": "server/index.js", + "transport": "stdio", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/server/index.js"], + "env": { + "API_KEY": "${user_config.api_key}" + } + } + } +} +``` + +| Field | Type | Required | Description | +| -------------- | ------ | ----------- | ------------------------------------------------------------------------------------- | +| `type` | enum | Yes | Server type: `"node"`, `"python"`, `"uv"`, `"binary"`, `"docker"` | +| `entry_point` | string | See notes | Path to the main server file. Required for node/python/uv/binary; optional for docker | +| `transport` | enum | No | Transport mode: `"stdio"` (default) or `"cvm"` | +| `image` | string | docker only | Docker image reference (e.g., `"ghcr.io/dev/my-server:1.0.0"`) | +| `compose_file` | string | docker only | Path to `docker-compose.yml` within the bundle for multi-container setups | +| `mcp_config` | object | Yes | Process spawn configuration | + +#### Server Types + +| Type | Description | Dependencies | +| -------- | ------------------------------ | ----------------------------------------------- | +| `node` | Node.js server | Bundled in `node_modules/` | +| `python` | Python server | Bundled in `server/lib/` or `server/venv/` | +| `uv` | Python server using UV runtime | Declared in `pyproject.toml`, installed by host | +| `binary` | Pre-compiled executable | Self-contained | +| `docker` | Docker container | Image referenced, pulled at runtime | + +#### MCP Configuration + +The `mcp_config` object defines the process spawn command: + +| Field | Type | Description | +| --------- | -------- | --------------------------------------- | +| `command` | string | Command to execute | +| `args` | string[] | Arguments passed to the command | +| `env` | object | Environment variables (string → string) | + +Variable substitution is supported in `command`, `args`, and `env` values: + +- `${__dirname}` — Absolute path to the extracted bundle directory +- `${HOME}`, `${DESKTOP}`, `${DOCUMENTS}`, `${DOWNLOADS}` — Standard user directories +- `${user_config.KEY}` — User-provided configuration value + +--- + +## Transport Modes + +### `stdio` (default) + +The server communicates over stdin/stdout. `cvmi serve` spawns the process and wraps it with the Gateway. The Gateway owns all CVM configuration (relays, encryption, announcements, payments). The server is completely transport-agnostic. + +``` +┌──────────┐ stdio ┌─────────┐ Nostr ┌────────┐ +│ Server │◄──────────►│ Gateway │◄──────────►│ Relays │ +│ Process │ │ │ │ │ +└──────────┘ └─────────┘ └────────┘ +``` + +### `cvm` + +The server uses the CVM SDK's `NostrServerTransport` directly. `cvmi serve` spawns the process without the Gateway. CVM configuration (relays, encryption, public mode) is injected as environment variables via `mcp_config.env`. The server manages its own transport. + +``` +┌──────────┐ Nostr ┌────────┐ +│ Server │◄──────────►│ Relays │ +│ Process │ │ │ +└──────────┘ └────────┘ +``` + +Use `cvm` mode when the server needs SDK-specific features: + +- `injectClientPubkey` — per-client authentication and authorization +- Dynamic authorization callbacks (`isPubkeyAllowed`) +- Direct transport-level capabilities not available through the Gateway + +**Environment variables are available for both transport modes.** `stdio` servers can also declare `mcp_config.env` for API keys, config paths, or any other runtime values the Gateway doesn't own. + +### Transport Selection + +The `transport` field declares the intended mode. At runtime, `cvmi serve` honors this but can override via CLI flags: + +```bash +# Force Gateway wrap even for cvm bundles +cvmi serve --transport stdio my-server.cvmb + +# Force direct spawn even for stdio bundles +cvmi serve --transport cvm my-server.cvmb +``` + +--- + +## User Configuration + +The `user_config` field follows the MCPB convention for typed, user-facing configuration. Each key defines a configuration option with type, validation, and sensitivity: + +```json +{ + "user_config": { + "api_key": { + "type": "string", + "title": "API Key", + "description": "Your API key for authentication", + "sensitive": true, + "required": true + }, + "max_file_size": { + "type": "number", + "title": "Maximum File Size (MB)", + "description": "Maximum file size to process", + "default": 10, + "min": 1, + "max": 100 + }, + "allowed_directories": { + "type": "directory", + "title": "Allowed Directories", + "description": "Directories the server can access", + "multiple": true, + "required": true, + "default": ["${HOME}/Desktop"] + } + } +} +``` + +### Configuration Types + +| Type | UI Control | `multiple` Support | `sensitive` Support | +| ----------- | ---------------- | ----------------------------- | ------------------- | +| `string` | Text input | No | Yes (masks input) | +| `number` | Numeric input | No | No | +| `boolean` | Checkbox/toggle | No | No | +| `directory` | Directory picker | Yes (array expansion in args) | No | +| `file` | File picker | Yes (array expansion in args) | No | + +### Secrets Handling + +Fields marked `sensitive: true` are never stored in plaintext. `cvmi serve` prompts for them on first run or reads them from environment variables. They are never included in `_meta.com.contextvm.defaults`. + +### Variable Substitution + +User config values are injected through `mcp_config` using `${user_config.KEY}`: + +```json +{ + "mcp_config": { + "env": { + "API_KEY": "${user_config.api_key}", + "BASE_URL": "${user_config.base_url}" + }, + "args": ["${user_config.allowed_directories}"] + } +} +``` + +When `multiple: true`, array values are expanded as separate arguments. + +--- + +## CVM Metadata (`_meta.com.contextvm`) + +CVM-specific configuration lives under the `_meta.com.contextvm` namespace: + +```json +{ + "_meta": { + "com.contextvm": { + "content_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + } +} +``` + +| Field | Type | Description | +| -------------- | ------ | ---------------------------------------------------------------- | +| `content_hash` | string | SHA-256 Merkle hash of all bundle files, prefixed with `sha256:` | + +The `content_hash` is computed during packing and included in the manifest before signing. This cryptographically binds all bundle contents to the manifest signature. + +Future CVM-specific fields (relay defaults, encryption preferences, pricing configuration) may be added to this namespace as the design evolves. + +--- + +## Signing and Verification + +Bundles are signed using the author's Nostr keypair. **All cryptography is delegated to [`nostr-tools`](https://github.com/nbd-wtf/nostr-tools)** (`finalizeEvent` / `verifyEvent`) — `cvmi` performs no manual curve operations and does not depend on `@noble/curves` directly. The signature lives in the `_sig` field: + +```json +{ + "_sig": { + "pubkey": "abc123...", + "id": "def456...", + "signature": "86f25c...", + "created_at": 1718123456 + } +} +``` + +### The Signing Event + +The signature is a real Nostr event (never published to relays) whose `content` is the canonical manifest. Using `finalizeEvent` / `verifyEvent` binds the author's Nostr identity to the exact manifest bytes without any manual curve math: + +```json +{ + "kind": "", + "tags": [], + "content": "", + "created_at": 1718123456, + "pubkey": "abc123...", + "id": "def456...", + "sig": "86f25c..." +} +``` + +`MANIFEST_SIGNATURE_KIND` is a fixed constant defined by the tooling. Its value is arbitrary (the event is never relayed); it only needs to be identical at sign and verify time. Only `pubkey`, `id`, `sig`, and `created_at` are stored in the manifest's `_sig`; the rest is reconstructed during verification. + +### `_sig` Fields + +| Field | Type | Description | +| ------------ | ------ | ------------------------------------------------------------------------------------------------- | +| `pubkey` | string | Author's Nostr public key (hex, x-only) | +| `id` | string | NIP-01 event id: SHA-256 of the serialized signing event, which commits to the canonical manifest | +| `signature` | string | Schnorr (BIP-340) signature of `id`, produced by `nostr-tools` `finalizeEvent` (hex) | +| `created_at` | number | Unix timestamp of when the signature was created (informational only) | + +### Signing Flow + +1. Compute `content_hash` over all bundle files (see Content Integrity) +2. Insert `content_hash` into `_meta.com.contextvm` +3. Remove `_sig` from the manifest (if present) +4. Canonicalize the manifest per RFC 8785 (sorted keys, no whitespace) → `content` +5. Build a Nostr signing event `{ kind, tags: [], content, created_at }` +6. Sign it with the author's Nostr private key via `finalizeEvent` → `{ pubkey, id, sig }` +7. Insert `_sig` with `pubkey`, `id`, `signature` (= the event's `sig`), `created_at` +8. Pack the ZIP + +### Verification Flow + +1. Extract the ZIP +2. Verify `content_hash` matches actual bundle files (see Content Integrity) +3. Remove `_sig` from the manifest, canonicalize per RFC 8785 → `content` +4. Reconstruct the signing event `{ kind, tags: [], content, pubkey: _sig.pubkey, id: _sig.id, sig: _sig.signature, created_at: _sig.created_at }` +5. Verify with `verifyEvent` — this checks both the NIP-01 event `id` and the Schnorr signature + +All checks pass → valid. Any check fails → invalid. + +The bundle author's Nostr identity is the root of trust. The same keypair can sign multiple servers, giving the author a stable identity across their catalog without any CA or certificate infrastructure. Verification is a pure cryptographic operation — no network access required. + +--- + +## Content Integrity + +Bundle file integrity is verified using a Merkle-style hash tree: + +1. Recursively list all files in the bundle directory +2. Exclude `manifest.json`, any `.cvmb`/`.mcpb` files, and ignored patterns (`.git`, `node_modules`, `.DS_Store`, `.env`) +3. Compute `SHA-256(file_contents)` for each remaining file +4. Sort entries alphabetically by relative path (using `/` as separator) +5. Concatenate `path:hash\n` for each entry +6. Compute `SHA-256(concatenation)` and prefix with `sha256:` + +``` +Files: + server/index.js → sha256:aaa... + server/utils.js → sha256:bbb... + package.json → sha256:ccc... + +Sorted concatenation: + "package.json:ccc...\nserver/index.js:aaa...\nserver/utils.js:bbb..." + +content_hash = "sha256:" + SHA-256(concatenation) +``` + +This approach enables: + +- **Single-hash verification**: one hash covers all files +- **Deterministic ordering**: alphabetical sort ensures reproducible hashes +- **Path binding**: file paths are part of the hash, preventing file relocation attacks + +--- + +## Docker Support + +Docker is a first-class server type for complex deployments requiring databases, caches, or other services alongside the MCP server. + +### Single Container + +```json +{ + "server": { + "type": "docker", + "image": "ghcr.io/developer/my-cvm-server:1.0.0", + "transport": "stdio", + "mcp_config": { + "command": "docker", + "args": ["run", "--rm", "-i", "ghcr.io/developer/my-cvm-server:1.0.0"] + } + } +} +``` + +The container exposes stdio MCP. `cvmi serve` spawns `docker run --rm -i ` and communicates over stdin/stdout, then wraps with the Gateway. The image is pulled from the registry on first run. + +### Multi-Container (Docker Compose) + +```json +{ + "server": { + "type": "docker", + "compose_file": "docker-compose.yml", + "transport": "stdio", + "mcp_config": { + "command": "docker", + "args": ["compose", "-f", "${__dirname}/docker-compose.yml", "run", "--rm", "-i", "server"] + } + } +} +``` + +The `compose_file` references a `docker-compose.yml` bundled inside the `.cvmb`. This handles orchestration of the server with its dependencies (database, cache, etc.). + +### Docker Considerations + +- Docker images are **referenced, not bundled** — the `.cvmb` contains only the manifest reference +- Images are pulled at runtime on first `cvmi serve` +- Docker must be installed on the host system +- The container communicates over stdio; `cvmi serve` wraps it with the Gateway + +--- + +## Packing Flow (`cvmi pack`) + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 1. Validate │────►│ 2. Hash │────►│ 3. Sign │────►│ 4. Archive │ +│ manifest │ │ contents │ │ manifest │ │ .cvmb ZIP │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +1. **Validate manifest** against the schema; ensure all required fields are present and types are correct +2. **Compute `content_hash`** over all bundle files (Merkle tree, see Content Integrity) +3. **Insert `content_hash`** into `_meta.com.contextvm` +4. **Sign the manifest**: canonicalize (RFC 8785), compute `id`, sign with author's Nostr private key, insert `_sig` +5. **Archive**: create ZIP with maximum compression, excluding dev files and ignored patterns + +--- + +## Serving Flow (`cvmi serve`) + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ 1. Extract │────►│ 2. Verify │────►│ 3. Resolve │────►│ 4. Spawn │ +│ .cvmb ZIP │ │ signature │ │ user_config │ │ process │ +└──────────────┘ └──────────────┘ └──────────────────┘ └──────────────┘ +``` + +1. **Extract** the `.cvmb` to a temporary or persistent directory +2. **Verify** the manifest signature and `content_hash` (see Verification Flow) +3. **Resolve configuration** using the standard precedence chain: + - CLI flags (highest priority) + - Custom config file (`--config `) + - Project-level `./.cvmi.json` + - Global `~/.cvmi/config.json` + - Environment variables + - Manifest defaults (lowest priority) +4. **Prompt for missing `user_config`** values (especially `sensitive: true` fields) +5. **Spawn the process** according to `transport` mode: + - `stdio`: spawn process → wrap with Gateway → Gateway manages Nostr transport + - `cvm`: spawn process directly → inject resolved env vars → server manages its own transport + +--- + +## Canonicalization + +Manifest canonicalization follows [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) (JSON Canonicalization Scheme): + +- Object keys are sorted lexicographically +- No whitespace outside string values +- Unicode characters are escaped per the RFC +- Numbers are serialized without insignificant digits + +The canonical form is used for computing `_sig.id` and must be reproduced identically by any implementation. The `canonicalize` npm package provides a conformant implementation. + +--- + +## Design Decisions + +### No MCPB Host Compatibility + +`.cvmb` bundles are designed exclusively for the ContextVM ecosystem. They are not intended to run in generic MCPB hosts (like Claude Desktop). MCPB is designed for local stdio servers installed into desktop applications — a different use case from distributing CVM servers that run over Nostr. Maintaining MCPB spec compatibility would add constraints (server type enums, `mcp_config` assumptions) without benefit. + +The format borrows MCPB's manifest shape where it makes sense (`user_config`, `mcp_config`, variable substitution) but defines its own server types, transport modes, and signing mechanism. + +### Secrets Never in Plaintext + +Private keys, API keys, and any sensitive values are never shipped in the bundle or stored in `_meta.com.contextvm.defaults`. They are declared via `user_config` with `sensitive: true` and resolved by `cvmi serve` at runtime through prompting or environment variables. + +### No PKI — Nostr Identity as Root of Trust + +X.509 certificates and CA infrastructure are unnecessary when the server already has a Nostr identity (keypair). Signing with the same keypair used for protocol operation and announcements provides a unified identity model. Verification is a pure Schnorr signature check — no network access, no certificate chains, no expiration. + +### Merkle Hashing Enables Integrity Without Extraction + +The Merkle tree structure means the `content_hash` can be verified incrementally. Future tooling could verify individual files without re-hashing the entire bundle, and partial updates could be validated against a known root hash. + +--- + +## References + +- [MCPB Specification](https://github.com/modelcontextprotocol/mcpb) — base manifest format inspiration +- [MCPB MANIFEST.md](https://github.com/anthropics/mcpb/blob/main/MANIFEST.md) — field definitions and user_config spec +- [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) — JSON Canonicalization Scheme +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Nostr event signing (Schnorr signatures) +- [CEP-6: Public Server Announcements](ceps.md) — server discovery events (kind 11316–11320) +- [CEP-4: Encryption Support](ceps.md) — `support_encryption` tag and NIP-44 diff --git a/package.json b/package.json index 1acb78c2c..7224589e2 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,9 @@ "dependencies": { "@contextvm/sdk": "^0.11.14", "@modelcontextprotocol/sdk": "^1.27.1", + "archiver": "^8.0.0", + "canonicalize": "^2.1.0", + "extract-zip": "^2.0.1", "json-schema-to-typescript": "15.0.4", "nostr-tools": "^2.23.3", "xdg-basedir": "^5.1.0", @@ -103,6 +106,7 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@clack/prompts": "^0.11.0", + "@types/archiver": "^8.0.0", "@types/bun": "latest", "@types/node": "^22.19.15", "gray-matter": "^4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c0a45209..4558706b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,15 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + archiver: + specifier: ^8.0.0 + version: 8.0.0 + canonicalize: + specifier: ^2.1.0 + version: 2.1.0 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1 json-schema-to-typescript: specifier: 15.0.4 version: 15.0.4 @@ -32,6 +41,9 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@types/archiver': + specifier: ^8.0.0 + version: 8.0.0 '@types/bun': specifier: latest version: 1.3.11 @@ -1251,6 +1263,12 @@ packages: integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, } + '@types/archiver@8.0.0': + resolution: + { + integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==, + } + '@types/bun@1.3.11': resolution: { @@ -1305,6 +1323,18 @@ packages: integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==, } + '@types/readdir-glob@1.1.5': + resolution: + { + integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==, + } + + '@types/yauzl@2.10.3': + resolution: + { + integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==, + } + '@vitest/expect@4.1.0': resolution: { @@ -1355,6 +1385,13 @@ packages: integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==, } + abort-controller@3.0.0: + resolution: + { + integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, + } + engines: { node: '>=6.5' } + accepts@2.0.0: resolution: { @@ -1426,6 +1463,13 @@ packages: integrity: sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==, } + archiver@8.0.0: + resolution: + { + integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==, + } + engines: { node: '>=18' } + argparse@1.0.10: resolution: { @@ -1466,6 +1510,12 @@ packages: } engines: { node: '>=20.19.0' } + async@3.2.6: + resolution: + { + integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, + } + atomic-sleep@1.0.0: resolution: { @@ -1473,6 +1523,89 @@ packages: } engines: { node: '>=8.0.0' } + b4a@1.8.1: + resolution: + { + integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==, + } + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, + } + engines: { node: 18 || 20 || >=22 } + + bare-events@2.9.1: + resolution: + { + integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==, + } + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.2: + resolution: + { + integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==, + } + engines: { bare: '>=1.16.0' } + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: + { + integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==, + } + engines: { bare: '>=1.14.0' } + + bare-path@3.0.1: + resolution: + { + integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==, + } + + bare-stream@2.13.1: + resolution: + { + integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==, + } + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: + { + integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==, + } + + base64-js@1.5.1: + resolution: + { + integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, + } + better-path-resolve@1.0.0: resolution: { @@ -1493,6 +1626,13 @@ packages: } engines: { node: '>=18' } + brace-expansion@5.0.6: + resolution: + { + integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==, + } + engines: { node: 18 || 20 || >=22 } + braces@3.0.3: resolution: { @@ -1500,6 +1640,25 @@ packages: } engines: { node: '>=8' } + buffer-crc32@0.2.13: + resolution: + { + integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==, + } + + buffer-crc32@1.0.0: + resolution: + { + integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==, + } + engines: { node: '>=8.0.0' } + + buffer@6.0.3: + resolution: + { + integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, + } + bun-types@1.3.11: resolution: { @@ -1603,6 +1762,13 @@ packages: integrity: sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA==, } + compress-commons@7.0.1: + resolution: + { + integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==, + } + engines: { node: '>=18' } + confbox@0.2.4: resolution: { @@ -1650,6 +1816,12 @@ packages: } engines: { node: '>= 0.6' } + core-util-is@1.0.3: + resolution: + { + integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==, + } + cors@2.8.6: resolution: { @@ -1657,6 +1829,21 @@ packages: } engines: { node: '>= 0.10' } + crc-32@1.2.2: + resolution: + { + integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, + } + engines: { node: '>=0.8' } + hasBin: true + + crc32-stream@7.0.1: + resolution: + { + integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==, + } + engines: { node: '>=18' } + cross-spawn@7.0.6: resolution: { @@ -1754,6 +1941,12 @@ packages: } engines: { node: '>= 0.8' } + end-of-stream@1.4.5: + resolution: + { + integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, + } + enquirer@2.4.1: resolution: { @@ -1830,12 +2023,32 @@ packages: } engines: { node: '>= 0.6' } + event-target-shim@5.0.1: + resolution: + { + integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, + } + engines: { node: '>=6' } + eventemitter3@5.0.4: resolution: { integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, } + events-universal@1.0.1: + resolution: + { + integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==, + } + + events@3.3.0: + resolution: + { + integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==, + } + engines: { node: '>=0.8.x' } + eventsource-parser@3.0.6: resolution: { @@ -1892,12 +2105,26 @@ packages: integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==, } + extract-zip@2.0.1: + resolution: + { + integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==, + } + engines: { node: '>= 10.17.0' } + hasBin: true + fast-deep-equal@3.1.3: resolution: { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, } + fast-fifo@1.3.2: + resolution: + { + integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==, + } + fast-glob@3.3.3: resolution: { @@ -1917,6 +2144,12 @@ packages: integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==, } + fd-slicer@1.1.0: + resolution: + { + integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==, + } + fdir@6.5.0: resolution: { @@ -2013,6 +2246,13 @@ packages: } engines: { node: '>= 0.4' } + get-stream@5.2.0: + resolution: + { + integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==, + } + engines: { node: '>=8' } + get-tsconfig@4.13.7: resolution: { @@ -2109,6 +2349,12 @@ packages: } engines: { node: '>=0.10.0' } + ieee754@1.2.1: + resolution: + { + integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, + } + ignore@5.3.2: resolution: { @@ -2177,6 +2423,13 @@ packages: integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, } + is-stream@4.0.1: + resolution: + { + integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==, + } + engines: { node: '>=18' } + is-subdir@1.2.0: resolution: { @@ -2191,6 +2444,12 @@ packages: } engines: { node: '>=0.10.0' } + isarray@1.0.0: + resolution: + { + integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==, + } + isexe@2.0.0: resolution: { @@ -2258,6 +2517,13 @@ packages: } engines: { node: '>=0.10.0' } + lazystream@1.0.1: + resolution: + { + integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==, + } + engines: { node: '>= 0.6.3' } + lightningcss-android-arm64@1.32.0: resolution: { @@ -2467,6 +2733,13 @@ packages: } engines: { node: '>=18' } + minimatch@10.2.5: + resolution: + { + integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==, + } + engines: { node: 18 || 20 || >=22 } + minimist@1.2.8: resolution: { @@ -2515,6 +2788,13 @@ packages: } engines: { node: '>= 0.6' } + normalize-path@3.0.0: + resolution: + { + integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==, + } + engines: { node: '>=0.10.0' } + nostr-tools@2.18.2: resolution: { @@ -2702,6 +2982,12 @@ packages: integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, } + pend@1.2.0: + resolution: + { + integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==, + } + picocolors@1.1.1: resolution: { @@ -2791,12 +3077,25 @@ packages: } engines: { node: '>=20' } + process-nextick-args@2.0.1: + resolution: + { + integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==, + } + process-warning@5.0.0: resolution: { integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==, } + process@0.11.10: + resolution: + { + integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==, + } + engines: { node: '>= 0.6.0' } + proxy-addr@2.0.7: resolution: { @@ -2804,6 +3103,12 @@ packages: } engines: { node: '>= 0.10' } + pump@3.0.4: + resolution: + { + integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==, + } + qs@6.15.0: resolution: { @@ -2856,6 +3161,26 @@ packages: } engines: { node: '>=6' } + readable-stream@2.3.8: + resolution: + { + integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==, + } + + readable-stream@4.7.0: + resolution: + { + integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + readdir-glob@3.0.0: + resolution: + { + integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==, + } + engines: { node: '>=18' } + real-require@0.2.0: resolution: { @@ -2977,6 +3302,18 @@ packages: integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==, } + safe-buffer@5.1.2: + resolution: + { + integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==, + } + + safe-buffer@5.2.1: + resolution: + { + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, + } + safe-stable-stringify@2.5.0: resolution: { @@ -3206,6 +3543,12 @@ packages: integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==, } + streamx@2.27.0: + resolution: + { + integrity: sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==, + } + string-argv@0.3.2: resolution: { @@ -3227,6 +3570,18 @@ packages: } engines: { node: '>=20' } + string_decoder@1.1.1: + resolution: + { + integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==, + } + + string_decoder@1.3.0: + resolution: + { + integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, + } + strip-ansi@6.0.1: resolution: { @@ -3255,6 +3610,18 @@ packages: } engines: { node: '>=4' } + tar-stream@3.2.0: + resolution: + { + integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==, + } + + teex@1.0.1: + resolution: + { + integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==, + } + term-size@2.2.1: resolution: { @@ -3262,6 +3629,12 @@ packages: } engines: { node: '>=8' } + text-decoder@1.2.7: + resolution: + { + integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==, + } + thread-stream@4.0.0: resolution: { @@ -3359,6 +3732,12 @@ packages: } engines: { node: '>= 0.8' } + util-deprecate@1.0.2: + resolution: + { + integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, + } + vary@1.1.2: resolution: { @@ -3509,6 +3888,19 @@ packages: engines: { node: '>= 14.6' } hasBin: true + yauzl@2.10.0: + resolution: + { + integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==, + } + + zip-stream@7.0.5: + resolution: + { + integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==, + } + engines: { node: '>=18' } + zod-to-json-schema@3.25.1: resolution: { @@ -4180,6 +4572,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/archiver@8.0.0': + dependencies: + '@types/node': 22.19.15 + '@types/readdir-glob': 1.1.5 + '@types/bun@1.3.11': dependencies: bun-types: 1.3.11 @@ -4205,6 +4602,15 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 22.19.15 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.19.15 + optional: true + '@vitest/expect@4.1.0': dependencies: '@standard-schema/spec': 1.1.0 @@ -4246,6 +4652,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4297,6 +4707,22 @@ snapshots: - supports-color - typescript + archiver@8.0.0: + dependencies: + async: 3.2.6 + buffer-crc32: 1.0.0 + is-stream: 4.0.1 + lazystream: 1.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + readdir-glob: 3.0.0 + tar-stream: 3.2.0 + zip-stream: 7.0.5 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -4315,8 +4741,48 @@ snapshots: estree-walker: 3.0.3 pathe: 2.0.3 + async@3.2.6: {} + atomic-sleep@1.0.0: {} + b4a@1.8.1: {} + + balanced-match@4.0.4: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.2: + dependencies: + bare-events: 2.9.1 + bare-path: 3.0.1 + bare-stream: 2.13.1(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.1: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.9.1): + dependencies: + streamx: 2.27.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.0.1 + + base64-js@1.5.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -4337,10 +4803,23 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 + buffer-crc32@0.2.13: {} + + buffer-crc32@1.0.0: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bun-types@1.3.11: dependencies: '@types/node': 22.19.15 @@ -4387,6 +4866,14 @@ snapshots: commenting@1.1.0: {} + compress-commons@7.0.1: + dependencies: + crc-32: 1.2.2 + crc32-stream: 7.0.1 + is-stream: 4.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + confbox@0.2.4: {} consola@3.4.2: {} @@ -4401,11 +4888,20 @@ snapshots: cookie@0.7.2: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 + crc-32@1.2.2: {} + + crc32-stream@7.0.1: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4444,6 +4940,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -4500,8 +5000,18 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -4556,8 +5066,20 @@ snapshots: extendable-error@0.1.7: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4572,6 +5094,10 @@ snapshots: dependencies: reusify: 1.1.0 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -4637,6 +5163,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -4691,6 +5221,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} inherits@2.0.4: {} @@ -4715,12 +5247,16 @@ snapshots: is-promise@4.0.0: {} + is-stream@4.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 is-windows@1.0.2: {} + isarray@1.0.0: {} + isexe@2.0.0: {} jose@6.2.2: {} @@ -4758,6 +5294,10 @@ snapshots: kind-of@6.0.3: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + lightningcss-android-arm64@1.32.0: optional: true @@ -4866,6 +5406,10 @@ snapshots: mimic-function@5.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimist@1.2.8: {} moment@2.30.1: {} @@ -4880,6 +5424,8 @@ snapshots: negotiator@1.0.0: {} + normalize-path@3.0.0: {} + nostr-tools@2.18.2(typescript@5.9.3): dependencies: '@noble/ciphers': 0.5.3 @@ -5001,6 +5547,8 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5049,13 +5597,22 @@ snapshots: pretty-bytes@7.1.0: {} + process-nextick-args@2.0.1: {} + process-warning@5.0.0: {} + process@0.11.10: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -5087,6 +5644,28 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@3.0.0: + dependencies: + minimatch: 10.2.5 + real-require@0.2.0: {} require-from-string@2.0.2: {} @@ -5226,6 +5805,10 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} @@ -5372,6 +5955,15 @@ snapshots: std-env@4.0.0: {} + streamx@2.27.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-argv@0.3.2: {} string-width@7.2.0: @@ -5385,6 +5977,14 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -5397,8 +5997,32 @@ snapshots: strip-bom@3.0.0: {} + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.2 + fast-fifo: 1.3.2 + streamx: 2.27.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.27.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + term-size@2.2.1: {} + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thread-stream@4.0.0: dependencies: real-require: 0.2.0 @@ -5443,6 +6067,8 @@ snapshots: unpipe@1.0.0: {} + util-deprecate@1.0.2: {} + vary@1.1.2: {} vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3): @@ -5510,6 +6136,17 @@ snapshots: yaml@2.8.3: {} + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + zip-stream@7.0.5: + dependencies: + compress-commons: 7.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/src/cli.ts b/src/cli.ts index bf7e54546..875847e15 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { runList } from './list.ts'; import { removeCommand, parseRemoveOptions } from './remove.ts'; import { track } from './telemetry.ts'; import { serve, showServeHelp } from './serve.ts'; +import { pack, showPackHelp, parsePackArgs } from './pack.ts'; import { showUseHelp, use } from './use.ts'; import { call, parseCallArgs, showCallHelp } from './call.ts'; import { discover, parseDiscoverArgs, showDiscoverHelp } from './discover.ts'; @@ -64,6 +65,7 @@ function showBanner(): void { console.log(); const entries: [string, string][] = [ ['npx cvmi add [options]', 'Install ContextVM skills'], + ['npx cvmi pack [options]', 'Package a server into a CVMB bundle'], ['npx cvmi serve [options] -- ', 'Expose MCP server over Nostr'], ['npx cvmi use ', 'Connect to Nostr MCP server'], ['npx cvmi config ', 'Manage saved server aliases'], @@ -93,6 +95,7 @@ ${BOLD}Commands:${RESET} remove, rm, r Remove installed skills list, ls List installed skills init [name] Initialize a new skill + pack Package an MCP server into a CVMB bundle sync Sync skills from node_modules serve Expose an MCP server over Nostr use Connect to a remote Nostr MCP server @@ -117,7 +120,9 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi add ${DIM}# install embedded ContextVM skills${RESET} ${DIM}$${RESET} cvmi add --skill overview ${DIM}# install a specific skill${RESET} ${DIM}$${RESET} cvmi remove ${DIM}# remove an installed skill${RESET} + ${DIM}$${RESET} cvmi pack ${DIM}# pack a server into .cvmb bundle${RESET} ${DIM}$${RESET} cvmi serve -- ${DIM}# start gateway, expose an already existing server (stdio or http) over nostr${RESET} + ${DIM}$${RESET} cvmi serve my-server.cvmb ${DIM}# serve a packed cvmb bundle over nostr${RESET} ${DIM}$${RESET} cvmi use ${DIM}# connect to remote MCP server, expose it as stdio${RESET} ${DIM}$${RESET} cvmi discover ${DIM}# find public ContextVM servers${RESET} ${DIM}$${RESET} cvmi call ${DIM}# list remote capabilities${RESET} @@ -957,6 +962,23 @@ async function main(): Promise { case 'upgrade': runUpdate(); break; + case 'pack': { + const parsed = parsePackArgs(restArgs); + + if (parsed.unknownFlags.length > 0) { + console.error(`Unknown flag(s): ${parsed.unknownFlags.join(', ')}`); + console.error(`Run 'cvmi pack --help' for usage.`); + process.exit(1); + } + + if (parsed.help) { + showPackHelp(); + break; + } + + await pack(parsed.targetDir, parsed.options); + break; + } case 'serve': { ensureRelayRuntime(); // Check for --help or -h flag (only before `--` separator) diff --git a/src/pack.ts b/src/pack.ts new file mode 100644 index 000000000..701ca6d3c --- /dev/null +++ b/src/pack.ts @@ -0,0 +1,223 @@ +import { ZipArchive } from 'archiver'; +import { createWriteStream, existsSync, readFileSync } from 'fs'; +import { join, resolve } from 'path'; +import * as p from '@clack/prompts'; +import { runPackInit } from './pack/pack-init.ts'; +import { validateManifest, type CvmbManifest } from './pack/cvm-manifest.ts'; +import { computeDirectoryContentHash, signManifest } from './pack/crypto.ts'; +import { BOLD, DIM, RESET } from './constants/ui.ts'; +import { BUNDLE_IGNORE_PATTERNS, CONTENT_HASH_IGNORE_PATTERNS } from './pack/constants.ts'; + +export interface PackOptions { + output?: string; + manifest?: string; + noValidate?: boolean; + verbose?: boolean; +} + +export async function pack(targetDir: string = '.', options: PackOptions = {}): Promise { + const dir = resolve(targetDir); + + if (!existsSync(dir)) { + p.log.error(`Directory not found: ${dir}`); + process.exit(1); + } + + const manifestPath = options.manifest ? resolve(options.manifest) : join(dir, 'manifest.json'); + + if (!existsSync(manifestPath)) { + p.log.info(`Manifest not found at ${manifestPath}`); + const initialized = await runPackInit(dir); + if (!initialized) { + process.exit(1); + } + } + + let manifest: CvmbManifest; + try { + const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); + if (!options.noValidate) { + manifest = validateManifest(raw, true); + } else { + manifest = raw as CvmbManifest; + } + } catch (error) { + p.log.error(`Invalid manifest: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + + const outFileName = options.output || `${manifest.name}-${manifest.version}.cvmb`; + const outPath = resolve(outFileName); + + p.log.info(`Packing ${manifest.name} v${manifest.version}...`); + + if (manifest.server.type === 'node') { + if (!existsSync(join(dir, 'node_modules'))) { + p.log.warn( + 'No node_modules directory found. Node.js servers usually require bundled dependencies.' + ); + } + } + + if (manifest.server.type === 'docker' && !manifest.server.image) { + p.log.warn( + 'Docker server type detected but no "image" field found in manifest. Bundle may not work.' + ); + } + + // 1. Cryptography Phase: Hashing + const sHash = p.spinner(); + sHash.start('Computing Merkle content hash...'); + const contentHash = await computeDirectoryContentHash(dir, CONTENT_HASH_IGNORE_PATTERNS); + sHash.stop(`Content hash computed: ${contentHash.slice(0, 16)}...`); + + if (!manifest._meta) manifest._meta = {}; + if (!manifest._meta['com.contextvm']) manifest._meta['com.contextvm'] = {}; + manifest._meta['com.contextvm'].content_hash = contentHash; + + // Remove existing signature to prevent invalidation + delete manifest._sig; + + // 2. Cryptography Phase: Signing + const shouldSign = await p.confirm({ + message: 'Do you want to cryptographically sign this bundle with a Nostr key?', + initialValue: true, + }); + + if (p.isCancel(shouldSign)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + if (shouldSign) { + const privateKeyHex = await p.password({ + message: 'Enter your Nostr private key (hex) to sign the bundle:', + validate: (value) => { + if (!value) return 'Private key is required to sign.'; + if (!/^[0-9a-fA-F]{64}$/.test(value)) return 'Must be a 64-character hex string.'; + }, + }); + + if (p.isCancel(privateKeyHex)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + try { + manifest._sig = signManifest(manifest, privateKeyHex as string); + p.log.success(`Signed bundle successfully (pubkey: ${manifest._sig.pubkey.slice(0, 8)}...)`); + } catch (err) { + p.log.error(`Signing failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + } else { + p.log.warn( + 'Creating unsigned bundle. This bundle will trigger warnings when users install it.' + ); + } + + // 3. Archive Phase + await new Promise((resolvePromise, rejectPromise) => { + const output = createWriteStream(outPath); + const archive = new ZipArchive({ + zlib: { level: 9 }, // maximum compression + }); + + output.on('close', () => { + p.log.success(`Created bundle: ${outPath} (${archive.pointer()} bytes)`); + resolvePromise(); + }); + + archive.on('error', (err: Error) => { + rejectPromise(err); + }); + + archive.pipe(output); + + // Add the signed/hashed manifest directly from memory + archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' }); + + // Add all files from directory, excluding some common things we don't want + archive.glob('**/*', { + cwd: dir, + dot: true, + ignore: [ + ...BUNDLE_IGNORE_PATTERNS, + 'manifest.json', // Excluded so we don't add the unsigned source file + outFileName, + ], + }); + + archive.finalize(); + }); +} + +export function showPackHelp(): void { + console.log(` +${BOLD}Usage:${RESET} + cvmi pack [directory] [options] + +${BOLD}Description:${RESET} + Package a local MCP server into a distributable ContextVM bundle (.cvmb). + If no manifest.json exists, an interactive wizard will help you create one. + +${BOLD}Options:${RESET} + --output, -o Custom output file name + --manifest, -m Custom manifest path (default: manifest.json) + --no-validate Skip manifest validation + --verbose Enable verbose logging + --help, -h Show this help message + +${BOLD}Examples:${RESET} + ${DIM}$${RESET} cvmi pack ${DIM}# package current directory${RESET} + ${DIM}$${RESET} cvmi pack ./my-server ${DIM}# package specific directory${RESET} + ${DIM}$${RESET} cvmi pack -o custom-name.cvmb ${DIM}# custom output name${RESET} + `); +} + +export function parsePackArgs(args: string[]): { + targetDir: string; + options: PackOptions; + help: boolean; + unknownFlags: string[]; +} { + const result = { + targetDir: '.', + options: {} as PackOptions, + help: false, + unknownFlags: [] as string[], + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i] ?? ''; + + const consumeValue = (flagName: string): string | undefined => { + const nextIndex = ++i; + const value = args[nextIndex]; + if (value === undefined || value.startsWith('-')) { + result.unknownFlags.push(`${flagName} (missing value)`); + if (value?.startsWith('-')) i--; + return undefined; + } + return value; + }; + + if (arg === '--help' || arg === '-h') { + result.help = true; + } else if (arg === '--verbose') { + result.options.verbose = true; + } else if (arg === '--no-validate') { + result.options.noValidate = true; + } else if (arg === '--output' || arg === '-o') { + result.options.output = consumeValue(arg); + } else if (arg === '--manifest' || arg === '-m') { + result.options.manifest = consumeValue(arg); + } else if (arg.startsWith('-')) { + result.unknownFlags.push(arg); + } else { + result.targetDir = arg; + } + } + + return result; +} diff --git a/src/pack/constants.ts b/src/pack/constants.ts new file mode 100644 index 000000000..fddbea43f --- /dev/null +++ b/src/pack/constants.ts @@ -0,0 +1,35 @@ +/** + * Ignored files/directories when calculating the bundle's Merkle content hash. + * node_modules is ignored to avoid hashing hundreds of megabytes of dependencies, + * meaning node_modules integrity relies on lockfiles being in the hash. + * + * Note: `.cvmb`/`.mcpb` bundle artifacts are excluded separately by extension + * in `computeDirectoryContentHash`, so they are not listed here. + */ +export const CONTENT_HASH_IGNORE_PATTERNS = ['.git', 'node_modules', '.DS_Store', '.env']; + +/** + * Ignored glob patterns when building the final ZIP archive. + */ +export const BUNDLE_IGNORE_PATTERNS = [ + '.git/**', + 'node_modules/.cache/**', + '.DS_Store', + '.env', + '*.cvmb', + '*.mcpb', +]; + +/** + * Current version of the CVM/MCPB manifest specification. + */ +export const CVM_MANIFEST_VERSION = '0.3'; + +/** + * Nostr event kind used as an opaque local signing container for manifest + * signatures. The signing event is never published to relays — it exists only + * so we can sign/verify the canonical manifest via nostr-tools (`finalizeEvent` + * / `verifyEvent`) without performing manual curve operations. The value is + * arbitrary but MUST be identical at sign and verify time. + */ +export const MANIFEST_SIGNATURE_KIND = 9501; diff --git a/src/pack/crypto.test.ts b/src/pack/crypto.test.ts new file mode 100644 index 000000000..c8a11787e --- /dev/null +++ b/src/pack/crypto.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { canonicalizeManifest, signManifest, verifyManifestSignature } from './crypto.ts'; +import type { CvmbManifest } from './cvm-manifest.ts'; +import { generatePrivateKey } from '../utils/crypto.ts'; // returns 64-char hex + +describe('crypto', () => { + const dummyManifest: CvmbManifest = { + manifest_version: '0.3', + name: 'test-server', + version: '1.0.0', + description: 'A test server', + author: { name: 'Alice' }, + server: { + type: 'node', + transport: 'stdio', + mcp_config: { + command: 'node', + args: ['index.js'], + }, + }, + _meta: { + 'com.contextvm': { + content_hash: 'sha256:dummyhash', + }, + }, + }; + + it('should canonicalize manifest consistently', () => { + const json1 = canonicalizeManifest(dummyManifest); + // Keys should be ordered alphabetically, no whitespace + expect(json1).toContain('{"_meta":{"com.contextvm":{"content_hash":"sha256:dummyhash"}}'); + expect(json1).toContain('"author":{"name":"Alice"}'); + }); + + it('should omit _sig when canonicalizing', () => { + const signedManifest = { + ...dummyManifest, + _sig: { + pubkey: 'dummy', + id: 'dummy', + signature: 'dummy', + created_at: 12345, + }, + }; + const json1 = canonicalizeManifest(dummyManifest); + const json2 = canonicalizeManifest(signedManifest); + expect(json1).toBe(json2); + }); + + it('should produce a well-formed _sig block', () => { + const privKey = generatePrivateKey(); // 64-char hex + const sig = signManifest(dummyManifest, privKey); + + expect(sig.pubkey).toMatch(/^[a-f0-9]{64}$/); // Nostr pubkey (x-only) + expect(sig.id).toMatch(/^[a-f0-9]{64}$/); // NIP-01 event id (sha256) + expect(sig.signature).toMatch(/^[a-f0-9]{128}$/); // Schnorr signature + expect(typeof sig.created_at).toBe('number'); + }); + + it('should successfully sign and verify a manifest', async () => { + const privKey = generatePrivateKey(); + const signedManifest: CvmbManifest = { ...dummyManifest }; + signedManifest._sig = signManifest(signedManifest, privKey); + + // Verification should pass without throwing + await expect(Promise.resolve(verifyManifestSignature(signedManifest))).resolves.toBe(true); + }); + + it('should fail verification if manifest is tampered', () => { + const privKey = generatePrivateKey(); + const signedManifest: CvmbManifest = { ...dummyManifest } as CvmbManifest; + signedManifest._sig = signManifest(signedManifest, privKey); + + // Tamper with the manifest after signing + signedManifest.version = '1.0.1'; + + expect(() => verifyManifestSignature(signedManifest)).toThrow('Invalid manifest signature'); + }); + + it('should fail verification if signature is tampered', () => { + const privKey = generatePrivateKey(); + const signedManifest: CvmbManifest = { ...dummyManifest } as CvmbManifest; + signedManifest._sig = signManifest(signedManifest, privKey); + + // Tamper with signature + signedManifest._sig.signature = signedManifest._sig.signature.replace(/0/g, '1'); + + expect(() => verifyManifestSignature(signedManifest)).toThrow('Invalid manifest signature'); + }); + + it('should fail verification if unsigned', () => { + expect(() => verifyManifestSignature({ ...dummyManifest })).toThrow('Manifest is not signed'); + }); +}); diff --git a/src/pack/crypto.ts b/src/pack/crypto.ts new file mode 100644 index 000000000..0ee02c6f2 --- /dev/null +++ b/src/pack/crypto.ts @@ -0,0 +1,153 @@ +import canonicalize from 'canonicalize'; +import { createHash } from 'crypto'; +import { finalizeEvent, verifyEvent } from 'nostr-tools'; +import { hexToBytes } from 'nostr-tools/utils'; +import { readdir, readFile } from 'fs/promises'; +import { join, relative } from 'path'; +import type { CvmbManifest } from './cvm-manifest.ts'; +import { CONTENT_HASH_IGNORE_PATTERNS, MANIFEST_SIGNATURE_KIND } from './constants.ts'; + +/** + * Canonicalizes a manifest object according to RFC 8785. + * The `_sig` field is removed before canonicalization so the digest is stable + * across signing and verification. + */ +export function canonicalizeManifest(manifest: CvmbManifest): string { + const { _sig, ...manifestWithoutSig } = manifest; + const canonical = canonicalize(manifestWithoutSig); + if (!canonical) { + throw new Error('Failed to canonicalize manifest'); + } + return canonical; +} + +/** + * Signs a manifest using the author's Nostr private key (64-char hex). + * + * The canonical manifest becomes the `content` of a Nostr signing event, which + * is signed with nostr-tools' `finalizeEvent`. This binds the author's Nostr + * identity to the exact manifest bytes while delegating all curve math to + * nostr-tools (no direct use of @noble/curves). + * + * Returns the complete `_sig` object to be injected into the manifest. + */ +export function signManifest(manifest: CvmbManifest, privateKeyHex: string) { + const content = canonicalizeManifest(manifest); + const event = finalizeEvent( + { + kind: MANIFEST_SIGNATURE_KIND, + tags: [], + content, + created_at: Math.floor(Date.now() / 1000), + }, + hexToBytes(privateKeyHex) + ); + + return { + pubkey: event.pubkey, + id: event.id, + signature: event.sig, + created_at: event.created_at, + }; +} + +/** + * Verifies the `_sig` block of a manifest. + * + * Reconstructs the signing event from the canonical manifest plus the `_sig` + * fields and delegates to nostr-tools' `verifyEvent`, which checks both the + * NIP-01 event id and the Schnorr signature. + * + * Throws an error if the manifest is unsigned or the signature is invalid. + */ +export function verifyManifestSignature(manifest: CvmbManifest): boolean { + const sig = manifest._sig; + if (!sig) { + throw new Error('Manifest is not signed'); + } + + const content = canonicalizeManifest(manifest); + const event = { + kind: MANIFEST_SIGNATURE_KIND, + tags: [], + content, + pubkey: sig.pubkey, + id: sig.id, + sig: sig.signature, + created_at: sig.created_at, + }; + + if (!verifyEvent(event)) { + throw new Error( + 'Invalid manifest signature: the manifest was modified after signing or the signature is corrupt.' + ); + } + + return true; +} + +/** + * Computes a Merkle-style hash over a directory's contents. + * 1. Hashes each file's contents (excluding manifest.json and ignored patterns). + * 2. Sorts paths alphabetically. + * 3. Concatenates path:hash\n and hashes the result. + * + * Note: node_modules is ignored to avoid hashing hundreds of megabytes of dependencies. + * Therefore, node_modules integrity relies on lockfiles (package-lock.json, etc.) being in the hash. + */ +export async function computeDirectoryContentHash( + dir: string, + ignoreList: string[] = CONTENT_HASH_IGNORE_PATTERNS +): Promise { + const allFiles = await getFilesRecursive(dir); + + // Filter out manifest and ignored patterns + const filteredFiles = allFiles.filter((file) => { + const relPath = relative(dir, file).replace(/\\/g, '/'); + if (relPath === 'manifest.json') return false; + if (relPath.endsWith('.cvmb') || relPath.endsWith('.mcpb')) return false; + + // Path-segment-aware ignore logic + const segments = relPath.split(/[/\\]/); + for (const ignore of ignoreList) { + if (segments.includes(ignore)) return false; + } + return true; + }); + + const fileHashes: Array<{ path: string; hash: string }> = []; + + for (const file of filteredFiles) { + const content = await readFile(file); + const hash = createHash('sha256').update(content).digest('hex'); + const relPath = relative(dir, file).replace(/\\/g, '/'); // Normalize path separators + fileHashes.push({ path: relPath, hash }); + } + + // Sort alphabetically by path + fileHashes.sort((a, b) => a.path.localeCompare(b.path)); + + // Concatenate path:hash\n + const manifestContent = fileHashes.map((f) => `${f.path}:${f.hash}`).join('\n'); + + return 'sha256:' + createHash('sha256').update(manifestContent).digest('hex'); +} + +/** + * Helper to recursively get all files in a directory. + */ +async function getFilesRecursive(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const res = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await getFilesRecursive(res))); + } else { + files.push(res); + } + } + + return files; +} diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts new file mode 100644 index 000000000..914cfec86 --- /dev/null +++ b/src/pack/cvm-manifest.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +export const UserConfigFieldSchema = z.object({ + type: z.enum(['string', 'number', 'boolean', 'directory', 'file']), + title: z.string().optional(), + description: z.string().optional(), + required: z.boolean().optional(), + default: z.any().optional(), + multiple: z.boolean().optional(), + sensitive: z.boolean().optional(), + min: z.number().optional(), + max: z.number().optional(), +}); + +export type UserConfigField = z.infer; + +export const CVMSigSchema = z.object({ + pubkey: z.string(), + id: z.string(), + signature: z.string(), + created_at: z.number(), +}); + +export type CVMSig = z.infer; + +export const CVMMetaSchema = z.object({ + content_hash: z.string().optional(), +}); + +export type CVMMeta = z.infer; + +export const CvmbManifestSchema = z.object({ + manifest_version: z.string(), + name: z.string(), + display_name: z.string().optional(), + version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be a valid semver version (e.g. 1.0.0)'), + description: z.string(), + author: z.object({ + name: z.string(), + email: z.string().optional(), + url: z.string().optional(), + }), + server: z.object({ + type: z.enum(['node', 'python', 'uv', 'binary', 'docker']), + entry_point: z.string().optional(), + image: z.string().optional(), + compose_file: z.string().optional(), + transport: z.enum(['stdio', 'cvm']).default('stdio'), + mcp_config: z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }), + }), + user_config: z.record(z.string(), UserConfigFieldSchema).optional(), + _meta: z + .object({ + 'com.contextvm': CVMMetaSchema.optional(), + }) + .optional(), + _sig: CVMSigSchema.optional(), +}); + +export type CvmbManifest = z.infer; + +export const CvmbManifestSchemaStrict = CvmbManifestSchema.strict(); +export const CvmbManifestSchemaPassthrough = CvmbManifestSchema.passthrough(); + +export function validateManifest(data: unknown, strict = false): CvmbManifest { + if (strict) { + return CvmbManifestSchemaStrict.parse(data); + } + return CvmbManifestSchemaPassthrough.parse(data); +} diff --git a/src/pack/extract.ts b/src/pack/extract.ts new file mode 100644 index 000000000..ccbe53a2d --- /dev/null +++ b/src/pack/extract.ts @@ -0,0 +1,38 @@ +import extractZip from 'extract-zip'; +import { readFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import os from 'os'; +import { validateManifest, type CvmbManifest } from './cvm-manifest.ts'; +import { randomBytes } from 'crypto'; + +export async function extractBundle( + bundlePath: string +): Promise<{ dir: string; manifest: CvmbManifest }> { + // Use a unique temp directory for extraction + const extractDir = join(os.tmpdir(), `cvmi-bundle-${randomBytes(8).toString('hex')}`); + // Ensure the directory is only readable/writable by the current user + mkdirSync(extractDir, { mode: 0o700, recursive: true }); + + try { + await extractZip(bundlePath, { dir: extractDir }); + } catch (err) { + throw new Error( + `Failed to extract bundle: ${err instanceof Error ? err.message : String(err)}` + ); + } + + const manifestPath = join(extractDir, 'manifest.json'); + if (!existsSync(manifestPath)) { + throw new Error('Invalid bundle: manifest.json not found inside the archive.'); + } + + try { + const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); + const manifest = validateManifest(raw); + return { dir: extractDir, manifest }; + } catch (error) { + throw new Error( + `Invalid manifest in bundle: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/src/pack/pack-init.ts b/src/pack/pack-init.ts new file mode 100644 index 000000000..9e750f42c --- /dev/null +++ b/src/pack/pack-init.ts @@ -0,0 +1,190 @@ +import * as p from '@clack/prompts'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, basename } from 'path'; +import { CVM_MANIFEST_VERSION } from './constants.ts'; + +export async function runPackInit(dir: string): Promise { + const manifestPath = join(dir, 'manifest.json'); + if (existsSync(manifestPath)) { + p.log.info('manifest.json already exists.'); + return true; + } + + p.log.info("No manifest.json found. Let's create one."); + + let defaultName = basename(dir); + let defaultVersion = '1.0.0'; + let defaultDescription = ''; + + const pkgJsonPath = join(dir, 'package.json'); + if (existsSync(pkgJsonPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); + if (pkg.name) defaultName = pkg.name; + if (pkg.version) defaultVersion = pkg.version; + if (pkg.description) defaultDescription = pkg.description; + } catch {} + } + + const result = await p.group( + { + name: () => + p.text({ + message: 'Server name', + initialValue: defaultName, + validate: (value) => { + if (!value) return 'Please enter a name.'; + if (!/^[a-z0-9-]+$/.test(value)) + return 'Name can only contain lowercase letters, numbers, and dashes.'; + }, + }), + displayName: ({ results }) => + p.text({ + message: 'Display name', + initialValue: results.name, + }), + version: () => + p.text({ + message: 'Version', + initialValue: defaultVersion, + }), + description: () => + p.text({ + message: 'Description', + initialValue: defaultDescription, + }), + author: () => + p.text({ + message: 'Author name', + validate: (value) => { + if (!value) return 'Please enter an author name.'; + }, + }), + type: () => + p.select({ + message: 'Server type', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'uv', label: 'Python (UV)' }, + { value: 'binary', label: 'Binary' }, + { value: 'docker', label: 'Docker' }, + ], + initialValue: 'node', + }), + image: ({ results }) => { + if (results.type !== 'docker') return Promise.resolve(undefined); + return p.text({ + message: 'Docker image (e.g., ghcr.io/dev/my-server:1.0.0)', + validate: (value) => { + if (!value) return 'Please enter a Docker image reference.'; + }, + }); + }, + entryPoint: ({ results }) => { + if (results.type === 'docker') return Promise.resolve(undefined); + let initial = 'index.js'; + if (results.type === 'node') initial = 'build/index.js'; + if (results.type === 'python' || results.type === 'uv') initial = 'src/server.py'; + if (results.type === 'binary') initial = 'bin/server'; + return p.text({ + message: 'Entry point path', + initialValue: initial, + }); + }, + transport: () => + p.select({ + message: 'Transport mode', + options: [ + { + value: 'stdio', + label: 'stdio (Gateway wraps the server, simplest)', + }, + { + value: 'cvm', + label: 'cvm (Server uses CVM SDK directly, advanced)', + }, + ], + initialValue: 'stdio', + }), + }, + { + onCancel: () => { + p.cancel('Operation cancelled.'); + process.exit(0); + }, + } + ); + + // Build mcp_config based on server type + let mcpConfig: { command: string; args: string[]; env?: Record }; + if (result.type === 'docker') { + mcpConfig = { + command: 'docker', + args: ['run', '--rm', '-i', result.image as string], + }; + } else if (result.type === 'uv') { + mcpConfig = { + command: 'uv', + args: ['run', `\${__dirname}/${result.entryPoint}`], + }; + } else if (result.type === 'binary') { + mcpConfig = { + command: `\${__dirname}/${result.entryPoint}`, + args: [], + }; + } else { + const cmd = result.type === 'python' ? 'python' : 'node'; + mcpConfig = { + command: cmd, + args: [`\${__dirname}/${result.entryPoint}`], + }; + } + + // Example of using user_config for CVM relays mapping + if (result.transport === 'cvm') { + mcpConfig.env = { + CVM_RELAYS: '${user_config.relays}', + }; + } + + // Build server section + const server: Record = { + type: result.type, + transport: result.transport, + mcp_config: mcpConfig, + }; + + if (result.type === 'docker') { + server.image = result.image; + } else { + server.entry_point = result.entryPoint; + } + + const manifest: Record = { + manifest_version: CVM_MANIFEST_VERSION, + name: result.name, + display_name: result.displayName, + version: result.version, + description: result.description, + author: { + name: result.author, + }, + server, + }; + + if (result.transport === 'cvm') { + manifest.user_config = { + relays: { + type: 'string', + title: 'Relays (comma separated)', + default: 'wss://relay.contextvm.org', + }, + }; + } + + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + p.log.success(`Created manifest.json in ${dir}`); + + return true; +} diff --git a/src/serve.ts b/src/serve.ts index b3210943c..0a695ac09 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -12,10 +12,18 @@ import { NostrMCPGateway, PrivateKeySigner, EncryptionMode } from '@contextvm/sd import { loadConfig, getServeConfig, DEFAULT_RELAYS } from './config/index.ts'; import { generatePrivateKey, normalizePrivateKey } from './utils/crypto.ts'; import { waitForShutdownSignal } from './utils/process.ts'; +import { extractBundle } from './pack/extract.ts'; +import fs from 'fs'; import { BOLD, DIM, RESET } from './constants/ui.ts'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { savePrivateKeyToEnv } from './config/loader.ts'; import { normalizeCommandAndArgs, splitCommandString } from './utils/command.ts'; +import { computeDirectoryContentHash, verifyManifestSignature } from './pack/crypto.ts'; +import { CONTENT_HASH_IGNORE_PATTERNS } from './pack/constants.ts'; + +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} function isHttpUrl(value: string): boolean { try { @@ -120,15 +128,206 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis // Priority: // - CLI args (positional) override config entirely // - otherwise config.url (remote Streamable HTTP) wins over config.command/config.args - const target = + let target = serverArgs.length > 0 ? serverArgs[0] : serveConfig.url ? serveConfig.url : serveConfig.command; - const targetArgs = serverArgs.length > 0 ? serverArgs.slice(1) : (serveConfig.args ?? []); + let targetArgs = serverArgs.length > 0 ? serverArgs.slice(1) : (serveConfig.args ?? []); if (!target) { showServeHelp(); process.exit(1); } + let cleanupPath: string | undefined; + + // Handle .cvmb / .mcpb bundle execution + if (target.endsWith('.cvmb') || target.endsWith('.mcpb')) { + p.log.info(`Extracting bundle ${target}...`); + // Tracks whether the extracted dir has been handed off to the long-lived + // gateway (stdio transport). When true, the `finally` below must NOT remove + // it — end-of-function cleanup + the `exit` hook own its lifecycle instead. + let handedOffToGateway = false; + try { + const { dir, manifest } = await extractBundle(target); + cleanupPath = dir; + // Synchronous backstop: guarantees cleanup on crashes / unexpected exits + // that bypass the `finally` (e.g. uncaught throws, process.exit()). + process.on('exit', () => { + if (cleanupPath && fs.existsSync(cleanupPath)) { + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + }); + + // 1. Content Hash Verification + const expectedHash = manifest._meta?.['com.contextvm']?.content_hash; + if (expectedHash) { + const actualHash = await computeDirectoryContentHash(dir, CONTENT_HASH_IGNORE_PATTERNS); + if (actualHash !== expectedHash) { + throw new Error( + 'Content hash verification failed! The bundle contents have been modified.' + ); + } + } else { + p.log.warn('No content hash found in manifest. Bundle integrity cannot be verified.'); + } + + // 2. Signature Verification + if (manifest._sig) { + verifyManifestSignature(manifest); + p.log.success(`Signature verified. Author: ${manifest._sig.pubkey.slice(0, 8)}...`); + } else { + p.log.warn('No cryptographic signature found. You are running unverified code.'); + const proceed = await p.confirm({ + message: 'Do you want to proceed anyway?', + initialValue: false, + }); + if (!proceed) process.exit(1); + } + + // 3. User Configuration Prompting + const userConfigValues: Record = {}; + if (manifest.user_config) { + for (const [key, field] of Object.entries(manifest.user_config)) { + // Check if env var already satisfies this + const existingEnv = process.env[key.toUpperCase()]; + if (existingEnv !== undefined) { + userConfigValues[key] = existingEnv; + continue; + } + + if (field.type === 'boolean') { + userConfigValues[key] = await p.confirm({ + message: field.title || key, + initialValue: field.default ?? false, + }); + } else { + const promptMethod = field.sensitive ? p.password : p.text; + userConfigValues[key] = await promptMethod({ + message: `${field.title || key}${field.description ? ` (${field.description})` : ''}`, + initialValue: field.default?.toString(), + validate: (val) => { + if (field.required && !val) return 'This field is required.'; + }, + }); + } + + if (p.isCancel(userConfigValues[key])) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + } + } + + // 4. Resolve command and args from manifest + target = manifest.server.mcp_config.command.replace(/\$\{__dirname\}/g, dir); + const rawArgs = manifest.server.mcp_config.args || []; + targetArgs = rawArgs.map((arg: string) => { + let resolved = arg.replace(/\$\{__dirname\}/g, dir); + // Replace ${user_config.X} in args + for (const [key, val] of Object.entries(userConfigValues)) { + resolved = resolved.replace( + new RegExp(`\\$\\{user_config\\.${escapeRegExp(key)}\\}`, 'g'), + String(val) + ); + } + return resolved; + }); + + // 5. Merge mcp_config.env into spawn environment + const manifestEnv = manifest.server.mcp_config.env; + const resolvedManifestEnv: Record = {}; + if (manifestEnv) { + for (const [key, val] of Object.entries(manifestEnv)) { + let resolved = (val as string).replace(/\$\{__dirname\}/g, dir); + // Replace ${user_config.X} + for (const [cfgKey, cfgVal] of Object.entries(userConfigValues)) { + resolved = resolved.replace( + new RegExp(`\\$\\{user_config\\.${escapeRegExp(cfgKey)}\\}`, 'g'), + String(cfgVal) + ); + } + resolvedManifestEnv[key] = resolved; + } + Object.assign(serveConfig, { + env: { ...(serveConfig.env || {}), ...resolvedManifestEnv }, + }); + } + + const transport = manifest.server.transport || 'stdio'; + + if (transport === 'cvm') { + // ── Native CVM transport ── + p.log.info(`Transport: cvm (native CVM server, no Gateway)`); + + // We ensure standard CVM vars are set if user_config resolved them + const cvmEnv = { + ...process.env, + ...(serveConfig.env || {}), + }; + + if (options.verbose) { + p.log.message( + `Injected env vars from bundle: ${Object.keys(resolvedManifestEnv).join(', ')}` + ); + } + + const { spawn } = await import('child_process'); + const normalized = normalizeCommandAndArgs(target, targetArgs); + const child = spawn(normalized.command, normalized.args, { + stdio: 'inherit', + env: cvmEnv, + }); + + p.outro(pc.green('CVM native server started. Press Ctrl+C to stop.')); + + const signal = await waitForShutdownSignal(); + p.log.message(`\n${signal} received. Shutting down...`); + child.kill('SIGTERM'); + + // The `finally` block cleans up the extracted bundle dir on exit. + process.exit(0); + } else { + // ── stdio transport (default) ── + p.log.info(`Transport: stdio (Gateway wraps the server)`); + + // If the bundle user_config provided typical CVM fields, use them to configure gateway + if (options.relays === undefined && !config.serve?.relays && userConfigValues.relays) { + serveConfig.relays = String(userConfigValues.relays) + .split(',') + .map((r) => r.trim()); + } + if ( + options.public === undefined && + !config.serve?.public && + userConfigValues.public !== undefined + ) { + serveConfig.public = Boolean(userConfigValues.public); + } + if ( + options.encryption === undefined && + !config.serve?.encryption && + userConfigValues.encryption + ) { + serveConfig.encryption = userConfigValues.encryption as EncryptionMode; + } + } + + // The extracted bundle dir is now owned by the gateway spawned below; it + // must persist until the gateway shuts down, so keep `finally` from + // removing it prematurely. (The cvm branch exits before reaching here.) + handedOffToGateway = true; + } catch (error) { + p.log.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } finally { + // Guaranteed cleanup for the cvm runtime path and any setup error. + // The stdio path is skipped (dir handed off to the gateway below). + if (cleanupPath && !handedOffToGateway) { + p.log.message(`Cleaning up temporary bundle at ${cleanupPath}`); + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + } + } + // Auto-generate private key if not provided let privateKey = serveConfig.privateKey; if (!privateKey) { @@ -227,6 +426,11 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis p.log.message(`\n${signal} received. Shutting down...`); await gateway.stop(); + if (cleanupPath && fs.existsSync(cleanupPath)) { + p.log.message(`Cleaning up temporary bundle at ${cleanupPath}`); + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + process.exit(0); } @@ -246,6 +450,8 @@ ${BOLD}Arguments:${RESET} Can also be specified in config file under serve.command If the first argument is an http(s) URL, cvmi will treat it as a Streamable HTTP MCP server and connect via HTTP instead of spawning a local process. + If the first argument is an .cvmb file, cvmi will extract the bundle, + read the manifest, apply CVM config defaults, and spawn the server. ${BOLD}Config keys:${RESET} serve.url Optional remote MCP server URL (Streamable HTTP). If set, it is used when no CLI target @@ -304,6 +510,7 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi serve https://mcp.server.com ${DIM}# expose a remote Streamable HTTP MCP server over Nostr${RESET} ${DIM}$${RESET} cvmi serve npx -y @modelcontextprotocol/server-prompt-generator --public ${DIM}# public server${RESET} ${DIM}$${RESET} cvmi serve python /path/to/server.py --relays wss://my-relay.com ${DIM}# custom relay${RESET} + ${DIM}$${RESET} cvmi serve my-server-1.0.0.cvmb ${DIM}# run a CVMB bundle over Nostr${RESET} ${DIM}$${RESET} cvmi serve --help ${DIM}# show this help${RESET} `); }