@highway1/cli 0.1.43 → 0.1.45

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.
@@ -0,0 +1,390 @@
1
+ import { Command } from 'commander';
2
+ import {
3
+ createNode,
4
+ importKeyPair,
5
+ createAgentCard,
6
+ signAgentCard,
7
+ createDHTOperations,
8
+ createMessageRouter,
9
+ sign,
10
+ verify,
11
+ extractPublicKey,
12
+ } from '@highway1/core';
13
+ import { getIdentity, getAgentCard, getBootstrapPeers } from '../config.js';
14
+ import { success, error, spinner, printHeader, info } from '../ui.js';
15
+ import { writeFile, mkdir } from 'node:fs/promises';
16
+ import { join, resolve } from 'node:path';
17
+
18
+ export function registerJoinCommand(program: Command): void {
19
+ program
20
+ .command('join')
21
+ .description('Join the Clawiverse network')
22
+ .option('--bootstrap <peers...>', 'Bootstrap peer addresses')
23
+ .option('--relay', 'Run as a relay server and advertise relay capability')
24
+ .option('--save-dir <path>', 'Directory to save received file attachments', './downloads')
25
+ .action(async (options) => {
26
+ try {
27
+ printHeader('Join Clawiverse Network');
28
+
29
+ const identity = getIdentity();
30
+ const card = getAgentCard();
31
+
32
+ if (!identity || !card) {
33
+ error('No identity found. Run "hw1 init" first.');
34
+ process.exit(1);
35
+ }
36
+
37
+ const spin = spinner('Starting libp2p node...');
38
+
39
+ const keyPair = importKeyPair({
40
+ publicKey: identity.publicKey,
41
+ privateKey: identity.privateKey,
42
+ });
43
+
44
+ const bootstrapPeers = options.bootstrap || getBootstrapPeers();
45
+ const bootstrapPeerIds = bootstrapPeers
46
+ .map((addr: string) => addr.split('/p2p/')[1])
47
+ .filter((peerId: string | undefined): peerId is string => Boolean(peerId));
48
+
49
+ const node = await createNode({
50
+ keyPair,
51
+ bootstrapPeers,
52
+ enableDHT: true,
53
+ reserveRelaySlot: true,
54
+ enableRelay: options.relay ?? false,
55
+ });
56
+
57
+ await node.start();
58
+
59
+ spin.succeed('Node started successfully!');
60
+
61
+ info(`Peer ID: ${node.getPeerId()}`);
62
+ info(`DID: ${identity.did}`);
63
+ info(`Listening on: ${node.getMultiaddrs().join(', ')}`);
64
+
65
+ // Wait for bootstrap peer connection before publishing to DHT
66
+ const connectSpin = spinner('Connecting to bootstrap peers...');
67
+
68
+ // Phase 1: wait for peer:connect (up to 10s)
69
+ let connected = false;
70
+ await new Promise<void>((resolve) => {
71
+ const timeout = setTimeout(resolve, 10000);
72
+ node.libp2p.addEventListener('peer:connect', () => {
73
+ connected = true;
74
+ clearTimeout(timeout);
75
+ resolve();
76
+ }, { once: true });
77
+ });
78
+
79
+ // If discovery has not connected us yet, proactively dial bootstrap peers once.
80
+ if (!connected && bootstrapPeers.length > 0) {
81
+ info('No peer discovered yet, dialing bootstrap peers...');
82
+ for (const bootstrapAddr of bootstrapPeers) {
83
+ try {
84
+ await node.libp2p.dial(bootstrapAddr);
85
+ connected = true;
86
+ break;
87
+ } catch {
88
+ // try next bootstrap peer
89
+ }
90
+ }
91
+ }
92
+
93
+ // Phase 2: wait for relay reservation using event + polling (more reliable across libp2p versions)
94
+ const countRelayAddrs = () => node.getMultiaddrs().filter(a => a.includes('/p2p-circuit')).length;
95
+ const initialRelayCount = countRelayAddrs();
96
+
97
+ info(`Initial relay addresses: ${initialRelayCount}`);
98
+ info('Waiting for relay reservation...');
99
+
100
+ let reservationSucceeded = false;
101
+ if (initialRelayCount === 0) {
102
+ await new Promise<void>((resolve) => {
103
+ const RELAY_WAIT_MS = 30000;
104
+ const POLL_MS = 500;
105
+ let settled = false;
106
+
107
+ const finish = () => {
108
+ if (settled) return;
109
+ settled = true;
110
+ clearTimeout(timeout);
111
+ clearInterval(pollTimer);
112
+ (node.libp2p as any).removeEventListener('relay:reservation', onReservation);
113
+ (node.libp2p as any).removeEventListener('self:peer:update', onPeerUpdate);
114
+ resolve();
115
+ };
116
+
117
+ const onReservation = () => {
118
+ reservationSucceeded = true;
119
+ info('✓ Relay reservation successful!');
120
+ setTimeout(finish, 300);
121
+ };
122
+
123
+ const onPeerUpdate = () => {
124
+ if (countRelayAddrs() > 0) {
125
+ reservationSucceeded = true;
126
+ info('✓ Relay address detected from peer update');
127
+ finish();
128
+ }
129
+ };
130
+
131
+ const pollTimer = setInterval(() => {
132
+ if (countRelayAddrs() > 0) {
133
+ reservationSucceeded = true;
134
+ info('✓ Relay address detected');
135
+ finish();
136
+ }
137
+ }, POLL_MS);
138
+
139
+ const timeout = setTimeout(() => {
140
+ if (!reservationSucceeded) {
141
+ info(`Relay reservation timeout after ${RELAY_WAIT_MS / 1000}s.`);
142
+ info(`Connected peers: ${node.libp2p.getPeers().map(p => p.toString()).join(', ')}`);
143
+ }
144
+ finish();
145
+ }, RELAY_WAIT_MS);
146
+
147
+ // Event names vary by libp2p internals; keep as any to avoid typing mismatch.
148
+ (node.libp2p as any).addEventListener('relay:reservation', onReservation);
149
+ (node.libp2p as any).addEventListener('self:peer:update', onPeerUpdate);
150
+ });
151
+ } else {
152
+ reservationSucceeded = true;
153
+ info('✓ Relay address already present');
154
+ }
155
+
156
+ if (!reservationSucceeded) {
157
+ info('⚠ Relay reservation did not complete, continuing with fallback relay addresses');
158
+ }
159
+
160
+ connectSpin.succeed('Connected to network!');
161
+
162
+ // Publish Agent Card with peerId and multiaddrs so others can dial us
163
+ const cardSpin = spinner('Publishing Agent Card to DHT...');
164
+
165
+ // Use relay addresses from libp2p (populated after reservation), fall back to manual construction
166
+ const directAddrs = node.getMultiaddrs().filter(a => !a.includes('/p2p-circuit'));
167
+ const relayAddrs = node.getMultiaddrs().filter(a => a.includes('/p2p-circuit'));
168
+ const fallbackRelayAddrs = relayAddrs.length === 0
169
+ ? bootstrapPeers.map((r: string) => `${r}/p2p-circuit/p2p/${node.getPeerId()}`)
170
+ : [];
171
+ const allAddrs = [...directAddrs, ...relayAddrs, ...fallbackRelayAddrs];
172
+
173
+ const capabilities = (card.capabilities ?? []).map((capability: string) => ({
174
+ id: capability,
175
+ name: capability,
176
+ description: `Capability: ${capability}`,
177
+ }));
178
+ if (options.relay) {
179
+ capabilities.push({
180
+ id: 'relay',
181
+ name: 'relay',
182
+ description: 'Provides circuit relay service for NAT traversal',
183
+ });
184
+ }
185
+
186
+ const agentCard = createAgentCard(
187
+ identity.did,
188
+ card.name,
189
+ card.description,
190
+ capabilities,
191
+ allAddrs,
192
+ node.getPeerId()
193
+ );
194
+
195
+ const signedCard = await signAgentCard(agentCard, (data) =>
196
+ sign(data, keyPair.privateKey)
197
+ );
198
+
199
+ const dht = createDHTOperations(node.libp2p);
200
+ await dht.publishAgentCard(signedCard);
201
+
202
+ cardSpin.succeed('Agent Card published!');
203
+
204
+ // Keep bootstrap connectivity stable: proactively re-dial missing bootstrap peers.
205
+ const ensureBootstrapConnections = async () => {
206
+ const connections = node.libp2p.getConnections();
207
+ const connectedPeerIds = new Set(connections.map((conn) => conn.remotePeer.toString()));
208
+
209
+ for (const bootstrapAddr of bootstrapPeers) {
210
+ const targetPeerId = bootstrapAddr.split('/p2p/')[1];
211
+ if (!targetPeerId || connectedPeerIds.has(targetPeerId)) continue;
212
+
213
+ try {
214
+ await node.libp2p.dial(bootstrapAddr);
215
+ info(`Reconnected bootstrap peer: ${targetPeerId}`);
216
+ } catch {
217
+ // best effort; keep trying on next tick
218
+ }
219
+ }
220
+ };
221
+
222
+ // Keep connection alive by pinging peers periodically
223
+ const pingInterval = setInterval(async () => {
224
+ const peers = node.libp2p.getPeers();
225
+ if (peers.length === 0) {
226
+ // No peers connected, try to reconnect to bootstrap
227
+ for (const bootstrapAddr of bootstrapPeers) {
228
+ try {
229
+ await node.libp2p.dial(bootstrapAddr);
230
+ info('Reconnected to bootstrap peer');
231
+ break;
232
+ } catch {
233
+ // try next bootstrap peer
234
+ }
235
+ }
236
+ } else {
237
+ await ensureBootstrapConnections();
238
+ // Ping existing peers to keep connection alive
239
+ for (const peer of peers) {
240
+ try {
241
+ await (node.libp2p.services.ping as any).ping(peer);
242
+ } catch {
243
+ // ignore ping failures
244
+ }
245
+ }
246
+ }
247
+ }, 15000); // ping every 15s (reduced from 30s for better stability)
248
+
249
+ // If a bootstrap peer disconnects, attempt immediate recovery.
250
+ const onPeerDisconnect = async (evt: any) => {
251
+ const disconnectedPeerId = evt?.detail?.toString?.() ?? '';
252
+ if (!bootstrapPeerIds.includes(disconnectedPeerId)) return;
253
+
254
+ info(`Bootstrap peer disconnected: ${disconnectedPeerId}, attempting reconnect...`);
255
+ for (const bootstrapAddr of bootstrapPeers) {
256
+ if (!bootstrapAddr.endsWith(`/p2p/${disconnectedPeerId}`)) continue;
257
+ try {
258
+ await node.libp2p.dial(bootstrapAddr);
259
+ info(`Recovered bootstrap connection: ${disconnectedPeerId}`);
260
+ return;
261
+ } catch {
262
+ // Continue trying other bootstrap peers if available
263
+ }
264
+ }
265
+ };
266
+ node.libp2p.addEventListener('peer:disconnect', onPeerDisconnect);
267
+
268
+ const verifyFn = async (signature: Uint8Array, data: Uint8Array): Promise<boolean> => {
269
+ try {
270
+ const decoded = JSON.parse(new TextDecoder().decode(data)) as { from?: string };
271
+ if (!decoded.from || typeof decoded.from !== 'string') return false;
272
+ const senderPublicKey = extractPublicKey(decoded.from);
273
+ return verify(signature, data, senderPublicKey);
274
+ } catch {
275
+ return false;
276
+ }
277
+ };
278
+
279
+ // Register message handlers for incoming messages
280
+ const router = createMessageRouter(
281
+ node.libp2p,
282
+ verifyFn,
283
+ dht
284
+ );
285
+
286
+ const saveDir = resolve(options.saveDir ?? './downloads');
287
+
288
+ // Generic message handler that accepts any protocol
289
+ const messageHandler = async (envelope: any) => {
290
+ const payload = envelope.payload as Record<string, unknown>;
291
+ console.log();
292
+ success(`>>> Received message from ${envelope.from}`);
293
+ info(` Message ID: ${envelope.id}`);
294
+ info(` Protocol: ${envelope.protocol}`);
295
+ info(` Type: ${envelope.type}`);
296
+
297
+ // Handle file attachment
298
+ let savedPath: string | undefined;
299
+ if (payload.attachment) {
300
+ const att = payload.attachment as {
301
+ filename: string;
302
+ mimeType: string;
303
+ size: number;
304
+ data: string;
305
+ };
306
+ try {
307
+ await mkdir(saveDir, { recursive: true });
308
+ // Avoid filename collisions by prefixing with message id fragment
309
+ const safeName = att.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
310
+ savedPath = join(saveDir, `${envelope.id.slice(-8)}_${safeName}`);
311
+ await writeFile(savedPath, Buffer.from(att.data, 'base64'));
312
+ success(` Attachment saved: ${savedPath}`);
313
+ info(` File: ${att.filename} (${att.size} bytes, ${att.mimeType})`);
314
+ } catch (e) {
315
+ error(` Failed to save attachment: ${(e as Error).message}`);
316
+ }
317
+ const displayPayload = { ...payload, attachment: '[binary data omitted]' };
318
+ info(` Payload: ${JSON.stringify(displayPayload, null, 2)}`);
319
+ } else {
320
+ info(` Payload: ${JSON.stringify(payload, null, 2)}`);
321
+ }
322
+ console.log();
323
+
324
+ // If this is a request, send back a simple acknowledgment response
325
+ if (envelope.type === 'request') {
326
+ info(' Sending acknowledgment response...');
327
+
328
+ const { createEnvelope, signEnvelope, sign } = await import('@highway1/core');
329
+ const identity = getIdentity();
330
+ const keyPair = importKeyPair({
331
+ publicKey: identity!.publicKey,
332
+ privateKey: identity!.privateKey,
333
+ });
334
+
335
+ const responseEnvelope = createEnvelope(
336
+ envelope.to,
337
+ envelope.from,
338
+ 'response',
339
+ envelope.protocol,
340
+ {
341
+ status: 'received',
342
+ message: 'Message received and processed',
343
+ savedPath: savedPath ?? null,
344
+ timestamp: Date.now(),
345
+ },
346
+ envelope.id
347
+ );
348
+
349
+ const signedResponse = await signEnvelope(responseEnvelope, (data) =>
350
+ sign(data, keyPair.privateKey)
351
+ );
352
+
353
+ return signedResponse;
354
+ }
355
+
356
+ return undefined;
357
+ };
358
+
359
+ // Register handlers for common protocols
360
+ router.registerHandler('/clawiverse/msg/1.0.0', messageHandler);
361
+ router.registerHandler('/clawiverse/greet/1.0.0', messageHandler);
362
+
363
+ // Register catch-all handler for any other protocol
364
+ router.registerCatchAllHandler(messageHandler);
365
+
366
+ await router.start();
367
+
368
+ console.log();
369
+ success('Successfully joined the Clawiverse network!');
370
+ info('Listening for incoming messages...');
371
+ info('Press Ctrl+C to stop');
372
+
373
+ process.on('SIGINT', async () => {
374
+ console.log();
375
+ const stopSpin = spinner('Stopping node...');
376
+ clearInterval(pingInterval);
377
+ node.libp2p.removeEventListener('peer:disconnect', onPeerDisconnect);
378
+ await router.stop();
379
+ await node.stop();
380
+ stopSpin.succeed('Node stopped');
381
+ process.exit(0);
382
+ });
383
+
384
+ await new Promise(() => {});
385
+ } catch (err) {
386
+ error(`Failed to join network: ${(err as Error).message}`);
387
+ process.exit(1);
388
+ }
389
+ });
390
+ }
@@ -0,0 +1,299 @@
1
+ import { Command } from 'commander';
2
+ import {
3
+ createNode,
4
+ importKeyPair,
5
+ createEnvelope,
6
+ signEnvelope,
7
+ createMessageRouter,
8
+ createDHTOperations,
9
+ sign,
10
+ verify,
11
+ extractPublicKey,
12
+ } from '@highway1/core';
13
+ import { getIdentity, getBootstrapPeers } from '../config.js';
14
+ import { success, error, spinner, printHeader, info } from '../ui.js';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { basename } from 'node:path';
17
+ import { DaemonClient } from '../daemon/client.js';
18
+
19
+ export function registerSendCommand(program: Command): void {
20
+ program
21
+ .command('send')
22
+ .description('Send a message to another agent')
23
+ .requiredOption('--to <did>', 'Recipient DID')
24
+ .requiredOption('--protocol <protocol>', 'Protocol identifier')
25
+ .option('--payload <json>', 'Message payload (JSON)')
26
+ .option('--file <path>', 'Attach a file (image, binary, text) as payload attachment')
27
+ .option('--type <type>', 'Message type (request|notification)', 'request')
28
+ .option('--bootstrap <peers...>', 'Bootstrap peer addresses')
29
+ .option('--peer <multiaddr>', 'Direct peer multiaddr (bypasses DHT lookup)')
30
+ .action(async (options) => {
31
+ try {
32
+ printHeader('Send Message');
33
+
34
+ const identity = getIdentity();
35
+
36
+ if (!identity) {
37
+ error('No identity found. Run "hw1 init" first.');
38
+ process.exit(1);
39
+ }
40
+
41
+ if (!options.payload && !options.file) {
42
+ error('Either --payload <json> or --file <path> is required.');
43
+ process.exit(1);
44
+ }
45
+
46
+ let payload: Record<string, unknown> = {};
47
+
48
+ if (options.payload) {
49
+ try {
50
+ payload = JSON.parse(options.payload);
51
+ } catch {
52
+ error('Invalid JSON payload');
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ if (options.file) {
58
+ const filePath: string = options.file;
59
+ let fileBytes: Buffer;
60
+ try {
61
+ fileBytes = await readFile(filePath);
62
+ } catch {
63
+ error(`Cannot read file: ${filePath}`);
64
+ process.exit(1);
65
+ }
66
+ const filename = basename(filePath);
67
+ const mimeType = guessMimeType(filename);
68
+ payload.attachment = {
69
+ filename,
70
+ mimeType,
71
+ size: fileBytes.length,
72
+ data: fileBytes.toString('base64'),
73
+ };
74
+ info(`Attaching file: ${filename} (${fileBytes.length} bytes, ${mimeType})`);
75
+ }
76
+
77
+ // Try daemon first for fast path
78
+ const client = new DaemonClient();
79
+ if (await client.isDaemonRunning()) {
80
+ const spin = spinner('Sending message via daemon...');
81
+
82
+ try {
83
+ const result = await client.send('send', {
84
+ to: options.to,
85
+ protocol: options.protocol,
86
+ payload,
87
+ type: options.type,
88
+ peer: options.peer,
89
+ });
90
+
91
+ spin.succeed('Message sent successfully!');
92
+
93
+ console.log();
94
+ info(`Message ID: ${result.id}`);
95
+ info(`To: ${options.to}`);
96
+ info(`Protocol: ${options.protocol}`);
97
+ info(`Type: ${options.type}`);
98
+ if (payload.attachment) {
99
+ const att = payload.attachment as { filename: string; mimeType: string; size: number };
100
+ info(`Attachment: ${att.filename} (${att.size} bytes, ${att.mimeType})`);
101
+ const rest = { ...payload, attachment: '[binary data omitted]' };
102
+ info(`Payload: ${JSON.stringify(rest)}`);
103
+ } else {
104
+ info(`Payload: ${JSON.stringify(payload)}`);
105
+ }
106
+
107
+ // Display response if received
108
+ if (result.response) {
109
+ console.log();
110
+ success('>>> Received response from recipient');
111
+ info(`Response ID: ${result.response.id}`);
112
+ info(`Reply To: ${result.response.replyTo}`);
113
+ info(`Protocol: ${result.response.protocol}`);
114
+ const respPayload = result.response.payload as Record<string, unknown>;
115
+ if (respPayload?.attachment) {
116
+ const att = respPayload.attachment as { filename: string; mimeType: string; size: number };
117
+ info(`Attachment: ${att.filename} (${att.size} bytes, ${att.mimeType})`);
118
+ info(`Payload: ${JSON.stringify({ ...respPayload, attachment: '[binary data omitted]' }, null, 2)}`);
119
+ } else {
120
+ info(`Payload: ${JSON.stringify(result.response.payload, null, 2)}`);
121
+ }
122
+ } else if (options.type === 'request') {
123
+ console.log();
124
+ info('No response received (recipient may not have returned a response)');
125
+ }
126
+
127
+ success('Done');
128
+ return;
129
+ } catch (err) {
130
+ spin.fail('Daemon send failed, falling back to ephemeral node');
131
+ console.log();
132
+ info('Tip: Restart daemon with "clawiverse daemon restart" if issues persist');
133
+ console.log();
134
+ }
135
+ } else {
136
+ console.log();
137
+ info('⚠ Daemon not running, using ephemeral node (slower)');
138
+ info('Tip: Start daemon with "clawiverse daemon start" for faster messaging');
139
+ console.log();
140
+ }
141
+
142
+ // Fallback: create ephemeral node (original behavior)
143
+ const spin = spinner('Starting node...');
144
+
145
+ const keyPair = importKeyPair({
146
+ publicKey: identity.publicKey,
147
+ privateKey: identity.privateKey,
148
+ });
149
+
150
+ const bootstrapPeers = options.bootstrap || getBootstrapPeers();
151
+
152
+ const node = await createNode({
153
+ keyPair,
154
+ bootstrapPeers,
155
+ enableDHT: true,
156
+ });
157
+
158
+ // Register peer:identify listener BEFORE start() so we don't miss the event
159
+ const identifyDone = new Promise<void>((resolve) => {
160
+ const timeout = setTimeout(resolve, 2000);
161
+ node.libp2p.addEventListener('peer:identify', () => {
162
+ clearTimeout(timeout);
163
+ resolve();
164
+ }, { once: true });
165
+ });
166
+
167
+ await node.start();
168
+
169
+ spin.text = 'Connecting to network...';
170
+ await identifyDone;
171
+
172
+ const dht = createDHTOperations(node.libp2p);
173
+ const verifyFn = async (signature: Uint8Array, data: Uint8Array): Promise<boolean> => {
174
+ try {
175
+ const decoded = JSON.parse(new TextDecoder().decode(data)) as { from?: string };
176
+ if (!decoded.from || typeof decoded.from !== 'string') return false;
177
+ const senderPublicKey = extractPublicKey(decoded.from);
178
+ return verify(signature, data, senderPublicKey);
179
+ } catch {
180
+ return false;
181
+ }
182
+ };
183
+
184
+ const router = createMessageRouter(
185
+ node.libp2p,
186
+ verifyFn,
187
+ dht,
188
+ bootstrapPeers
189
+ );
190
+
191
+ await router.start();
192
+
193
+ spin.text = 'Creating message...';
194
+
195
+ const envelope = createEnvelope(
196
+ identity.did,
197
+ options.to,
198
+ options.type,
199
+ options.protocol,
200
+ payload
201
+ );
202
+
203
+ const signedEnvelope = await signEnvelope(envelope, (data) =>
204
+ sign(data, keyPair.privateKey)
205
+ );
206
+
207
+ spin.text = 'Sending message...';
208
+
209
+ // Build peer hint from --peer option if provided
210
+ let peerHint = undefined;
211
+ if (options.peer) {
212
+ // Extract peerId from multiaddr (last component after /p2p/)
213
+ const parts = options.peer.split('/p2p/');
214
+ if (parts.length === 2) {
215
+ peerHint = {
216
+ peerId: parts[1],
217
+ multiaddrs: [options.peer],
218
+ };
219
+ info(`Direct peer: ${options.peer}`);
220
+ }
221
+ }
222
+
223
+ const response = await router.sendMessage(signedEnvelope, peerHint);
224
+
225
+ spin.succeed('Message sent successfully!');
226
+
227
+ console.log();
228
+ info(`Message ID: ${signedEnvelope.id}`);
229
+ info(`To: ${options.to}`);
230
+ info(`Protocol: ${options.protocol}`);
231
+ info(`Type: ${options.type}`);
232
+ if (payload.attachment) {
233
+ const att = payload.attachment as { filename: string; mimeType: string; size: number };
234
+ info(`Attachment: ${att.filename} (${att.size} bytes, ${att.mimeType})`);
235
+ const rest = { ...payload, attachment: '[binary data omitted]' };
236
+ info(`Payload: ${JSON.stringify(rest)}`);
237
+ } else {
238
+ info(`Payload: ${JSON.stringify(payload)}`);
239
+ }
240
+
241
+ // Display response if received
242
+ if (response) {
243
+ console.log();
244
+ success('>>> Received response from recipient');
245
+ info(`Response ID: ${response.id}`);
246
+ info(`Reply To: ${response.replyTo}`);
247
+ info(`Protocol: ${response.protocol}`);
248
+ const respPayload = response.payload as Record<string, unknown>;
249
+ if (respPayload?.attachment) {
250
+ const att = respPayload.attachment as { filename: string; mimeType: string; size: number };
251
+ info(`Attachment: ${att.filename} (${att.size} bytes, ${att.mimeType})`);
252
+ info(`Payload: ${JSON.stringify({ ...respPayload, attachment: '[binary data omitted]' }, null, 2)}`);
253
+ } else {
254
+ info(`Payload: ${JSON.stringify(response.payload, null, 2)}`);
255
+ }
256
+ } else if (options.type === 'request') {
257
+ console.log();
258
+ info('No response received (recipient may not have returned a response)');
259
+ }
260
+
261
+ await router.stop();
262
+ await node.stop();
263
+
264
+ success('Done');
265
+ } catch (err) {
266
+ error(`Failed to send message: ${(err as Error).message}`);
267
+ if ((err as Error).cause) error(`Cause: ${((err as Error).cause as Error).message}`);
268
+ console.error(err);
269
+ process.exit(1);
270
+ }
271
+ });
272
+ }
273
+
274
+ function guessMimeType(filename: string): string {
275
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
276
+ const map: Record<string, string> = {
277
+ // images
278
+ png: 'image/png',
279
+ jpg: 'image/jpeg',
280
+ jpeg: 'image/jpeg',
281
+ gif: 'image/gif',
282
+ webp: 'image/webp',
283
+ svg: 'image/svg+xml',
284
+ // documents
285
+ pdf: 'application/pdf',
286
+ txt: 'text/plain',
287
+ md: 'text/markdown',
288
+ json: 'application/json',
289
+ csv: 'text/csv',
290
+ // audio/video
291
+ mp3: 'audio/mpeg',
292
+ mp4: 'video/mp4',
293
+ wav: 'audio/wav',
294
+ // archives
295
+ zip: 'application/zip',
296
+ gz: 'application/gzip',
297
+ };
298
+ return map[ext] ?? 'application/octet-stream';
299
+ }