neuronum 8.4.0__py3-none-any.whl → 10.0.0__py3-none-any.whl

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.

Potentially problematic release.


This version of neuronum might be problematic. Click here for more details.

cli/main.py CHANGED
@@ -1,314 +1,52 @@
1
- import subprocess
2
- import os
3
- import platform
4
- import glob
5
- import asyncio
6
- import aiohttp
7
1
  import click
8
2
  import questionary
9
3
  from pathlib import Path
10
4
  import requests
11
- import psutil
12
- from datetime import datetime
13
- import sys
14
- import json
5
+ from cryptography.hazmat.primitives import hashes
15
6
  from cryptography.hazmat.primitives.asymmetric import ec
16
7
  from cryptography.hazmat.primitives import serialization
17
-
18
- @click.group()
19
- def cli():
20
- """Neuronum CLI Tool"""
21
-
22
-
23
- @click.command()
24
- def create_cell():
25
- cell_type = questionary.select(
26
- "Choose Cell type:",
27
- choices=["business", "community"]
28
- ).ask()
29
-
30
- network = questionary.select(
31
- "Choose Network:",
32
- choices=["neuronum.net"]
33
- ).ask()
34
-
35
- if cell_type == "business":
36
- click.echo("Visit https://neuronum.net/createcell to create your Neuronum Business Cell")
37
-
38
- if cell_type == "community":
39
-
40
- email = click.prompt("Enter email")
41
- password = click.prompt("Enter password", hide_input=True)
42
- repeat_password = click.prompt("Repeat password", hide_input=True)
43
-
44
- if password != repeat_password:
45
- click.echo("Passwords do not match!")
46
- return
47
-
48
- url = f"https://{network}/api/create_cell/{cell_type}"
49
-
50
- create_cell = {"email": email, "password": password}
51
-
52
- try:
53
- response = requests.post(url, json=create_cell)
54
- response.raise_for_status()
55
- status = response.json()["status"]
56
-
57
- except requests.exceptions.RequestException as e:
58
- click.echo(f"Error sending request: {e}")
59
- return
60
-
61
- if status == True:
62
- host = response.json()["host"]
63
- cellkey = click.prompt(f"Please verify your email address with the Cell Key send to {email}")
64
-
65
- url = f"https://{network}/api/verify_email"
66
-
67
- verify_email = {"host": host, "email": email, "cellkey": cellkey}
68
-
69
- try:
70
- response = requests.post(url, json=verify_email)
71
- response.raise_for_status()
72
- status = response.json()["status"]
73
-
74
- except requests.exceptions.RequestException as e:
75
- click.echo(f"Error sending request: {e}")
76
- return
77
-
78
- if status == True:
79
- synapse = response.json()["synapse"]
80
- credentials_folder_path = Path.home() / ".neuronum"
81
- credentials_folder_path.mkdir(parents=True, exist_ok=True)
82
-
83
- env_path = credentials_folder_path / ".env"
84
- env_path.write_text(f"HOST={host}\nPASSWORD={password}\nNETWORK={network}\nSYNAPSE={synapse}\n")
85
-
86
- click.echo(f"Welcome to Neuronum! Community Cell '{host}' created and connected!")
87
-
88
- if status == False:
89
- click.echo(f"Error:'{email}' already assigned!")
90
-
91
-
92
- @click.command()
93
- def connect_cell():
94
- email = click.prompt("Enter your Email")
95
- password = click.prompt("Enter password", hide_input=True)
96
-
97
- network = questionary.select(
98
- "Choose Network:",
99
- choices=["neuronum.net"]
100
- ).ask()
101
-
102
- url = f"https://{network}/api/connect_cell"
103
- payload = {"email": email, "password": password}
104
-
105
- try:
106
- response = requests.post(url, json=payload)
107
- response.raise_for_status()
108
- status = response.json()["status"]
109
- host = response.json()["host"]
110
- except requests.exceptions.RequestException as e:
111
- click.echo(f"Error connecting: {e}")
112
- return
113
-
114
- if status == True:
115
- cellkey = click.prompt(f"Please verify your email address with the Cell Key send to {email}")
116
- url = f"https://{network}/api/verify_email"
117
- verify_email = {"host": host, "email": email, "cellkey": cellkey}
118
-
119
- try:
120
- response = requests.post(url, json=verify_email)
121
- response.raise_for_status()
122
- status = response.json()["status"]
123
- synapse = response.json()["synapse"]
124
-
125
- except requests.exceptions.RequestException as e:
126
- click.echo(f"Error sending request: {e}")
127
- return
128
-
129
- if status == True:
130
- credentials_folder_path = Path.home() / ".neuronum"
131
- credentials_folder_path.mkdir(parents=True, exist_ok=True)
132
-
133
- env_path = credentials_folder_path / f".env"
134
- env_path.write_text(f"HOST={host}\nPASSWORD={password}\nNETWORK={network}\nSYNAPSE={synapse}\n")
135
-
136
- click.echo(f"Cell '{host}' connected!")
137
- else:
138
- click.echo(f"Connection failed!")
139
-
140
-
141
- @click.command()
142
- def view_cell():
143
- credentials_folder_path = Path.home() / ".neuronum"
144
- env_path = credentials_folder_path / ".env"
145
-
146
- env_data = {}
147
-
148
- try:
149
- with open(env_path, "r") as f:
150
- for line in f:
151
- key, value = line.strip().split("=")
152
- env_data[key] = value
153
-
154
- host = env_data.get("HOST", "")
155
-
156
- except FileNotFoundError:
157
- click.echo("Error: No credentials found. Please connect to a cell first.")
158
- return
159
- except Exception as e:
160
- click.echo(f"Error reading .env file: {e}")
161
- return
162
-
163
- if host:
164
- click.echo(f"Connected Cell: '{host}'")
165
- else:
166
- click.echo("No active cell connection found.")
167
-
168
-
169
- @click.command()
170
- def disconnect_cell():
171
- credentials_folder_path = Path.home() / ".neuronum"
172
- env_path = credentials_folder_path / ".env"
173
-
174
- env_data = {}
175
-
176
- try:
177
- with open(env_path, "r") as f:
178
- for line in f:
179
- key, value = line.strip().split("=")
180
- env_data[key] = value
181
-
182
- host = env_data.get("HOST", "")
183
-
184
- except FileNotFoundError:
185
- click.echo("Error: .env with credentials not found")
186
- return
187
- except Exception as e:
188
- click.echo(f"Error reading .env file: {e}")
189
- return
190
-
191
- if env_path.exists():
192
- if click.confirm(f"Are you sure you want to disconnect Cell '{host}'?", default=True):
193
- os.remove(env_path)
194
- click.echo(f"'{host}' disconnected!")
195
- else:
196
- click.echo("Disconnect canceled.")
197
- else:
198
- click.echo(f"No Neuronum Cell connected!")
199
-
200
-
201
- @click.command()
202
- def delete_cell():
203
- credentials_folder_path = Path.home() / ".neuronum"
204
- env_path = credentials_folder_path / ".env"
205
-
206
- env_data = {}
207
-
208
- try:
209
- with open(env_path, "r") as f:
210
- for line in f:
211
- key, value = line.strip().split("=")
212
- env_data[key] = value
213
-
214
- host = env_data.get("HOST", "")
215
- password = env_data.get("PASSWORD", "")
216
- network = env_data.get("NETWORK", "")
217
- synapse = env_data.get("SYNAPSE", "")
218
-
219
- except FileNotFoundError:
220
- click.echo("Error: No cell connected. Connect Cell first to delete")
221
- return
222
- except Exception as e:
223
- click.echo(f"Error reading .env file: {e}")
224
- return
225
-
226
- confirm = click.confirm(f" Are you sure you want to delete '{host}'?", default=True)
227
- os.remove(env_path)
228
- if not confirm:
229
- click.echo("Deletion canceled.")
230
- return
231
-
232
- url = f"https://{network}/api/delete_cell"
233
- payload = {"host": host, "password": password, "synapse": synapse}
234
-
8
+ from cryptography.hazmat.backends import default_backend
9
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
10
+ import base64
11
+ import time
12
+ import hashlib
13
+ from bip_utils import Bip39MnemonicGenerator, Bip39SeedGenerator
14
+ from bip_utils import Bip39MnemonicValidator, Bip39Languages
15
+
16
+ # --- Configuration Constants ---
17
+ NEURONUM_PATH = Path.home() / ".neuronum"
18
+ ENV_FILE = NEURONUM_PATH / ".env"
19
+ PUBLIC_KEY_FILE = NEURONUM_PATH / "public_key.pem"
20
+ PRIVATE_KEY_FILE = NEURONUM_PATH / "private_key.pem"
21
+ API_BASE_URL = "https://neuronum.net/api"
22
+
23
+ # --- Utility Functions ---
24
+
25
+ def sign_message(private_key: EllipticCurvePrivateKey, message: bytes) -> str:
26
+ """Signs a message using the given private key and returns a base64 encoded signature."""
235
27
  try:
236
- response = requests.delete(url, json=payload)
237
- response.raise_for_status()
238
- status = response.json()["status"]
239
- except requests.exceptions.RequestException as e:
240
- click.echo(f"Error deleting cell: {e}")
241
- return
242
-
243
- if status == True:
244
- env_path = credentials_folder_path / f"{host}.env"
245
- if env_path.exists():
246
- os.remove(env_path)
247
- click.echo("Credentials deleted successfully!")
248
- click.echo(f"Neuronum Cell '{host}' has been deleted!")
249
- else:
250
- click.echo(f"Neuronum Cell '{host}' deletion failed!")
251
-
252
-
253
- @click.command()
254
- def init_node():
255
- descr = click.prompt("Node description: Type up to 25 characters").strip()
256
- if descr and len(descr) > 25:
257
- click.echo("Description too long. Max 25 characters allowed.")
258
- return
259
- asyncio.run(async_init_node(descr))
260
-
261
- async def async_init_node(descr):
262
- credentials_folder_path = Path.home() / ".neuronum"
263
- env_path = credentials_folder_path / ".env"
264
-
265
- env_data = {}
266
-
267
- try:
268
- with open(env_path, "r") as f:
269
- for line in f:
270
- key, value = line.strip().split("=")
271
- env_data[key] = value
272
-
273
-
274
- host = env_data.get("HOST", "")
275
- password = env_data.get("PASSWORD", "")
276
- network = env_data.get("NETWORK", "")
277
- synapse = env_data.get("SYNAPSE", "")
278
-
279
- except FileNotFoundError:
280
- click.echo("No cell connected. Connect your cell with command neuronum connect-cell")
281
- return
28
+ signature = private_key.sign(
29
+ message,
30
+ ec.ECDSA(hashes.SHA256())
31
+ )
32
+ return base64.b64encode(signature).decode()
282
33
  except Exception as e:
283
- click.echo(f"Error reading .env file: {e}")
284
- return
285
-
286
- url = f"https://{network}/api/init_node"
287
- node = {
288
- "host": host,
289
- "password": password,
290
- "synapse": synapse,
291
- "descr": descr,
292
- }
293
-
294
- async with aiohttp.ClientSession() as session:
295
- try:
296
- async with session.post(url, json=node) as response:
297
- response.raise_for_status()
298
- data = await response.json()
299
- node_id = data["nodeID"]
300
- except aiohttp.ClientError as e:
301
- click.echo(f"Error sending request: {e}")
302
- return
303
-
304
- node_filename = descr + "_" + node_id.replace("::node", "")
305
- project_path = Path(node_filename)
306
- project_path.mkdir(exist_ok=True)
34
+ click.echo(f"Error signing message: {e}")
35
+ return ""
307
36
 
37
+ def derive_keys_from_mnemonic(mnemonic: str):
38
+ """Derives EC-SECP256R1 keys from a BIP-39 mnemonic's seed."""
308
39
  try:
309
- private_key = ec.generate_private_key(ec.SECP256R1())
40
+ seed = Bip39SeedGenerator(mnemonic).Generate()
41
+ # Hash the seed to get a deterministic and strong key derivation input
42
+ digest = hashlib.sha256(seed).digest()
43
+ int_key = int.from_bytes(digest, "big")
44
+
45
+ # Derive the private key
46
+ private_key = ec.derive_private_key(int_key, ec.SECP256R1(), default_backend())
310
47
  public_key = private_key.public_key()
311
48
 
49
+ # Serialize keys to PEM format
312
50
  pem_private = private_key.private_bytes(
313
51
  encoding=serialization.Encoding.PEM,
314
52
  format=serialization.PrivateFormat.PKCS8,
@@ -319,714 +57,263 @@ async def async_init_node(descr):
319
57
  encoding=serialization.Encoding.PEM,
320
58
  format=serialization.PublicFormat.SubjectPublicKeyInfo
321
59
  )
322
-
323
- public_key_pem_file = project_path / "public_key.pem"
324
- with open(public_key_pem_file, "wb") as key_file:
325
- key_file.write(pem_public)
326
-
327
- private_key_pem_file = project_path / "private_key.pem"
328
- with open(private_key_pem_file, "wb") as key_file:
329
- key_file.write(pem_private)
330
-
331
- pem_public_str = pem_public.decode('utf-8')
332
- pem_public_oneline = "".join(pem_public_str.split())
333
-
334
- current_directory = os.getcwd()
335
- private_key_file = os.path.join(current_directory / project_path, "private_key.pem")
336
- public_key_file = os.path.join(current_directory / project_path, "public_key.pem")
337
- except:
338
- print("Error creating Private/Public Key Pair")
339
-
340
- app_path = project_path / "app.py"
341
- app_path.write_text(f"""\
342
- import asyncio
343
- from neuronum import Node
344
- from jinja2 import Environment, FileSystemLoader
345
-
346
- env = Environment(loader=FileSystemLoader('.'))
347
- template = env.get_template('ping.html')
348
-
349
- node = Node(
350
- id="{node_id}",
351
- private_key="{private_key_file}",
352
- public_key="{public_key_file}"
353
- )
354
-
355
- async def main():
356
-
357
- async for transmitter in node.sync():
358
- ts = transmitter.get("time")
359
- data = transmitter.get("data")
360
- transmitter_id = transmitter.get("transmitter_id")
361
- client = transmitter.get("operator")
362
- client_public_key = data.get("publicKey")
363
- action = data.get("action")
364
-
365
- response_data = {{}}
366
-
367
- if action == "ping_node" or action == "start_app":
368
60
 
369
- html_content = template.render(client=client, ts=ts, data=action, transmitter_id=transmitter_id)
370
-
371
- response_data = {{
372
- "json": f"{{transmitter_id}} - Reply from {node_id}: Pinged by {{client}} at {{ts}} with action: {{action}}",
373
- "html": html_content
374
- }}
375
-
376
- await node.tx_response(transmitter_id, response_data, client_public_key)
377
-
378
- asyncio.run(main())
379
- """)
61
+ return private_key, public_key, pem_private, pem_public
380
62
 
381
- html_path = project_path / "ping.html"
382
- html_content = f"""\
383
- <!DOCTYPE html>
384
- <html>
385
- <head>
386
- <style>
387
- body {{
388
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
389
- background-color: #121212;
390
- color: #e0e0e0;
391
- margin: 0;
392
- padding: 0;
393
- display: flex;
394
- justify-content: center;
395
- align-items: center;
396
- min-height: 100vh;
397
- }}
398
-
399
- .container {{
400
- background-color: #1e1e1e;
401
- border-radius: 12px;
402
- padding: 40px;
403
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
404
- width: 100%;
405
- max-width: 500px;
406
- text-align: center;
407
- box-sizing: border-box;
408
- }}
409
-
410
- .logo {{
411
- width: 80px;
412
- margin-bottom: 25px;
413
- filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.1));
414
- }}
415
-
416
- h1 {{
417
- font-size: 1.5em;
418
- font-weight: 600;
419
- margin-bottom: 5px;
420
- color: #f5f5f5;
421
- }}
422
-
423
- .subtitle {{
424
- font-size: 0.9em;
425
- color: #a0a0a0;
426
- margin-bottom: 30px;
427
- }}
428
-
429
- .data-row {{
430
- background-color: #2a2a2a;
431
- padding: 12px 15px;
432
- border-radius: 8px;
433
- margin-bottom: 10px;
434
- display: flex;
435
- justify-content: space-between;
436
- align-items: center;
437
- }}
438
-
439
- .data-label {{
440
- font-weight: 400;
441
- color: #a0a0a0;
442
- margin: 0;
443
- }}
444
-
445
- .data-value {{
446
- font-weight: 500;
447
- color: #e0e0e0;
448
- margin: 0;
449
- }}
450
-
451
- .data-value.truncated {{
452
- white-space: nowrap;
453
- overflow: hidden;
454
- text-overflow: ellipsis;
455
- max-width: 60%;
456
- }}
457
-
458
- .data-value.client {{
459
- color: #8cafff;
460
- }}
461
- .data-value.timestamp {{
462
- color: #a1e8a1;
463
- }}
464
- .data-value.transmitter-id {{
465
- color: #f7a2a2;
466
- }}
467
- .api-button {{
468
- background: #01c07d 100%;
469
- color: white;
470
- border: none;
471
- border-radius: 8px;
472
- padding: 12px 24px;
473
- font-size: 16px;
474
- font-weight: bold;
475
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
476
- cursor: pointer;
477
- margin-top: 10px;
478
- }}
479
- </style>
480
- </head>
481
- <body>
482
- <div class="container">
483
- <img class="logo" src="https://neuronum.net/static/logo.png" alt="Neuronum Logo">
484
-
485
- <h1>Reply from {node_id}</h1>
486
- <p class="subtitle">Pinged successfully.</p>
487
-
488
- <div class="data-row">
489
- <p class="data-label">Client</p>
490
- <p class="data-value client">{{{{client}}}}</p>
491
- </div>
492
-
493
- <div class="data-row">
494
- <p class="data-label">Timestamp</p>
495
- <p class="data-value timestamp">{{{{ts}}}}</p>
496
- </div>
497
-
498
- <div class="data-row">
499
- <p class="data-label">Data</p>
500
- <p class="data-value">{{{{data}}}}</p>
501
- </div>
502
-
503
- <div class="data-row">
504
- <p class="data-label">Transmitter ID</p>
505
- <p class="data-value transmitter-id truncated">{{{{transmitter_id}}}}</p>
506
- </div>
507
-
508
- <button id="send-request-btn" class="api-button">Ping again</button>
509
- </div>
510
-
511
- <script>
512
- document.getElementById('send-request-btn').addEventListener('click', () => {{
513
- const messagePayload = {{
514
- type: 'iframe_request',
515
- endpoint: 'https://neuronum.net/browser/api/activate_tx/{node_id}',
516
- data: {{ "action": "ping_node" }},
517
- nodePublicKey: '{pem_public_oneline}',
518
- }};
519
- if (window.parent) {{
520
- window.parent.postMessage(messagePayload, '*');
521
- }}
522
- }});
523
- </script>
524
-
525
- </body>
526
- </html>
527
- """
528
- html_path.write_text(html_content)
529
- config_path = project_path / "config.json"
530
- await asyncio.to_thread(
531
- config_path.write_text,
532
- f"""{{
533
- "app_metadata": {{
534
- "name": "{descr}",
535
- "version": "1.0.0",
536
- "author": "{host}",
537
- "audience": "private",
538
- "logo": "https://neuronum.net/static/logo.png",
539
- "node_id": "{node_id}"
540
- }},
541
- "legals": {{
542
- "terms": "https://url_to_your/terms",
543
- "privacy_policy": "https://url_to_your/privacy_policy"
544
- }},
545
- "public_key": "{pem_public_oneline}"
546
- }}"""
547
-
548
- )
549
- click.echo(f"Neuronum Node '{node_id}' initialized!")
550
-
551
-
552
- @click.command()
553
- @click.option('--d', is_flag=True, help="Start node in detached mode")
554
- def start_node(d):
555
- update_node_at_start()
556
- pid_file = Path.cwd() / "status.txt"
557
- system_name = platform.system()
558
- active_pids = []
559
-
560
- start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
561
-
562
- if pid_file.exists():
563
- try:
564
- with open(pid_file, "r") as f:
565
- pids = [int(line.strip()) for line in f if line.strip().isdigit()]
566
- for pid in pids:
567
- if system_name == "Windows":
568
- if psutil.pid_exists(pid):
569
- active_pids.append(pid)
570
- else:
571
- try:
572
- os.kill(pid, 0)
573
- active_pids.append(pid)
574
- except OSError:
575
- continue
576
- except Exception as e:
577
- click.echo(f"Failed to read PID file: {e}")
578
-
579
- if active_pids:
580
- click.echo(f"Node is already running. Active PIDs: {', '.join(map(str, active_pids))}")
581
- return
582
-
583
- click.echo("Starting Node...")
584
-
585
- project_path = Path.cwd()
586
- script_files = glob.glob("app.py")
587
- processes = []
588
-
589
- for script in script_files:
590
- script_path = project_path / script
591
- if script_path.exists():
592
-
593
- if d:
594
- process = subprocess.Popen(
595
- ["nohup", sys.executable, str(script_path), "&"] if system_name != "Windows"
596
- else ["pythonw", str(script_path)],
597
- stdout=subprocess.DEVNULL,
598
- stderr=subprocess.DEVNULL,
599
- start_new_session=True
600
- )
601
- else:
602
- process = subprocess.Popen(
603
- [sys.executable, str(script_path)]
604
- )
605
-
606
- processes.append(process.pid)
607
-
608
- if not processes:
609
- click.echo("Error: No valid node script found. Ensure the node is set up correctly.")
610
- return
611
-
612
- with open(pid_file, "w") as f:
613
- f.write(f"Started at: {start_time}\n")
614
- f.write("\n".join(map(str, processes)))
615
-
616
- click.echo(f"Node started successfully with PIDs: {', '.join(map(str, processes))}")
617
-
618
-
619
- @click.command()
620
- def check_node():
621
- click.echo("Checking Node status...")
622
-
623
- try:
624
- with open('config.json', 'r') as f:
625
- data = json.load(f)
626
-
627
- nodeID = data['app_metadata']['node_id']
628
-
629
- except FileNotFoundError:
630
- click.echo("Error: .env with credentials not found")
631
- return
632
63
  except Exception as e:
633
- click.echo(f"Error reading .env file: {e}")
634
- return
64
+ click.echo(f"Error generating keys from mnemonic: {e}")
65
+ return None, None, None, None
635
66
 
636
- pid_file = Path.cwd() / "status.txt"
637
-
638
- if not pid_file.exists():
639
- click.echo(f"Node {nodeID} is not running. Status file missing.")
640
- return
641
67
 
68
+ def save_credentials(host: str, mnemonic: str, pem_public: bytes, pem_private: bytes):
69
+ """Saves host, mnemonic, and keys to the .neuronum directory."""
642
70
  try:
643
- with open(pid_file, "r") as f:
644
- lines = f.readlines()
645
- timestamp_line = next((line for line in lines if line.startswith("Started at:")), None)
646
- pids = [int(line.strip()) for line in lines if line.strip().isdigit()]
647
-
648
- if timestamp_line:
649
- click.echo(timestamp_line.strip())
650
- start_time = datetime.strptime(timestamp_line.split(":", 1)[1].strip(), "%Y-%m-%d %H:%M:%S")
651
- now = datetime.now()
652
- uptime = now - start_time
653
- click.echo(f"Uptime: {str(uptime).split('.')[0]}")
71
+ NEURONUM_PATH.mkdir(parents=True, exist_ok=True)
72
+
73
+ # Save .env with host and mnemonic (Sensitive data)
74
+ env_content = f"HOST={host}\nMNEMONIC=\"{mnemonic}\""
75
+ ENV_FILE.write_text(env_content)
76
+
77
+ # Save PEM files
78
+ PUBLIC_KEY_FILE.write_bytes(pem_public)
79
+ PRIVATE_KEY_FILE.write_bytes(pem_private)
80
+
81
+ return True
654
82
  except Exception as e:
655
- click.echo(f"Failed to read PID file: {e}")
656
- return
83
+ click.echo(f" Error saving credentials: {e}")
84
+ return False
657
85
 
658
- system_name = platform.system()
659
- running_pids = []
86
+ def load_credentials():
87
+ """Loads host, mnemonic, and private key from local files."""
88
+ credentials = {}
89
+ try:
90
+ # Load .env data (Host and Mnemonic)
91
+ if not ENV_FILE.exists():
92
+ click.echo("Error: No credentials found. Please create or connect a cell first.")
93
+ return None
660
94
 
661
- for pid in pids:
662
- if system_name == "Windows":
663
- if psutil.pid_exists(pid):
664
- running_pids.append(pid)
665
- else:
666
- try:
667
- os.kill(pid, 0)
668
- running_pids.append(pid)
669
- except OSError:
670
- continue
95
+ with open(ENV_FILE, "r") as f:
96
+ for line in f:
97
+ line = line.strip()
98
+ if "=" in line:
99
+ key, value = line.split("=", 1)
100
+ # Clean up quotes from mnemonic
101
+ credentials[key] = value.strip().strip('"')
671
102
 
672
- if running_pids:
673
- click.echo(f"Node {nodeID} is running. Active PIDs: {', '.join(map(str, running_pids))}")
674
- else:
675
- click.echo(f"Node {nodeID} is not running.")
103
+ credentials['host'] = credentials.get("HOST")
104
+ credentials['mnemonic'] = credentials.get("MNEMONIC")
105
+
106
+ # Load Private Key
107
+ with open(PRIVATE_KEY_FILE, "rb") as key_file:
108
+ private_key = serialization.load_pem_private_key(
109
+ key_file.read(),
110
+ password=None,
111
+ backend=default_backend()
112
+ )
113
+ credentials['private_key'] = private_key
114
+ credentials['public_key'] = private_key.public_key()
676
115
 
116
+ return credentials
677
117
 
678
- @click.command()
679
- @click.option('--d', is_flag=True, help="Restart node in detached mode")
680
- def restart_node(d):
681
- update_node_at_start()
682
- pid_file = Path.cwd() / "status.txt"
683
- system_name = platform.system()
684
-
685
- start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
686
-
687
- try:
688
- with open('config.json', 'r') as f:
689
- data = json.load(f)
690
-
691
- nodeID = data['app_metadata']['node_id']
692
-
693
118
  except FileNotFoundError:
694
- print("Error: .env with credentials not found")
695
- return
119
+ click.echo("Error: Credentials files are incomplete. Try deleting the '.neuronum' folder or reconnecting.")
120
+ return None
696
121
  except Exception as e:
697
- print(f"Error reading .env file: {e}")
698
- return
699
-
700
- if pid_file.exists():
701
- try:
702
- with open(pid_file, "r") as f:
703
- pids = [int(line.strip()) for line in f if line.strip().isdigit()]
704
-
705
- for pid in pids:
706
- if system_name == "Windows":
707
- if psutil.pid_exists(pid):
708
- proc = psutil.Process(pid)
709
- proc.terminate()
710
- else:
711
- try:
712
- os.kill(pid, 15)
713
- except OSError:
714
- continue
715
-
716
- pid_file.unlink()
717
-
718
- click.echo(f"Terminated existing {nodeID} processes: {', '.join(map(str, pids))}")
719
-
720
- except Exception as e:
721
- click.echo(f"Failed to terminate processes: {e}")
722
- return
723
- else:
724
- click.echo(f"Node {nodeID} is not running")
725
-
726
- click.echo(f"Starting Node {nodeID}...")
727
- project_path = Path.cwd()
728
- script_files = glob.glob("app.py")
729
- processes = []
730
-
731
- for script in script_files:
732
- script_path = project_path / script
733
- if script_path.exists():
734
- if d:
735
- process = subprocess.Popen(
736
- ["nohup", sys.executable, str(script_path), "&"] if system_name != "Windows"
737
- else ["pythonw", str(script_path)],
738
- stdout=subprocess.DEVNULL,
739
- stderr=subprocess.DEVNULL,
740
- start_new_session=True
741
- )
742
- else:
743
- process = subprocess.Popen(
744
- [sys.executable, str(script_path)]
745
- )
746
-
747
- processes.append(process.pid)
748
-
749
- if not processes:
750
- click.echo("Error: No valid node script found.")
751
- return
122
+ click.echo(f"Error loading credentials: {e}")
123
+ return None
752
124
 
753
- with open(pid_file, "w") as f:
754
- f.write(f"Started at: {start_time}\n")
755
- f.write("\n".join(map(str, processes)))
125
+ # --- CLI Group ---
756
126
 
757
- click.echo(f"Node {nodeID} started with new PIDs: {', '.join(map(str, processes))}")
127
+ @click.group()
128
+ def cli():
129
+ """Neuronum CLI Tool for Community Cell management."""
130
+ pass
758
131
 
132
+ # --- CLI Commands ---
759
133
 
760
134
  @click.command()
761
- def stop_node():
762
- asyncio.run(async_stop_node())
135
+ def create_cell():
136
+ """Creates a new Community Cell with a randomly generated key pair."""
137
+
138
+ # 1. Generate Mnemonic and Keys
139
+ mnemonic = Bip39MnemonicGenerator().FromWordsNumber(12)
140
+ private_key, public_key, pem_private, pem_public = derive_keys_from_mnemonic(mnemonic)
763
141
 
764
- async def async_stop_node():
765
- click.echo("Stopping Node...")
142
+ if not private_key:
143
+ return
766
144
 
767
- node_pid_path = Path("status.txt")
145
+ # 2. Call API to Create Cell
146
+ click.echo("🔗 Requesting new cell creation from server...")
147
+ url = f"{API_BASE_URL}/create_cell"
148
+ create_data = {"public_key": pem_public.decode("utf-8")}
768
149
 
769
150
  try:
770
- with open('config.json', 'r') as f:
771
- data = json.load(f)
772
-
773
- nodeID = data['app_metadata']['node_id']
151
+ response = requests.post(url, json=create_data, timeout=10)
152
+ response.raise_for_status()
153
+ host = response.json().get("host")
774
154
 
775
- except FileNotFoundError:
776
- print("Error: .env with credentials not found")
777
- return
778
- except Exception as e:
779
- print(f"Error reading .env file: {e}")
155
+ except requests.exceptions.RequestException as e:
156
+ click.echo(f"Error communicating with the server: {e}")
780
157
  return
781
158
 
782
- try:
783
- with open("status.txt", "r") as f:
784
- pids = [int(line.strip()) for line in f if line.strip().isdigit()]
785
-
786
- system_name = platform.system()
787
-
788
- for pid in pids:
789
- try:
790
- if system_name == "Windows":
791
- await asyncio.to_thread(subprocess.run, ["taskkill", "/F", "/PID", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
792
- else:
793
- await asyncio.to_thread(os.kill, pid, 9)
794
- except ProcessLookupError:
795
- click.echo(f"Warning: Process {pid} already stopped or does not exist.")
796
-
797
- await asyncio.to_thread(os.remove, node_pid_path)
798
- click.echo(f"Node {nodeID} stopped successfully!")
799
-
800
- except FileNotFoundError:
801
- click.echo("Error: No active node process found.")
802
- except subprocess.CalledProcessError:
803
- click.echo("Error: Unable to stop some node processes.")
159
+ # 3. Save Credentials
160
+ if host:
161
+ if save_credentials(host, mnemonic, pem_public, pem_private):
162
+ click.echo("\n" + "=" * 50)
163
+ click.echo(" ✅ WELCOME TO NEURONUM! Cell Created Successfully.")
164
+ click.echo("=" * 50)
165
+ click.echo(f" Host: {host}")
166
+ click.echo(f" Mnemonic (CRITICAL! Back this up!):")
167
+ click.echo(f" {mnemonic}")
168
+ click.echo("-" * 50)
169
+ click.echo(f"Credentials saved to: {NEURONUM_PATH}")
170
+ else:
171
+ # Error saving credentials already echoed in helper
172
+ pass
173
+ else:
174
+ click.echo("❌ Error: Server did not return a host. Cell creation failed.")
804
175
 
805
176
 
806
177
  @click.command()
807
- def update_node():
808
- click.echo("Update your Node")
809
- try:
810
- env_path = Path.home() / ".neuronum" / ".env"
811
- env_data = {}
812
- with open(env_path, "r") as f:
813
- for line in f:
814
- if "=" in line:
815
- key, value = line.strip().split("=", 1)
816
- env_data[key] = value
817
-
818
- host = env_data.get("HOST", "")
819
-
820
- with open("config.json", "r") as f:
821
- config_data = json.load(f)
822
-
823
- audience = config_data.get("app_metadata", {}).get("audience", "")
824
- descr = config_data.get("app_metadata", {}).get("name", "")
178
+ def connect_cell():
179
+ """Connects to an existing Community Cell using a 12-word mnemonic."""
825
180
 
826
- if host.startswith("CMTY_") and audience != "private":
827
- raise click.ClickException(
828
- 'Community Cells can only create private Nodes. Set audience to "private".'
829
- )
830
- if descr and len(descr) > 25:
831
- raise click.ClickException(
832
- 'Description too long. Max 25 characters allowed.'
833
- )
181
+ # 1. Get and Validate Mnemonic
182
+ mnemonic = questionary.text("Enter your 12-word BIP-39 mnemonic (space separated):").ask()
834
183
 
835
- except FileNotFoundError as e:
836
- click.echo(f"Error: File not found - {e.filename}")
837
- return
838
- except click.ClickException as e:
839
- click.echo(e.format_message())
840
- return
841
- except Exception as e:
842
- click.echo(f"Error reading files: {e}")
184
+ if not mnemonic:
185
+ click.echo("Connection canceled.")
843
186
  return
844
187
 
845
- asyncio.run(async_update_node(env_data, config_data, audience, descr))
188
+ mnemonic = " ".join(mnemonic.strip().split())
189
+ words = mnemonic.split()
846
190
 
191
+ if len(words) != 12:
192
+ click.echo("❌ Mnemonic must be exactly 12 words.")
193
+ return
847
194
 
848
- async def async_update_node(env_data, config_data, audience: str, descr: str):
849
- try:
850
- host = env_data.get("HOST", "")
851
- password = env_data.get("PASSWORD", "")
852
- network = env_data.get("NETWORK", "")
853
- synapse = env_data.get("SYNAPSE", "")
854
-
855
- node_id = config_data.get("app_metadata", [{}]).get("node_id", "")
856
-
857
- with open("config.json", "r") as f:
858
- config_file_content = f.read()
195
+ if not Bip39MnemonicValidator(Bip39Languages.ENGLISH).IsValid(mnemonic):
196
+ click.echo("❌ Invalid mnemonic. Please ensure all words are valid BIP-39 words.")
197
+ return
859
198
 
860
- except FileNotFoundError:
861
- click.echo("Error: config.json or .env not found")
199
+ # 2. Derive Keys
200
+ private_key, public_key, pem_private, pem_public = derive_keys_from_mnemonic(mnemonic)
201
+ if not private_key:
862
202
  return
863
- except Exception as e:
864
- click.echo(f"Error reading files: {e}")
203
+
204
+ # 3. Prepare Signed Message
205
+ timestamp = str(int(time.time()))
206
+ public_key_pem_str = pem_public.decode('utf-8')
207
+ message = f"public_key={public_key_pem_str};timestamp={timestamp}"
208
+ signature_b64 = sign_message(private_key, message.encode())
209
+
210
+ if not signature_b64:
865
211
  return
866
212
 
867
- url = f"https://{network}/api/update_node"
868
- node = {
869
- "nodeID": node_id,
870
- "host": host,
871
- "password": password,
872
- "synapse": synapse,
873
- "node_type": audience,
874
- "config_file": config_file_content,
875
- "descr": descr,
213
+ # 4. Call API to Connect
214
+ click.echo("🔗 Attempting to connect to cell...")
215
+ url = f"{API_BASE_URL}/connect_cell"
216
+ connect_data = {
217
+ "public_key": public_key_pem_str,
218
+ "signed_message": signature_b64,
219
+ "message": message
876
220
  }
877
221
 
878
- async with aiohttp.ClientSession() as session:
879
- try:
880
- async with session.post(url, json=node) as response:
881
- response.raise_for_status()
882
- data = await response.json()
883
- updated_node_id = data.get("nodeID", node_id)
884
- click.echo(f"Neuronum Node '{updated_node_id}' updated!")
885
- except aiohttp.ClientError as e:
886
- click.echo(f"Error sending request: {e}")
887
-
888
-
889
-
890
- def update_node_at_start():
891
- click.echo("Update your Node")
892
222
  try:
893
- env_path = Path.home() / ".neuronum" / ".env"
894
- env_data = {}
895
- with open(env_path, "r") as f:
896
- for line in f:
897
- if "=" in line:
898
- key, value = line.strip().split("=", 1)
899
- env_data[key] = value
223
+ response = requests.post(url, json=connect_data, timeout=10)
224
+ response.raise_for_status()
225
+ host = response.json().get("host")
226
+ except requests.exceptions.RequestException as e:
227
+ click.echo(f" Error connecting to cell: {e}")
228
+ return
900
229
 
901
- with open("config.json", "r") as f:
902
- config_data = json.load(f)
230
+ # 5. Save Credentials
231
+ if host:
232
+ if save_credentials(host, mnemonic, pem_public, pem_private):
233
+ click.echo(f"🔗 Successfully connected to Community Cell '{host}'.")
234
+ # Error saving credentials already echoed in helper
235
+ else:
236
+ click.echo("❌ Failed to retrieve host from server. Connection failed.")
903
237
 
904
- host = env_data.get("HOST", "")
905
- audience = config_data.get("app_metadata", {}).get("audience", "")
906
- descr = config_data.get("app_metadata", {}).get("name", "")
907
238
 
908
- if host.startswith("CMTY_") and audience != "private":
909
- raise click.ClickException(
910
- 'Community Cells can only start private Nodes. Node starting "privately".'
911
- )
912
- if descr and len(descr) > 25:
913
- raise click.ClickException(
914
- 'Description too long. Max 25 characters allowed.'
915
- )
239
+ @click.command()
240
+ def view_cell():
241
+ """Displays the connection status and host name of the current cell."""
242
+
243
+ credentials = load_credentials()
244
+
245
+ if credentials:
246
+ click.echo("\n--- Neuronum Cell Status ---")
247
+ click.echo(f"Status: ✅ Connected")
248
+ click.echo(f"Host: {credentials['host']}")
249
+ click.echo(f"Path: {NEURONUM_PATH}")
250
+ click.echo(f"Key Type: {credentials['private_key'].curve.name} (SECP256R1)")
251
+ click.echo("----------------------------")
916
252
 
917
- asyncio.run(_async_update_node_at_start(env_data, config_data, audience, descr))
918
253
 
919
- except FileNotFoundError as e:
920
- click.echo(f"Error: File not found - {e.filename}")
921
- except click.ClickException as e:
922
- click.echo(e.format_message())
923
- except Exception as e:
924
- click.echo(f"Unexpected error: {e}")
254
+ @click.command()
255
+ def delete_cell():
256
+ """Deletes the locally stored credentials and requests cell deletion from the server."""
257
+
258
+ # 1. Load Credentials
259
+ credentials = load_credentials()
260
+ if not credentials:
261
+ # Error already echoed in helper
262
+ return
925
263
 
264
+ host = credentials['host']
265
+ private_key = credentials['private_key']
926
266
 
927
- async def _async_update_node_at_start(env_data, config_data, audience, descr):
928
- host = env_data.get("HOST", "")
929
- password = env_data.get("PASSWORD", "")
930
- network = env_data.get("NETWORK", "")
931
- synapse = env_data.get("SYNAPSE", "")
267
+ # 2. Confirmation
268
+ confirm = click.confirm(f"Are you sure you want to permanently delete connection to '{host}'?", default=False)
269
+ if not confirm:
270
+ click.echo("Deletion canceled.")
271
+ return
932
272
 
933
- node_id = config_data.get("app_metadata", [{}]).get("node_id", "")
273
+ # 3. Prepare Signed Message
274
+ timestamp = str(int(time.time()))
275
+ message = f"host={host};timestamp={timestamp}"
276
+ signature_b64 = sign_message(private_key, message.encode())
934
277
 
935
- try:
936
- with open("config.json", "r") as f:
937
- config_file_content = f.read()
938
- except Exception as e:
939
- click.echo(f"Error reading config.json content: {e}")
278
+ if not signature_b64:
940
279
  return
941
280
 
942
- url = f"https://{network}/api/update_node"
943
- node = {
944
- "nodeID": node_id,
281
+ # 4. Call API to Delete
282
+ click.echo(f"🗑️ Requesting deletion of cell '{host}'...")
283
+ url = f"{API_BASE_URL}/delete_cell"
284
+ payload = {
945
285
  "host": host,
946
- "password": password,
947
- "synapse": synapse,
948
- "node_type": audience,
949
- "config_file": config_file_content,
950
- "descr": descr,
286
+ "signed_message": signature_b64,
287
+ "message": message
951
288
  }
952
289
 
953
- async with aiohttp.ClientSession() as session:
954
- try:
955
- async with session.post(url, json=node) as response:
956
- response.raise_for_status()
957
- data = await response.json()
958
- updated_node_id = data.get("nodeID", node_id)
959
- click.echo(f"Neuronum Node '{updated_node_id}' updated!")
960
- except aiohttp.ClientError as e:
961
- click.echo(f"Error sending request: {e}")
962
-
963
-
964
- @click.command()
965
- def delete_node():
966
- asyncio.run(async_delete_node())
967
-
968
- async def async_delete_node():
969
- credentials_folder_path = Path.home() / ".neuronum"
970
- env_path = credentials_folder_path / ".env"
971
- env_data = {}
972
-
973
290
  try:
974
- with open(env_path, "r") as f:
975
- for line in f:
976
- key, value = line.strip().split("=")
977
- env_data[key] = value
978
-
979
- host = env_data.get("HOST", "")
980
- password = env_data.get("PASSWORD", "")
981
- network = env_data.get("NETWORK", "")
982
- synapse = env_data.get("SYNAPSE", "")
983
-
984
- with open('config.json', 'r') as f:
985
- data = json.load(f)
986
-
987
- nodeID = data['app_metadata']['node_id']
988
-
989
- except FileNotFoundError:
990
- click.echo("Error: .env with credentials not found")
991
- return
992
- except Exception as e:
993
- click.echo(f"Error reading .env file: {e}")
291
+ response = requests.delete(url, json=payload, timeout=10)
292
+ response.raise_for_status()
293
+ status = response.json().get("status", False)
294
+ except requests.exceptions.RequestException as e:
295
+ click.echo(f"❌ Error communicating with the server during deletion: {e}")
994
296
  return
995
297
 
996
- url = f"https://{network}/api/delete_node"
997
- node_payload = {
998
- "nodeID": nodeID,
999
- "host": host,
1000
- "password": password,
1001
- "synapse": synapse
1002
- }
1003
-
1004
- async with aiohttp.ClientSession() as session:
298
+ # 5. Cleanup Local Files
299
+ if status:
1005
300
  try:
1006
- async with session.post(url, json=node_payload) as response:
1007
- response.raise_for_status()
1008
- data = await response.json()
1009
- nodeID = data["nodeID"]
1010
- except aiohttp.ClientError as e:
1011
- click.echo(f"Error sending request: {e}")
1012
- return
1013
-
1014
- click.echo(f"Neuronum Node '{nodeID}' deleted!")
301
+ ENV_FILE.unlink(missing_ok=True)
302
+ PRIVATE_KEY_FILE.unlink(missing_ok=True)
303
+ PUBLIC_KEY_FILE.unlink(missing_ok=True)
304
+
305
+ click.echo(f"✅ Neuronum Cell '{host}' has been deleted and local credentials removed.")
306
+ except Exception as e:
307
+ click.echo(f"⚠️ Warning: Successfully deleted cell on server, but failed to clean up all local files: {e}")
308
+ else:
309
+ click.echo(f"Neuronum Cell '{host}' deletion failed on server.")
1015
310
 
1016
311
 
312
+ # --- CLI Registration ---
1017
313
  cli.add_command(create_cell)
1018
- cli.add_command(connect_cell)
1019
314
  cli.add_command(view_cell)
1020
- cli.add_command(disconnect_cell)
315
+ cli.add_command(connect_cell)
1021
316
  cli.add_command(delete_cell)
1022
- cli.add_command(init_node)
1023
- cli.add_command(update_node)
1024
- cli.add_command(start_node)
1025
- cli.add_command(restart_node)
1026
- cli.add_command(stop_node)
1027
- cli.add_command(check_node)
1028
- cli.add_command(delete_node)
1029
-
1030
317
 
1031
318
  if __name__ == "__main__":
1032
319
  cli()