0nmcp 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * npx 0nmcp connect Interactive connection setup
13
13
  * npx 0nmcp list List connected services
14
14
  * npx 0nmcp migrate Migrate from ~/.0nmcp to ~/.0n
15
+ * npx 0nmcp engine Engine commands (import, verify, platforms, export, open)
15
16
  *
16
17
  * ═══════════════════════════════════════════════════════════════════════════
17
18
  */
@@ -78,6 +79,14 @@ ${c.bright}Usage:${c.reset}
78
79
  ${c.cyan}npx 0nmcp list${c.reset} List connected services
79
80
  ${c.cyan}npx 0nmcp migrate${c.reset} Migrate from ~/.0nmcp to ~/.0n
80
81
 
82
+ ${c.bright}Engine commands:${c.reset}
83
+
84
+ ${c.cyan}npx 0nmcp engine import <file>${c.reset} Import credentials from .env/CSV/JSON
85
+ ${c.cyan}npx 0nmcp engine verify${c.reset} Verify all connected API keys
86
+ ${c.cyan}npx 0nmcp engine platforms${c.reset} Generate AI platform configs
87
+ ${c.cyan}npx 0nmcp engine export${c.reset} Export .0n bundle (AI Brain)
88
+ ${c.cyan}npx 0nmcp engine open <bundle>${c.reset} Open/inspect a .0n bundle
89
+
81
90
  ${c.bright}Serve options:${c.reset}
82
91
 
83
92
  ${c.cyan}npx 0nmcp serve --port 3000 --host 0.0.0.0${c.reset}
@@ -208,6 +217,13 @@ ${c.bright}Links:${c.reset}
208
217
  }
209
218
  }
210
219
 
220
+ // Engine
221
+ if (command === 'engine') {
222
+ console.log(BANNER);
223
+ await handleEngine(args.slice(1));
224
+ return;
225
+ }
226
+
211
227
  // Migrate
212
228
  if (command === 'migrate') {
213
229
  console.log(BANNER);
@@ -421,4 +437,244 @@ async function interactiveConnect() {
421
437
  console.log(` Saved to: ${filePath}`);
422
438
  }
423
439
 
440
+ async function handleEngine(args) {
441
+ const sub = args[0];
442
+
443
+ if (!sub || sub === 'help') {
444
+ console.log(`${c.bright}Engine — .0n Conversion Engine${c.reset}\n`);
445
+ console.log(` ${c.cyan}import <file>${c.reset} Import credentials from .env, CSV, or JSON`);
446
+ console.log(` ${c.cyan}verify${c.reset} Verify all connected API keys`);
447
+ console.log(` ${c.cyan}platforms${c.reset} Generate AI platform configs`);
448
+ console.log(` ${c.cyan}export${c.reset} Export .0n bundle (AI Brain)`);
449
+ console.log(` ${c.cyan}open <bundle>${c.reset} Open/inspect a .0n bundle file\n`);
450
+ return;
451
+ }
452
+
453
+ if (sub === 'import') {
454
+ const source = args[1];
455
+ if (!source) {
456
+ console.log(`${c.red}Usage: npx 0nmcp engine import <file>${c.reset}`);
457
+ process.exit(1);
458
+ }
459
+ try {
460
+ const { parseFile } = await import('./engine/parser.js');
461
+ const { mapEnvVars, groupByService, validateMapping } = await import('./engine/mapper.js');
462
+ const { entries } = parseFile(source);
463
+ const { mapped, unmapped } = mapEnvVars(entries);
464
+ const groups = groupByService(mapped);
465
+
466
+ console.log(`${c.bright}Import Results:${c.reset}\n`);
467
+ console.log(` Entries found: ${entries.length}`);
468
+ console.log(` Mapped: ${mapped.length}`);
469
+ console.log(` Unmapped: ${unmapped.length}\n`);
470
+
471
+ console.log(`${c.bright}Services Detected:${c.reset}\n`);
472
+ for (const [service, group] of Object.entries(groups)) {
473
+ const validation = validateMapping(service, group.credentials);
474
+ const status = validation.valid ? `${c.green}complete${c.reset}` : `${c.yellow}missing: ${validation.missing.join(', ')}${c.reset}`;
475
+ console.log(` ${c.green}●${c.reset} ${c.bright}${service}${c.reset} — ${Object.keys(group.credentials).length} credentials (${status})`);
476
+ }
477
+
478
+ if (unmapped.length > 0) {
479
+ console.log(`\n${c.yellow}Unmapped variables:${c.reset} ${unmapped.map(u => u.key).join(', ')}`);
480
+ }
481
+
482
+ console.log(`\n${c.bright}Next:${c.reset} Run ${c.cyan}npx 0nmcp engine export${c.reset} to create a portable .0n bundle.`);
483
+ } catch (err) {
484
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
485
+ process.exit(1);
486
+ }
487
+ return;
488
+ }
489
+
490
+ if (sub === 'verify') {
491
+ try {
492
+ const { verifyAll } = await import('./engine/validator.js');
493
+ const { existsSync, readdirSync, readFileSync } = await import('fs');
494
+ const connectionsDir = path.join(DOT_ON_DIR, 'connections');
495
+
496
+ if (!existsSync(connectionsDir)) {
497
+ console.log(`${c.yellow}No connections found. Run ${c.cyan}npx 0nmcp connect${c.reset} first.`);
498
+ return;
499
+ }
500
+
501
+ const files = readdirSync(connectionsDir).filter(f => f.endsWith('.0n') || f.endsWith('.0n.json'));
502
+ const connections = {};
503
+ for (const file of files) {
504
+ try {
505
+ const data = JSON.parse(readFileSync(path.join(connectionsDir, file), 'utf8'));
506
+ if (data.$0n?.sealed) continue;
507
+ connections[data.service] = { credentials: data.auth?.credentials || {} };
508
+ } catch { /* skip */ }
509
+ }
510
+
511
+ if (Object.keys(connections).length === 0) {
512
+ console.log(`${c.yellow}No unsealed connections to verify.${c.reset}`);
513
+ return;
514
+ }
515
+
516
+ console.log(`${c.bright}Verifying ${Object.keys(connections).length} connections...${c.reset}\n`);
517
+
518
+ const { results, summary } = await verifyAll(connections);
519
+ for (const [service, result] of Object.entries(results)) {
520
+ const icon = result.valid ? `${c.green}✓` : `${c.red}✗`;
521
+ const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : '';
522
+ console.log(` ${icon}${c.reset} ${c.bright}${service}${c.reset}${latency}${result.error ? ` — ${result.error}` : ''}`);
523
+ }
524
+
525
+ console.log(`\n${c.bright}Summary:${c.reset} ${summary.valid}/${summary.total} valid`);
526
+ } catch (err) {
527
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
528
+ process.exit(1);
529
+ }
530
+ return;
531
+ }
532
+
533
+ if (sub === 'platforms') {
534
+ try {
535
+ const { getPlatformInfo, generatePlatformConfig } = await import('./engine/platforms.js');
536
+ const platform = args[1];
537
+
538
+ if (platform) {
539
+ const config = generatePlatformConfig(platform);
540
+ console.log(`${c.bright}${config.name} Configuration:${c.reset}\n`);
541
+ console.log(` Config path: ${config.path || '(HTTP only)'}`);
542
+ console.log(` Format: ${config.format}\n`);
543
+ console.log(typeof config.config === 'string' ? config.config : JSON.stringify(config.config, null, 2));
544
+ } else {
545
+ const info = getPlatformInfo();
546
+ console.log(`${c.bright}Supported AI Platforms:${c.reset}\n`);
547
+ for (const p of info) {
548
+ const status = p.installed ? `${c.green}installed${c.reset}` : `${c.yellow}not installed${c.reset}`;
549
+ console.log(` ${p.installed ? c.green + '●' : c.blue + '○'}${c.reset} ${c.bright}${p.name}${c.reset} (${status})`);
550
+ if (p.configPath) console.log(` ${p.configPath}`);
551
+ }
552
+ console.log(`\n${c.bright}Tip:${c.reset} Run ${c.cyan}npx 0nmcp engine platforms <name>${c.reset} to see config for a specific platform.`);
553
+ console.log(` Names: claude_desktop, cursor, windsurf, gemini, continue, cline, openai`);
554
+ }
555
+ } catch (err) {
556
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
557
+ process.exit(1);
558
+ }
559
+ return;
560
+ }
561
+
562
+ if (sub === 'export') {
563
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
564
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
565
+
566
+ try {
567
+ const passphrase = await ask('Bundle passphrase: ');
568
+ if (!passphrase) {
569
+ console.log(`${c.red}Passphrase required.${c.reset}`);
570
+ rl.close();
571
+ process.exit(1);
572
+ }
573
+ const name = await ask(`Bundle name (default: 0n AI Brain): `) || '0n AI Brain';
574
+ rl.close();
575
+
576
+ const { createBundle } = await import('./engine/bundler.js');
577
+ const { existsSync, readdirSync, readFileSync } = await import('fs');
578
+ const connectionsDir = path.join(DOT_ON_DIR, 'connections');
579
+
580
+ if (!existsSync(connectionsDir)) {
581
+ console.log(`${c.yellow}No connections found.${c.reset}`);
582
+ return;
583
+ }
584
+
585
+ const files = readdirSync(connectionsDir).filter(f => f.endsWith('.0n') || f.endsWith('.0n.json'));
586
+ const connections = {};
587
+ for (const file of files) {
588
+ try {
589
+ const data = JSON.parse(readFileSync(path.join(connectionsDir, file), 'utf8'));
590
+ if (data.$0n?.sealed) continue;
591
+ connections[data.service] = {
592
+ credentials: data.auth?.credentials || {},
593
+ name: data.$0n?.name || data.service,
594
+ authType: data.auth?.type || 'api_key',
595
+ environment: data.environment || 'production',
596
+ };
597
+ } catch { /* skip */ }
598
+ }
599
+
600
+ if (Object.keys(connections).length === 0) {
601
+ console.log(`${c.yellow}No exportable connections found.${c.reset}`);
602
+ return;
603
+ }
604
+
605
+ console.log(`\n${c.bright}Creating AI Brain bundle...${c.reset}\n`);
606
+
607
+ const result = createBundle({ connections, passphrase, name, platforms: 'all' });
608
+
609
+ console.log(`${c.green}${c.bright}Bundle created!${c.reset}\n`);
610
+ console.log(` Path: ${result.path}`);
611
+ console.log(` Services: ${result.manifest.services.join(', ')}`);
612
+ console.log(` Connections: ${result.manifest.connection_count}`);
613
+ console.log(` Platforms: ${result.manifest.platform_count}`);
614
+ console.log(` Encryption: ${result.manifest.encryption.method}`);
615
+ console.log(`\n${c.bright}Share this file — recipient opens with:${c.reset} ${c.cyan}npx 0nmcp engine open <bundle>${c.reset}`);
616
+ } catch (err) {
617
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
618
+ process.exit(1);
619
+ }
620
+ return;
621
+ }
622
+
623
+ if (sub === 'open') {
624
+ const bundlePath = args[1];
625
+ if (!bundlePath) {
626
+ console.log(`${c.red}Usage: npx 0nmcp engine open <bundle.0n>${c.reset}`);
627
+ process.exit(1);
628
+ }
629
+
630
+ try {
631
+ const { inspectBundle, openBundle } = await import('./engine/bundler.js');
632
+
633
+ // First inspect
634
+ const info = inspectBundle(bundlePath);
635
+ console.log(`${c.bright}Bundle: ${info.name}${c.reset}\n`);
636
+ console.log(` Created: ${info.created}`);
637
+ console.log(` Services: ${info.services.map(s => s.service).join(', ')}`);
638
+ console.log(` Platforms: ${info.platforms.join(', ')}`);
639
+ if (info.includes.length > 0) {
640
+ console.log(` Includes: ${info.includes.map(i => i.name).join(', ')}`);
641
+ }
642
+
643
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
644
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
645
+
646
+ const passphrase = await ask('\nPassphrase to decrypt (leave empty to inspect only): ');
647
+ rl.close();
648
+
649
+ if (!passphrase) {
650
+ console.log(`\n${c.yellow}Inspect only — no credentials extracted.${c.reset}`);
651
+ return;
652
+ }
653
+
654
+ const result = openBundle(bundlePath, passphrase);
655
+
656
+ console.log(`\n${c.green}${c.bright}Bundle opened!${c.reset}\n`);
657
+ console.log(` Connections imported: ${result.connections.join(', ')}`);
658
+ if (result.includes.length > 0) {
659
+ console.log(` Files extracted: ${result.includes.join(', ')}`);
660
+ }
661
+ if (result.errors.length > 0) {
662
+ console.log(`\n${c.red}Errors:${c.reset}`);
663
+ for (const err of result.errors) {
664
+ console.log(` ${c.red}●${c.reset} ${err}`);
665
+ }
666
+ }
667
+ console.log(`\n${c.bright}Tip:${c.reset} Run ${c.cyan}npx 0nmcp engine platforms${c.reset} to install AI platform configs.`);
668
+ } catch (err) {
669
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
670
+ process.exit(1);
671
+ }
672
+ return;
673
+ }
674
+
675
+ console.log(`${c.red}Unknown engine command: ${sub}${c.reset}`);
676
+ console.log(`Run ${c.cyan}npx 0nmcp engine help${c.reset} for usage`);
677
+ process.exit(1);
678
+ }
679
+
424
680
  main().catch(console.error);
package/connections.js CHANGED
@@ -244,8 +244,12 @@ export class ConnectionManager {
244
244
 
245
245
  /**
246
246
  * Get credentials for a service.
247
+ * Checks vault unsealed cache first for sealed connections.
247
248
  */
248
249
  getCredentials(serviceKey) {
250
+ // Check if vault has unsealed credentials in memory
251
+ const unsealed = this._vaultCache?.get(serviceKey);
252
+ if (unsealed) return unsealed;
249
253
  return this.connections[serviceKey]?.credentials || null;
250
254
  }
251
255
 
@@ -0,0 +1,395 @@
1
+ // ============================================================
2
+ // 0nMCP — Engine: Bundle Creator/Opener
3
+ // ============================================================
4
+ // Creates and opens portable .0n bundle files — the "Alpha
5
+ // AI Brain" format. Bundles contain encrypted connections,
6
+ // platform configs, and optional include files.
7
+ //
8
+ // Portable encryption: passphrase-only (no machine binding).
9
+ // After import, optionally re-seal with vault for machine lock.
10
+ //
11
+ // Patent Pending: US Provisional Patent Application #63/968,814
12
+ // ============================================================
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
15
+ import { join, basename } from "path";
16
+ import { createHash } from "crypto";
17
+ import { homedir } from "os";
18
+ import { sealPortable, unsealPortable } from "./cipher-portable.js";
19
+ import { generateAllPlatformConfigs } from "./platforms.js";
20
+ import { CONNECTIONS_PATH } from "../connections.js";
21
+
22
+ const BUNDLES_DIR = join(homedir(), ".0n", "bundles");
23
+
24
+ /**
25
+ * Compute SHA-256 checksum of a string.
26
+ */
27
+ function sha256(data) {
28
+ return createHash("sha256").update(data).digest("hex");
29
+ }
30
+
31
+ /**
32
+ * Create a .0n bundle file.
33
+ *
34
+ * @param {object} options
35
+ * @param {Record<string, { credentials: Record<string, string>, authType?: string }>} options.connections
36
+ * @param {string} options.passphrase
37
+ * @param {string} [options.outputPath]
38
+ * @param {string} [options.name]
39
+ * @param {string} [options.description]
40
+ * @param {string[]|"all"} [options.platforms] - Platform keys or "all"
41
+ * @param {Array<{ path: string, type?: string, name?: string }>} [options.includes]
42
+ * @param {boolean} [options.seal] - Default true
43
+ * @returns {{ bundle: object, path: string, manifest: object }}
44
+ */
45
+ export function createBundle(options) {
46
+ const {
47
+ connections,
48
+ passphrase,
49
+ outputPath,
50
+ name = "0n Bundle",
51
+ description = "",
52
+ platforms = "all",
53
+ includes = [],
54
+ seal = true,
55
+ } = options;
56
+
57
+ const now = new Date().toISOString();
58
+
59
+ // Build connections array
60
+ const bundleConnections = [];
61
+ for (const [service, conn] of Object.entries(connections)) {
62
+ const credJson = JSON.stringify(conn.credentials);
63
+
64
+ if (seal) {
65
+ const { sealed } = sealPortable(credJson, passphrase);
66
+ bundleConnections.push({
67
+ service,
68
+ name: conn.name || service,
69
+ environment: conn.environment || "production",
70
+ auth_type: conn.authType || "api_key",
71
+ credential_keys: Object.keys(conn.credentials),
72
+ sealed: true,
73
+ vault: {
74
+ data: sealed,
75
+ algorithm: "aes-256-gcm",
76
+ kdf: "pbkdf2-sha512-100k",
77
+ portable: true,
78
+ },
79
+ });
80
+ } else {
81
+ bundleConnections.push({
82
+ service,
83
+ name: conn.name || service,
84
+ environment: conn.environment || "production",
85
+ auth_type: conn.authType || "api_key",
86
+ credential_keys: Object.keys(conn.credentials),
87
+ sealed: false,
88
+ credentials: conn.credentials,
89
+ });
90
+ }
91
+ }
92
+
93
+ // Generate platform configs
94
+ let platformConfigs = {};
95
+ if (platforms === "all" || (Array.isArray(platforms) && platforms.length > 0)) {
96
+ const allConfigs = generateAllPlatformConfigs({});
97
+ if (platforms === "all") {
98
+ platformConfigs = {};
99
+ for (const [key, cfg] of Object.entries(allConfigs)) {
100
+ platformConfigs[key] = {
101
+ format: cfg.format,
102
+ config_path: cfg.path,
103
+ config: cfg.config,
104
+ };
105
+ }
106
+ } else {
107
+ for (const key of platforms) {
108
+ if (allConfigs[key]) {
109
+ platformConfigs[key] = {
110
+ format: allConfigs[key].format,
111
+ config_path: allConfigs[key].path,
112
+ config: allConfigs[key].config,
113
+ };
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ // Process include files
120
+ const bundleIncludes = [];
121
+ for (const inc of includes) {
122
+ if (!existsSync(inc.path)) continue;
123
+ const content = readFileSync(inc.path);
124
+ const data = content.toString("base64");
125
+ bundleIncludes.push({
126
+ name: inc.name || basename(inc.path),
127
+ type: inc.type || detectIncludeType(inc.path),
128
+ target: inc.target || detectTarget(inc.path),
129
+ data,
130
+ checksum: `sha256:${sha256(content.toString())}`,
131
+ size: content.length,
132
+ });
133
+ }
134
+
135
+ // Build manifest
136
+ const connectionsStr = JSON.stringify(bundleConnections);
137
+ const platformsStr = JSON.stringify(platformConfigs);
138
+ const includesStr = JSON.stringify(bundleIncludes);
139
+
140
+ const manifest = {
141
+ bundle_version: "1.0.0",
142
+ generator: "0nmcp-engine/1.6.0",
143
+ connection_count: bundleConnections.length,
144
+ platform_count: Object.keys(platformConfigs).length,
145
+ include_count: bundleIncludes.length,
146
+ services: bundleConnections.map(c => c.service),
147
+ encryption: seal
148
+ ? { method: "portable", algorithm: "aes-256-gcm", kdf: "pbkdf2-sha512-100k" }
149
+ : { method: "none" },
150
+ checksums: {
151
+ connections: `sha256:${sha256(connectionsStr)}`,
152
+ platforms: `sha256:${sha256(platformsStr)}`,
153
+ includes: `sha256:${sha256(includesStr)}`,
154
+ },
155
+ };
156
+
157
+ // Assemble bundle
158
+ const bundle = {
159
+ $0n: {
160
+ type: "bundle",
161
+ version: "1.0.0",
162
+ created: now,
163
+ updated: now,
164
+ name,
165
+ description,
166
+ },
167
+ connections: bundleConnections,
168
+ platforms: platformConfigs,
169
+ includes: bundleIncludes,
170
+ manifest,
171
+ };
172
+
173
+ // Write to file
174
+ if (!existsSync(BUNDLES_DIR)) mkdirSync(BUNDLES_DIR, { recursive: true });
175
+ const ts = now.replace(/[:.]/g, "-").slice(0, 19);
176
+ const outPath = outputPath || join(BUNDLES_DIR, `bundle-${ts}.0n`);
177
+ const outDir = join(outPath, "..");
178
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
179
+
180
+ writeFileSync(outPath, JSON.stringify(bundle, null, 2));
181
+
182
+ return { bundle, path: outPath, manifest };
183
+ }
184
+
185
+ /**
186
+ * Open a .0n bundle and extract connections.
187
+ *
188
+ * @param {string} bundlePath
189
+ * @param {string} passphrase
190
+ * @param {object} [options]
191
+ * @param {boolean} [options.installConnections] - Write to ~/.0n/connections/ (default true)
192
+ * @param {boolean} [options.dryRun] - Preview without writing (default false)
193
+ * @returns {{ connections: string[], platforms: string[], includes: string[], errors: string[] }}
194
+ */
195
+ export function openBundle(bundlePath, passphrase, options = {}) {
196
+ const { installConnections = true, dryRun = false } = options;
197
+
198
+ const raw = readFileSync(bundlePath, "utf-8");
199
+ const bundle = JSON.parse(raw);
200
+
201
+ if (!bundle.$0n || bundle.$0n.type !== "bundle") {
202
+ throw new Error("Not a valid .0n bundle file.");
203
+ }
204
+
205
+ const results = { connections: [], platforms: [], includes: [], errors: [] };
206
+
207
+ // Extract connections
208
+ for (const conn of bundle.connections || []) {
209
+ try {
210
+ let credentials;
211
+
212
+ if (conn.sealed && conn.vault?.data) {
213
+ const decrypted = unsealPortable(conn.vault.data, passphrase);
214
+ credentials = JSON.parse(decrypted);
215
+ } else if (conn.credentials) {
216
+ credentials = conn.credentials;
217
+ } else {
218
+ results.errors.push(`${conn.service}: No credentials found`);
219
+ continue;
220
+ }
221
+
222
+ if (installConnections && !dryRun) {
223
+ // Write as standard .0n connection file
224
+ const connFile = {
225
+ $0n: {
226
+ type: "connection",
227
+ version: "1.0.0",
228
+ created: new Date().toISOString(),
229
+ name: conn.name || conn.service,
230
+ },
231
+ service: conn.service,
232
+ environment: conn.environment || "production",
233
+ auth: {
234
+ type: conn.auth_type || "api_key",
235
+ credentials,
236
+ },
237
+ metadata: {
238
+ imported_from: basename(bundlePath),
239
+ imported_at: new Date().toISOString(),
240
+ },
241
+ };
242
+
243
+ if (!existsSync(CONNECTIONS_PATH)) mkdirSync(CONNECTIONS_PATH, { recursive: true });
244
+ const filePath = join(CONNECTIONS_PATH, `${conn.service}.0n`);
245
+ writeFileSync(filePath, JSON.stringify(connFile, null, 2));
246
+ }
247
+
248
+ results.connections.push(conn.service);
249
+ } catch (err) {
250
+ results.errors.push(`${conn.service}: ${err.message}`);
251
+ }
252
+ }
253
+
254
+ // Extract includes
255
+ for (const inc of bundle.includes || []) {
256
+ try {
257
+ if (dryRun) {
258
+ results.includes.push(inc.name);
259
+ continue;
260
+ }
261
+
262
+ const target = inc.target
263
+ ? inc.target.replace("~", homedir())
264
+ : join(homedir(), ".0n", inc.type === "workflow" ? "workflows" : "plugins", inc.name);
265
+
266
+ const dir = join(target, "..");
267
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
268
+
269
+ const data = Buffer.from(inc.data, "base64");
270
+
271
+ // Verify checksum
272
+ if (inc.checksum) {
273
+ const expected = inc.checksum.replace("sha256:", "");
274
+ const actual = sha256(data.toString());
275
+ if (actual !== expected) {
276
+ results.errors.push(`${inc.name}: Checksum mismatch`);
277
+ continue;
278
+ }
279
+ }
280
+
281
+ writeFileSync(target, data);
282
+ results.includes.push(inc.name);
283
+ } catch (err) {
284
+ results.errors.push(`${inc.name}: ${err.message}`);
285
+ }
286
+ }
287
+
288
+ // List platforms
289
+ for (const key of Object.keys(bundle.platforms || {})) {
290
+ results.platforms.push(key);
291
+ }
292
+
293
+ return results;
294
+ }
295
+
296
+ /**
297
+ * Inspect a bundle without passphrase — shows metadata only, no credentials.
298
+ * @param {string} bundlePath
299
+ * @returns {object} Bundle metadata and service list
300
+ */
301
+ export function inspectBundle(bundlePath) {
302
+ const raw = readFileSync(bundlePath, "utf-8");
303
+ const bundle = JSON.parse(raw);
304
+
305
+ if (!bundle.$0n || bundle.$0n.type !== "bundle") {
306
+ throw new Error("Not a valid .0n bundle file.");
307
+ }
308
+
309
+ return {
310
+ name: bundle.$0n.name,
311
+ description: bundle.$0n.description,
312
+ created: bundle.$0n.created,
313
+ version: bundle.$0n.version,
314
+ services: (bundle.connections || []).map(c => ({
315
+ service: c.service,
316
+ name: c.name,
317
+ sealed: c.sealed,
318
+ credential_keys: c.credential_keys,
319
+ })),
320
+ platforms: Object.keys(bundle.platforms || {}),
321
+ includes: (bundle.includes || []).map(i => ({
322
+ name: i.name,
323
+ type: i.type,
324
+ size: i.size,
325
+ })),
326
+ manifest: bundle.manifest,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Verify bundle integrity: checksums + passphrase test.
332
+ * @param {string} bundlePath
333
+ * @param {string} passphrase
334
+ * @returns {{ valid: boolean, errors: string[] }}
335
+ */
336
+ export function verifyBundle(bundlePath, passphrase) {
337
+ const raw = readFileSync(bundlePath, "utf-8");
338
+ const bundle = JSON.parse(raw);
339
+ const errors = [];
340
+
341
+ if (!bundle.$0n || bundle.$0n.type !== "bundle") {
342
+ return { valid: false, errors: ["Not a valid .0n bundle file."] };
343
+ }
344
+
345
+ // Verify manifest checksums
346
+ const checksums = bundle.manifest?.checksums || {};
347
+ if (checksums.connections) {
348
+ const actual = sha256(JSON.stringify(bundle.connections));
349
+ const expected = checksums.connections.replace("sha256:", "");
350
+ if (actual !== expected) errors.push("Connections checksum mismatch");
351
+ }
352
+ if (checksums.platforms) {
353
+ const actual = sha256(JSON.stringify(bundle.platforms));
354
+ const expected = checksums.platforms.replace("sha256:", "");
355
+ if (actual !== expected) errors.push("Platforms checksum mismatch");
356
+ }
357
+
358
+ // Try to unseal each connection
359
+ for (const conn of bundle.connections || []) {
360
+ if (conn.sealed && conn.vault?.data) {
361
+ try {
362
+ unsealPortable(conn.vault.data, passphrase);
363
+ } catch {
364
+ errors.push(`${conn.service}: Failed to unseal — wrong passphrase`);
365
+ }
366
+ }
367
+ }
368
+
369
+ return { valid: errors.length === 0, errors };
370
+ }
371
+
372
+ /**
373
+ * Detect include file type from path.
374
+ */
375
+ function detectIncludeType(filePath) {
376
+ if (filePath.endsWith(".0n")) return "workflow";
377
+ if (filePath.endsWith(".js") || filePath.endsWith(".mjs")) return "skill";
378
+ if (filePath.endsWith(".json")) return "config";
379
+ if (filePath.endsWith(".md")) return "doc";
380
+ return "file";
381
+ }
382
+
383
+ /**
384
+ * Detect target directory for include files.
385
+ */
386
+ function detectTarget(filePath) {
387
+ const type = detectIncludeType(filePath);
388
+ const name = basename(filePath);
389
+ switch (type) {
390
+ case "workflow": return `~/.0n/workflows/${name}`;
391
+ case "skill": return `~/.0n/plugins/${name}`;
392
+ case "config": return `~/.0n/${name}`;
393
+ default: return `~/.0n/includes/${name}`;
394
+ }
395
+ }