@highway1/cli 0.1.44 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@highway1/cli",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "description": "CLI tool for Clawiverse network",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "clean": "rm -rf dist"
14
14
  },
15
15
  "dependencies": {
16
- "@highway1/core": "^0.1.44",
16
+ "@highway1/core": "^0.1.45",
17
17
  "chalk": "^5.3.0",
18
18
  "cli-table3": "^0.6.5",
19
19
  "commander": "^12.1.0",
@@ -0,0 +1,207 @@
1
+ import { Command } from 'commander';
2
+ import { ClawDaemon } from '../daemon/server.js';
3
+ import { DaemonClient } from '../daemon/client.js';
4
+ import { spawn } from 'child_process';
5
+ import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'fs';
6
+ import { success, error, info } from '../ui.js';
7
+
8
+ const PID_FILE = '/tmp/clawiverse.pid';
9
+ const SOCKET_PATH = '/tmp/clawiverse.sock';
10
+
11
+ export function registerDaemonCommand(program: Command): void {
12
+ const daemonCommand = program
13
+ .command('daemon')
14
+ .description('Manage Clawiverse daemon for fast messaging');
15
+
16
+ daemonCommand
17
+ .command('start')
18
+ .description('Start daemon in background')
19
+ .action(async () => {
20
+ try {
21
+ const client = new DaemonClient(SOCKET_PATH);
22
+ if (await client.isDaemonRunning()) {
23
+ success('Daemon already running');
24
+ const status = await client.send('status', {});
25
+ info(`Peer ID: ${status.peerId}`);
26
+ info(`DID: ${status.did}`);
27
+ return;
28
+ }
29
+
30
+ // Start daemon as detached background process
31
+ const child = spawn(process.execPath, [
32
+ process.argv[1],
33
+ 'daemon',
34
+ 'run'
35
+ ], {
36
+ detached: true,
37
+ stdio: 'ignore'
38
+ });
39
+
40
+ child.unref();
41
+ writeFileSync(PID_FILE, child.pid!.toString());
42
+
43
+ // Wait for daemon to be ready
44
+ for (let i = 0; i < 20; i++) {
45
+ await new Promise(resolve => setTimeout(resolve, 500));
46
+ if (await client.isDaemonRunning()) {
47
+ success('Daemon started');
48
+ const status = await client.send('status', {});
49
+ info(`Peer ID: ${status.peerId}`);
50
+ info(`DID: ${status.did}`);
51
+ info(`Socket: ${SOCKET_PATH}`);
52
+ return;
53
+ }
54
+ }
55
+
56
+ error('Failed to start daemon (timeout)');
57
+ process.exit(1);
58
+ } catch (err) {
59
+ error(`Failed to start daemon: ${(err as Error).message}`);
60
+ process.exit(1);
61
+ }
62
+ });
63
+
64
+ daemonCommand
65
+ .command('run')
66
+ .description('Run daemon in foreground (internal use)')
67
+ .action(async () => {
68
+ try {
69
+ const daemon = new ClawDaemon(SOCKET_PATH);
70
+ await daemon.start();
71
+
72
+ // Keep process alive
73
+ process.on('SIGINT', async () => {
74
+ console.log('\nShutting down...');
75
+ await daemon.shutdown();
76
+ process.exit(0);
77
+ });
78
+
79
+ process.on('SIGTERM', async () => {
80
+ await daemon.shutdown();
81
+ process.exit(0);
82
+ });
83
+ } catch (err) {
84
+ error(`Failed to run daemon: ${(err as Error).message}`);
85
+ process.exit(1);
86
+ }
87
+ });
88
+
89
+ daemonCommand
90
+ .command('stop')
91
+ .description('Stop daemon')
92
+ .action(async () => {
93
+ try {
94
+ const client = new DaemonClient(SOCKET_PATH);
95
+
96
+ if (!(await client.isDaemonRunning())) {
97
+ info('Daemon not running');
98
+ return;
99
+ }
100
+
101
+ await client.send('shutdown', {});
102
+ success('Daemon stopped');
103
+
104
+ // Clean up PID file
105
+ try {
106
+ if (existsSync(PID_FILE)) {
107
+ unlinkSync(PID_FILE);
108
+ }
109
+ } catch {}
110
+
111
+ // Clean up socket file
112
+ try {
113
+ if (existsSync(SOCKET_PATH)) {
114
+ unlinkSync(SOCKET_PATH);
115
+ }
116
+ } catch {}
117
+ } catch (err) {
118
+ error(`Failed to stop daemon: ${(err as Error).message}`);
119
+ process.exit(1);
120
+ }
121
+ });
122
+
123
+ daemonCommand
124
+ .command('status')
125
+ .description('Check daemon status')
126
+ .action(async () => {
127
+ try {
128
+ const client = new DaemonClient(SOCKET_PATH);
129
+
130
+ if (await client.isDaemonRunning()) {
131
+ const status = await client.send('status', {});
132
+ success('Daemon running');
133
+ console.log();
134
+ info(`Peer ID: ${status.peerId}`);
135
+ info(`DID: ${status.did}`);
136
+ info(`Socket: ${SOCKET_PATH}`);
137
+ info(`Bootstrap peers: ${status.bootstrapPeers.length}`);
138
+ console.log();
139
+ info('Multiaddrs:');
140
+ status.multiaddrs.forEach((addr: string) => {
141
+ console.log(` ${addr}`);
142
+ });
143
+ } else {
144
+ info('Daemon not running');
145
+ console.log();
146
+ info('Start daemon with: clawiverse daemon start');
147
+ }
148
+ } catch (err) {
149
+ error(`Failed to check status: ${(err as Error).message}`);
150
+ process.exit(1);
151
+ }
152
+ });
153
+
154
+ daemonCommand
155
+ .command('restart')
156
+ .description('Restart daemon')
157
+ .action(async () => {
158
+ try {
159
+ const client = new DaemonClient(SOCKET_PATH);
160
+
161
+ // Stop if running
162
+ if (await client.isDaemonRunning()) {
163
+ info('Stopping daemon...');
164
+ await client.send('shutdown', {});
165
+ await new Promise(resolve => setTimeout(resolve, 1000));
166
+ }
167
+
168
+ // Clean up files
169
+ try {
170
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
171
+ if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
172
+ } catch {}
173
+
174
+ // Start daemon
175
+ info('Starting daemon...');
176
+ const child = spawn(process.execPath, [
177
+ process.argv[1],
178
+ 'daemon',
179
+ 'run'
180
+ ], {
181
+ detached: true,
182
+ stdio: 'ignore'
183
+ });
184
+
185
+ child.unref();
186
+ writeFileSync(PID_FILE, child.pid!.toString());
187
+
188
+ // Wait for daemon to be ready
189
+ for (let i = 0; i < 20; i++) {
190
+ await new Promise(resolve => setTimeout(resolve, 500));
191
+ if (await client.isDaemonRunning()) {
192
+ success('Daemon restarted');
193
+ const status = await client.send('status', {});
194
+ info(`Peer ID: ${status.peerId}`);
195
+ info(`DID: ${status.did}`);
196
+ return;
197
+ }
198
+ }
199
+
200
+ error('Failed to restart daemon (timeout)');
201
+ process.exit(1);
202
+ } catch (err) {
203
+ error(`Failed to restart daemon: ${(err as Error).message}`);
204
+ process.exit(1);
205
+ }
206
+ });
207
+ }
@@ -12,6 +12,8 @@ import {
12
12
  } from '@highway1/core';
13
13
  import { getIdentity, getAgentCard, getBootstrapPeers } from '../config.js';
14
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';
15
17
 
16
18
  export function registerJoinCommand(program: Command): void {
17
19
  program
@@ -19,6 +21,7 @@ export function registerJoinCommand(program: Command): void {
19
21
  .description('Join the Clawiverse network')
20
22
  .option('--bootstrap <peers...>', 'Bootstrap peer addresses')
21
23
  .option('--relay', 'Run as a relay server and advertise relay capability')
24
+ .option('--save-dir <path>', 'Directory to save received file attachments', './downloads')
22
25
  .action(async (options) => {
23
26
  try {
24
27
  printHeader('Join Clawiverse Network');
@@ -280,6 +283,8 @@ export function registerJoinCommand(program: Command): void {
280
283
  dht
281
284
  );
282
285
 
286
+ const saveDir = resolve(options.saveDir ?? './downloads');
287
+
283
288
  // Generic message handler that accepts any protocol
284
289
  const messageHandler = async (envelope: any) => {
285
290
  const payload = envelope.payload as Record<string, unknown>;
@@ -288,7 +293,32 @@ export function registerJoinCommand(program: Command): void {
288
293
  info(` Message ID: ${envelope.id}`);
289
294
  info(` Protocol: ${envelope.protocol}`);
290
295
  info(` Type: ${envelope.type}`);
291
- info(` Payload: ${JSON.stringify(payload, null, 2)}`);
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
+ }
292
322
  console.log();
293
323
 
294
324
  // If this is a request, send back a simple acknowledgment response
@@ -303,17 +333,17 @@ export function registerJoinCommand(program: Command): void {
303
333
  });
304
334
 
305
335
  const responseEnvelope = createEnvelope(
306
- envelope.to, // We are the recipient, now we're the sender
307
- envelope.from, // Original sender is now the recipient
336
+ envelope.to,
337
+ envelope.from,
308
338
  'response',
309
339
  envelope.protocol,
310
340
  {
311
341
  status: 'received',
312
342
  message: 'Message received and processed',
313
- originalPayload: payload,
314
- timestamp: Date.now()
343
+ savedPath: savedPath ?? null,
344
+ timestamp: Date.now(),
315
345
  },
316
- envelope.id // replyTo: original message ID
346
+ envelope.id
317
347
  );
318
348
 
319
349
  const signedResponse = await signEnvelope(responseEnvelope, (data) =>
@@ -12,6 +12,9 @@ import {
12
12
  } from '@highway1/core';
13
13
  import { getIdentity, getBootstrapPeers } from '../config.js';
14
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';
15
18
 
16
19
  export function registerSendCommand(program: Command): void {
17
20
  program
@@ -19,7 +22,8 @@ export function registerSendCommand(program: Command): void {
19
22
  .description('Send a message to another agent')
20
23
  .requiredOption('--to <did>', 'Recipient DID')
21
24
  .requiredOption('--protocol <protocol>', 'Protocol identifier')
22
- .requiredOption('--payload <json>', 'Message payload (JSON)')
25
+ .option('--payload <json>', 'Message payload (JSON)')
26
+ .option('--file <path>', 'Attach a file (image, binary, text) as payload attachment')
23
27
  .option('--type <type>', 'Message type (request|notification)', 'request')
24
28
  .option('--bootstrap <peers...>', 'Bootstrap peer addresses')
25
29
  .option('--peer <multiaddr>', 'Direct peer multiaddr (bypasses DHT lookup)')
@@ -34,14 +38,108 @@ export function registerSendCommand(program: Command): void {
34
38
  process.exit(1);
35
39
  }
36
40
 
37
- let payload;
38
- try {
39
- payload = JSON.parse(options.payload);
40
- } catch {
41
- error('Invalid JSON payload');
41
+ if (!options.payload && !options.file) {
42
+ error('Either --payload <json> or --file <path> is required.');
42
43
  process.exit(1);
43
44
  }
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)
45
143
  const spin = spinner('Starting node...');
46
144
 
47
145
  const keyPair = importKeyPair({
@@ -59,10 +157,10 @@ export function registerSendCommand(program: Command): void {
59
157
 
60
158
  // Register peer:identify listener BEFORE start() so we don't miss the event
61
159
  const identifyDone = new Promise<void>((resolve) => {
62
- const timeout = setTimeout(resolve, 12000);
160
+ const timeout = setTimeout(resolve, 2000);
63
161
  node.libp2p.addEventListener('peer:identify', () => {
64
162
  clearTimeout(timeout);
65
- setTimeout(resolve, 500);
163
+ resolve();
66
164
  }, { once: true });
67
165
  });
68
166
 
@@ -131,7 +229,14 @@ export function registerSendCommand(program: Command): void {
131
229
  info(`To: ${options.to}`);
132
230
  info(`Protocol: ${options.protocol}`);
133
231
  info(`Type: ${options.type}`);
134
- info(`Payload: ${JSON.stringify(payload)}`);
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
+ }
135
240
 
136
241
  // Display response if received
137
242
  if (response) {
@@ -140,7 +245,14 @@ export function registerSendCommand(program: Command): void {
140
245
  info(`Response ID: ${response.id}`);
141
246
  info(`Reply To: ${response.replyTo}`);
142
247
  info(`Protocol: ${response.protocol}`);
143
- info(`Payload: ${JSON.stringify(response.payload, null, 2)}`);
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
+ }
144
256
  } else if (options.type === 'request') {
145
257
  console.log();
146
258
  info('No response received (recipient may not have returned a response)');
@@ -158,3 +270,30 @@ export function registerSendCommand(program: Command): void {
158
270
  }
159
271
  });
160
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
+ }
@@ -0,0 +1,68 @@
1
+ import { connect, Socket } from 'net';
2
+ import { createLogger } from '@highway1/core';
3
+
4
+ const logger = createLogger('daemon-client');
5
+
6
+ export class DaemonClient {
7
+ private socketPath: string;
8
+
9
+ constructor(socketPath: string = '/tmp/clawiverse.sock') {
10
+ this.socketPath = socketPath;
11
+ }
12
+
13
+ async send(command: string, params: any): Promise<any> {
14
+ return new Promise((resolve, reject) => {
15
+ const socket = connect(this.socketPath);
16
+ const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
17
+
18
+ let responseReceived = false;
19
+
20
+ socket.on('connect', () => {
21
+ const request = { id: requestId, command, params };
22
+ logger.debug('Sending request', { command, id: requestId });
23
+ socket.write(JSON.stringify(request));
24
+ });
25
+
26
+ socket.on('data', (data) => {
27
+ try {
28
+ const response = JSON.parse(data.toString());
29
+ responseReceived = true;
30
+ socket.end();
31
+
32
+ logger.debug('Received response', { success: response.success, id: response.id });
33
+
34
+ if (response.success) {
35
+ resolve(response.data);
36
+ } else {
37
+ reject(new Error(response.error));
38
+ }
39
+ } catch (error) {
40
+ reject(new Error(`Failed to parse response: ${(error as Error).message}`));
41
+ }
42
+ });
43
+
44
+ socket.on('error', (err) => {
45
+ if (!responseReceived) {
46
+ logger.debug('Socket error', { error: err.message });
47
+ reject(err);
48
+ }
49
+ });
50
+
51
+ socket.setTimeout(30000, () => {
52
+ if (!responseReceived) {
53
+ socket.destroy();
54
+ reject(new Error('Request timeout'));
55
+ }
56
+ });
57
+ });
58
+ }
59
+
60
+ async isDaemonRunning(): Promise<boolean> {
61
+ try {
62
+ await this.send('status', {});
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ }