diff --git a/AGENTS.md b/AGENTS.md index e34869d1..d861250a 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 `.mcpb` 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/package.json b/package.json index 1acb78c2..69a48e60 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,8 @@ "dependencies": { "@contextvm/sdk": "^0.11.14", "@modelcontextprotocol/sdk": "^1.27.1", + "archiver": "^8.0.0", + "extract-zip": "^2.0.1", "json-schema-to-typescript": "15.0.4", "nostr-tools": "^2.23.3", "xdg-basedir": "^5.1.0", @@ -103,7 +105,9 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@clack/prompts": "^0.11.0", + "@types/archiver": "^8.0.0", "@types/bun": "latest", + "@types/extract-zip": "^2.0.3", "@types/node": "^22.19.15", "gray-matter": "^4.0.3", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c0a4520..65d8c1ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,12 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + archiver: + specifier: ^8.0.0 + version: 8.0.0 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1 json-schema-to-typescript: specifier: 15.0.4 version: 15.0.4 @@ -32,9 +38,15 @@ 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 + '@types/extract-zip': + specifier: ^2.0.3 + version: 2.0.3 '@types/node': specifier: ^22.19.15 version: 22.19.15 @@ -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: { @@ -1275,6 +1293,13 @@ packages: integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, } + '@types/extract-zip@2.0.3': + resolution: + { + integrity: sha512-yrO7h+0qOIGxHCmBeL5fKFzR+PBafh9LG6sOLBFFi2JuN+Hj663TAxfnqJh5vkQn963VimrhBF1GZzea3A+4Ig==, + } + deprecated: This is a stub types definition. extract-zip provides its own type definitions, so you do not need this installed. + '@types/jsesc@2.5.1': resolution: { @@ -1305,6 +1330,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 +1392,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 +1470,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 +1517,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 +1530,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 +1633,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 +1647,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 +1769,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 +1823,12 @@ packages: } engines: { node: '>= 0.6' } + core-util-is@1.0.3: + resolution: + { + integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==, + } + cors@2.8.6: resolution: { @@ -1657,6 +1836,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 +1948,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 +2030,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 +2112,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 +2151,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 +2253,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 +2356,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 +2430,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 +2451,12 @@ packages: } engines: { node: '>=0.10.0' } + isarray@1.0.0: + resolution: + { + integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==, + } + isexe@2.0.0: resolution: { @@ -2258,6 +2524,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 +2740,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 +2795,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 +2989,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 +3084,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 +3110,12 @@ packages: } engines: { node: '>= 0.10' } + pump@3.0.4: + resolution: + { + integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==, + } + qs@6.15.0: resolution: { @@ -2856,6 +3168,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 +3309,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 +3550,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 +3577,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 +3617,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 +3636,12 @@ packages: } engines: { node: '>=8' } + text-decoder@1.2.7: + resolution: + { + integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==, + } + thread-stream@4.0.0: resolution: { @@ -3359,6 +3739,12 @@ packages: } engines: { node: '>= 0.8' } + util-deprecate@1.0.2: + resolution: + { + integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, + } + vary@1.1.2: resolution: { @@ -3509,6 +3895,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 +4579,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 @@ -4193,6 +4597,12 @@ snapshots: '@types/estree@1.0.8': {} + '@types/extract-zip@2.0.3': + dependencies: + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -4205,6 +4615,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 +4665,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 +4720,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 +4754,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 +4816,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 +4879,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 +4901,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 +4953,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 +5013,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 +5079,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 +5107,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 +5176,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 +5234,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} inherits@2.0.4: {} @@ -4715,12 +5260,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 +5307,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 +5419,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 +5437,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 +5560,8 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5049,13 +5610,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 +5657,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 +5818,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 +5968,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 +5990,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 +6010,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 +6080,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 +6149,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 bf7e5454..7352087b 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 an MCPB 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 an MCPB 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 .mcpb 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.mcpb ${DIM}# serve a packed mcpb 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 00000000..f1f59f3b --- /dev/null +++ b/src/pack.ts @@ -0,0 +1,165 @@ +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const archiver = require('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 McpbManifest } from './pack/cvm-manifest.ts'; +import { BOLD, DIM, RESET } from './constants/ui.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: McpbManifest; + try { + const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); + if (!options.noValidate) { + manifest = validateManifest(raw); + } else { + manifest = raw as McpbManifest; + } + } 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}.mcpb`; + 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.' + ); + } + + await new Promise((resolvePromise, rejectPromise) => { + const output = createWriteStream(outPath); + const archive = archiver('zip', { + 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 all files from directory, excluding some common things we don't want + archive.glob('**/*', { + cwd: dir, + dot: true, + ignore: ['.git/**', 'node_modules/.cache/**', '.DS_Store', '.env', '*.mcpb', 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 MCPB bundle (.mcpb). + If no manifest.json exists, an interactive wizard will help you create one + with ContextVM-specific extensions (relays, public mode, encryption). + +${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.mcpb ${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/cvm-manifest.ts b/src/pack/cvm-manifest.ts new file mode 100644 index 00000000..95a07aee --- /dev/null +++ b/src/pack/cvm-manifest.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { DEFAULT_RELAYS } from '../config/index.ts'; + +// CVM-specific defaults for a bundle +export const CVMDefaultsSchema = z.object({ + relays: z.array(z.string()).default(DEFAULT_RELAYS), + encryption: z.enum(['required', 'optional', 'disabled']).default('optional'), + public: z.boolean().default(false), +}); + +export type CVMDefaults = z.infer; + +// The env_mapping contract: maps config keys to env var names +export const CVMEnvMappingSchema = z + .record(z.enum(['relays', 'encryption', 'public', 'private_key']), z.string()) + .optional(); + +export type CVMEnvMapping = z.infer; + +// Top-level CVM meta namespace +export const CVMMetaSchema = z.object({ + transport: z.enum(['stdio', 'cvm']).default('stdio'), + env_mapping: CVMEnvMappingSchema, + defaults: CVMDefaultsSchema.optional(), +}); + +export type CVMMeta = z.infer; + +// The full manifest including MCPB and CVM extension +export const McpbManifestSchema = z + .object({ + manifest_version: z.string(), + name: z.string(), + display_name: z.string().optional(), + version: z.string(), + description: z.string().optional(), + 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(), + 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(), z.any()).optional(), + _meta: z + .object({ + 'com.contextvm': CVMMetaSchema.optional(), + }) + .optional(), + }) + .passthrough(); + +export type McpbManifest = z.infer; + +export function validateManifest(data: unknown): McpbManifest { + return McpbManifestSchema.parse(data); +} + +export const DEFAULT_CVM_META: CVMMeta = { + transport: 'stdio', + env_mapping: undefined, + defaults: { + relays: DEFAULT_RELAYS, + encryption: 'optional', + public: false, + }, +}; diff --git a/src/pack/extract.ts b/src/pack/extract.ts new file mode 100644 index 00000000..47cadcb9 --- /dev/null +++ b/src/pack/extract.ts @@ -0,0 +1,36 @@ +import extractZip from 'extract-zip'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import os from 'os'; +import { validateManifest, type McpbManifest } from './cvm-manifest.ts'; +import { randomBytes } from 'crypto'; + +export async function extractBundle( + mcpbPath: string +): Promise<{ dir: string; manifest: McpbManifest }> { + // Use a unique temp directory for extraction + const extractDir = join(os.tmpdir(), `cvmi-bundle-${randomBytes(8).toString('hex')}`); + + try { + await extractZip(mcpbPath, { 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 00000000..acf71840 --- /dev/null +++ b/src/pack/pack-init.ts @@ -0,0 +1,219 @@ +import * as p from '@clack/prompts'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, basename } from 'path'; +import { DEFAULT_RELAYS } from '../config/index.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', + }), + isPublic: () => + p.confirm({ + message: 'Should this server be public? (accept connections from any pubkey)', + initialValue: false, + }), + relays: () => + p.text({ + message: 'Default relays (comma-separated)', + initialValue: DEFAULT_RELAYS.join(', '), + }), + encryption: () => + p.select({ + message: 'Encryption mode', + options: [ + { value: 'required', label: 'Required (NIP-44 encryption)' }, + { value: 'optional', label: 'Optional (Fallback to unencrypted)' }, + { value: 'disabled', label: 'Disabled (Unencrypted)' }, + ], + initialValue: 'optional', + }), + }, + { + onCancel: () => { + p.cancel('Operation cancelled.'); + process.exit(0); + }, + } + ); + + const relaysList = result.relays + .split(',') + .map((r) => r.trim()) + .filter((r) => r); + + // Build mcp_config based on server type + let mcpConfig: { command: string; args: string[] }; + 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}`], + }; + } + + // Build server section + const server: Record = { + type: result.type, + mcp_config: mcpConfig, + }; + if (result.type === 'docker') { + server.image = result.image; + } else { + server.entry_point = result.entryPoint; + } + + // Build CVM meta + const cvmMeta: Record = { + transport: result.transport, + defaults: { + relays: relaysList, + encryption: result.encryption, + public: result.isPublic, + }, + }; + + // For native CVM transport, add default env_mapping + if (result.transport === 'cvm') { + cvmMeta.env_mapping = { + relays: 'CVM_RELAYS', + encryption: 'CVM_ENCRYPTION', + public: 'CVM_PUBLIC', + private_key: 'CVM_PRIVATE_KEY', + }; + } + + const manifest = { + manifest_version: '0.3', + name: result.name, + display_name: result.displayName, + version: result.version, + description: result.description, + author: { + name: result.author, + }, + server, + _meta: { + 'com.contextvm': cvmMeta, + }, + }; + + 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 b3210943..76a1d00b 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -12,6 +12,9 @@ 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 { DEFAULT_CVM_META } from './pack/cvm-manifest.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'; @@ -120,15 +123,128 @@ 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 .mcpb bundle execution + if (target.endsWith('.mcpb')) { + p.log.info(`Extracting bundle ${target}...`); + try { + const { dir, manifest } = await extractBundle(target); + cleanupPath = dir; + + // Load CVM config from manifest + const meta = manifest._meta?.['com.contextvm'] || DEFAULT_CVM_META; + const defaults = meta.defaults || DEFAULT_CVM_META.defaults!; + const transport = meta.transport || 'stdio'; + + // 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) => arg.replace(/\$\{__dirname\}/g, dir)); + + // Merge mcp_config.env into spawn environment (apply ${__dirname} substitution) + const manifestEnv = manifest.server.mcp_config.env; + if (manifestEnv) { + const resolvedManifestEnv: Record = {}; + for (const [key, val] of Object.entries(manifestEnv)) { + resolvedManifestEnv[key] = val.replace(/\$\{__dirname\}/g, dir); + } + Object.assign(serveConfig, { + env: { ...(serveConfig.env || {}), ...resolvedManifestEnv }, + }); + } + + if (transport === 'cvm') { + // ── Native CVM transport ── + // The server uses the CVM SDK directly (NostrServerTransport). + // We inject config as environment variables per the env_mapping contract. + // No Gateway is used. + + const envMapping = meta.env_mapping; + + // Resolve final config values (CLI flags > config file > manifest defaults) + const resolvedRelays = options.relays ?? serveConfig.relays ?? defaults.relays; + const resolvedEncryption = + options.encryption ?? serveConfig.encryption ?? defaults.encryption; + const resolvedPublic = options.public ?? serveConfig.public ?? defaults.public; + const resolvedPrivateKey = serveConfig.privateKey ?? generatePrivateKey(); + + // Build env vars from the mapping + const cvmEnv: Record = {}; + if (envMapping?.relays && resolvedRelays) { + cvmEnv[envMapping.relays] = Array.isArray(resolvedRelays) + ? resolvedRelays.join(',') + : resolvedRelays; + } + if (envMapping?.encryption && resolvedEncryption) { + cvmEnv[envMapping.encryption] = resolvedEncryption; + } + if (envMapping?.public) { + cvmEnv[envMapping.public] = String(resolvedPublic ?? false); + } + if (envMapping?.private_key) { + cvmEnv[envMapping.private_key] = normalizePrivateKey(resolvedPrivateKey); + } + + p.log.info(`Transport: cvm (native CVM server, no Gateway)`); + if (options.verbose) { + p.log.message(`Injected env vars: ${Object.keys(cvmEnv).join(', ')}`); + } + + // Spawn the server process directly with injected env vars + const { spawn } = await import('child_process'); + const normalized = normalizeCommandAndArgs(target, targetArgs); + const child = spawn(normalized.command, normalized.args, { + stdio: 'inherit', + env: { + ...process.env, + ...cvmEnv, + ...(serveConfig.env || {}), + }, + }); + + 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'); + + if (cleanupPath && fs.existsSync(cleanupPath)) { + p.log.message(`Cleaning up temporary bundle at ${cleanupPath}`); + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + + process.exit(0); + } else { + // ── stdio transport (default) ── + // Gateway wraps the process. Apply manifest defaults to serveConfig. + if (options.relays === undefined && !config.serve?.relays) { + serveConfig.relays = defaults.relays; + } + if (options.public === undefined && !config.serve?.public) { + serveConfig.public = defaults.public; + } + if (options.encryption === undefined && !config.serve?.encryption) { + serveConfig.encryption = defaults.encryption as EncryptionMode; + } + + p.log.info(`Transport: stdio (Gateway wraps the server)`); + } + } catch (error) { + p.log.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + } + // Auto-generate private key if not provided let privateKey = serveConfig.privateKey; if (!privateKey) { @@ -227,6 +343,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 +367,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 .mcpb 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 +427,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.mcpb ${DIM}# run an MCPB bundle over Nostr${RESET} ${DIM}$${RESET} cvmi serve --help ${DIM}# show this help${RESET} `); }