neuronum 9.0.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,717 +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
- if not client_public_key:
377
- await node.tx_response(transmitter_id, response_data, encrypted=False)
378
- else:
379
- await node.tx_response(transmitter_id, response_data, client_public_key)
380
-
381
- asyncio.run(main())
382
- """)
61
+ return private_key, public_key, pem_private, pem_public
383
62
 
384
- html_path = project_path / "ping.html"
385
- html_content = f"""\
386
- <!DOCTYPE html>
387
- <html>
388
- <head>
389
- <style>
390
- body {{
391
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
392
- background-color: #121212;
393
- color: #e0e0e0;
394
- margin: 0;
395
- padding: 0;
396
- display: flex;
397
- justify-content: center;
398
- align-items: center;
399
- min-height: 100vh;
400
- }}
401
-
402
- .container {{
403
- background-color: #1e1e1e;
404
- border-radius: 12px;
405
- padding: 40px;
406
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
407
- width: 100%;
408
- max-width: 500px;
409
- text-align: center;
410
- box-sizing: border-box;
411
- }}
412
-
413
- .logo {{
414
- width: 80px;
415
- margin-bottom: 25px;
416
- filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.1));
417
- }}
418
-
419
- h1 {{
420
- font-size: 1.5em;
421
- font-weight: 600;
422
- margin-bottom: 5px;
423
- color: #f5f5f5;
424
- }}
425
-
426
- .subtitle {{
427
- font-size: 0.9em;
428
- color: #a0a0a0;
429
- margin-bottom: 30px;
430
- }}
431
-
432
- .data-row {{
433
- background-color: #2a2a2a;
434
- padding: 12px 15px;
435
- border-radius: 8px;
436
- margin-bottom: 10px;
437
- display: flex;
438
- justify-content: space-between;
439
- align-items: center;
440
- }}
441
-
442
- .data-label {{
443
- font-weight: 400;
444
- color: #a0a0a0;
445
- margin: 0;
446
- }}
447
-
448
- .data-value {{
449
- font-weight: 500;
450
- color: #e0e0e0;
451
- margin: 0;
452
- }}
453
-
454
- .data-value.truncated {{
455
- white-space: nowrap;
456
- overflow: hidden;
457
- text-overflow: ellipsis;
458
- max-width: 60%;
459
- }}
460
-
461
- .data-value.client {{
462
- color: #8cafff;
463
- }}
464
- .data-value.timestamp {{
465
- color: #a1e8a1;
466
- }}
467
- .data-value.transmitter-id {{
468
- color: #f7a2a2;
469
- }}
470
- .api-button {{
471
- background: #01c07d 100%;
472
- color: white;
473
- border: none;
474
- border-radius: 8px;
475
- padding: 12px 24px;
476
- font-size: 16px;
477
- font-weight: bold;
478
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
479
- cursor: pointer;
480
- margin-top: 10px;
481
- }}
482
- </style>
483
- </head>
484
- <body>
485
- <div class="container">
486
- <img class="logo" src="https://neuronum.net/static/logo.png" alt="Neuronum Logo">
487
-
488
- <h1>Reply from {node_id}</h1>
489
- <p class="subtitle">Pinged successfully.</p>
490
-
491
- <div class="data-row">
492
- <p class="data-label">Client</p>
493
- <p class="data-value client">{{{{client}}}}</p>
494
- </div>
495
-
496
- <div class="data-row">
497
- <p class="data-label">Timestamp</p>
498
- <p class="data-value timestamp">{{{{ts}}}}</p>
499
- </div>
500
-
501
- <div class="data-row">
502
- <p class="data-label">Data</p>
503
- <p class="data-value">{{{{data}}}}</p>
504
- </div>
505
-
506
- <div class="data-row">
507
- <p class="data-label">Transmitter ID</p>
508
- <p class="data-value transmitter-id truncated">{{{{transmitter_id}}}}</p>
509
- </div>
510
-
511
- <button id="send-request-btn" class="api-button">Ping again</button>
512
- </div>
513
-
514
- <script>
515
- document.getElementById('send-request-btn').addEventListener('click', () => {{
516
- const messagePayload = {{
517
- type: 'iframe_request',
518
- endpoint: 'https://neuronum.net/browser/api/activate_tx/{node_id}',
519
- data: {{ "action": "ping_node" }},
520
- nodePublicKey: '{pem_public_oneline}',
521
- }};
522
- if (window.parent) {{
523
- window.parent.postMessage(messagePayload, '*');
524
- }}
525
- }});
526
- </script>
527
-
528
- </body>
529
- </html>
530
- """
531
- html_path.write_text(html_content)
532
- config_path = project_path / "config.json"
533
- await asyncio.to_thread(
534
- config_path.write_text,
535
- f"""{{
536
- "app_metadata": {{
537
- "name": "{descr}",
538
- "version": "1.0.0",
539
- "author": "{host}",
540
- "audience": "private",
541
- "logo": "https://neuronum.net/static/logo.png",
542
- "node_id": "{node_id}"
543
- }},
544
- "legals": {{
545
- "terms": "https://url_to_your/terms",
546
- "privacy_policy": "https://url_to_your/privacy_policy"
547
- }},
548
- "public_key": "{pem_public_oneline}"
549
- }}"""
550
-
551
- )
552
- click.echo(f"Neuronum Node '{node_id}' initialized!")
553
-
554
-
555
- @click.command()
556
- @click.option('--d', is_flag=True, help="Start node in detached mode")
557
- def start_node(d):
558
- update_node_at_start()
559
- pid_file = Path.cwd() / "status.txt"
560
- system_name = platform.system()
561
- active_pids = []
562
-
563
- start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
564
-
565
- if pid_file.exists():
566
- try:
567
- with open(pid_file, "r") as f:
568
- pids = [int(line.strip()) for line in f if line.strip().isdigit()]
569
- for pid in pids:
570
- if system_name == "Windows":
571
- if psutil.pid_exists(pid):
572
- active_pids.append(pid)
573
- else:
574
- try:
575
- os.kill(pid, 0)
576
- active_pids.append(pid)
577
- except OSError:
578
- continue
579
- except Exception as e:
580
- click.echo(f"Failed to read PID file: {e}")
581
-
582
- if active_pids:
583
- click.echo(f"Node is already running. Active PIDs: {', '.join(map(str, active_pids))}")
584
- return
585
-
586
- click.echo("Starting Node...")
587
-
588
- project_path = Path.cwd()
589
- script_files = glob.glob("app.py")
590
- processes = []
591
-
592
- for script in script_files:
593
- script_path = project_path / script
594
- if script_path.exists():
595
-
596
- if d:
597
- process = subprocess.Popen(
598
- ["nohup", sys.executable, str(script_path), "&"] if system_name != "Windows"
599
- else ["pythonw", str(script_path)],
600
- stdout=subprocess.DEVNULL,
601
- stderr=subprocess.DEVNULL,
602
- start_new_session=True
603
- )
604
- else:
605
- process = subprocess.Popen(
606
- [sys.executable, str(script_path)]
607
- )
608
-
609
- processes.append(process.pid)
610
-
611
- if not processes:
612
- click.echo("Error: No valid node script found. Ensure the node is set up correctly.")
613
- return
614
-
615
- with open(pid_file, "w") as f:
616
- f.write(f"Started at: {start_time}\n")
617
- f.write("\n".join(map(str, processes)))
618
-
619
- click.echo(f"Node started successfully with PIDs: {', '.join(map(str, processes))}")
620
-
621
-
622
- @click.command()
623
- def check_node():
624
- click.echo("Checking Node status...")
625
-
626
- try:
627
- with open('config.json', 'r') as f:
628
- data = json.load(f)
629
-
630
- nodeID = data['app_metadata']['node_id']
631
-
632
- except FileNotFoundError:
633
- click.echo("Error: .env with credentials not found")
634
- return
635
63
  except Exception as e:
636
- click.echo(f"Error reading .env file: {e}")
637
- return
64
+ click.echo(f"Error generating keys from mnemonic: {e}")
65
+ return None, None, None, None
638
66
 
639
- pid_file = Path.cwd() / "status.txt"
640
-
641
- if not pid_file.exists():
642
- click.echo(f"Node {nodeID} is not running. Status file missing.")
643
- return
644
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."""
645
70
  try:
646
- with open(pid_file, "r") as f:
647
- lines = f.readlines()
648
- timestamp_line = next((line for line in lines if line.startswith("Started at:")), None)
649
- pids = [int(line.strip()) for line in lines if line.strip().isdigit()]
650
-
651
- if timestamp_line:
652
- click.echo(timestamp_line.strip())
653
- start_time = datetime.strptime(timestamp_line.split(":", 1)[1].strip(), "%Y-%m-%d %H:%M:%S")
654
- now = datetime.now()
655
- uptime = now - start_time
656
- 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
657
82
  except Exception as e:
658
- click.echo(f"Failed to read PID file: {e}")
659
- return
83
+ click.echo(f" Error saving credentials: {e}")
84
+ return False
660
85
 
661
- system_name = platform.system()
662
- 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
663
94
 
664
- for pid in pids:
665
- if system_name == "Windows":
666
- if psutil.pid_exists(pid):
667
- running_pids.append(pid)
668
- else:
669
- try:
670
- os.kill(pid, 0)
671
- running_pids.append(pid)
672
- except OSError:
673
- 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('"')
674
102
 
675
- if running_pids:
676
- click.echo(f"Node {nodeID} is running. Active PIDs: {', '.join(map(str, running_pids))}")
677
- else:
678
- 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()
679
115
 
116
+ return credentials
680
117
 
681
- @click.command()
682
- @click.option('--d', is_flag=True, help="Restart node in detached mode")
683
- def restart_node(d):
684
- update_node_at_start()
685
- pid_file = Path.cwd() / "status.txt"
686
- system_name = platform.system()
687
-
688
- start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
689
-
690
- try:
691
- with open('config.json', 'r') as f:
692
- data = json.load(f)
693
-
694
- nodeID = data['app_metadata']['node_id']
695
-
696
118
  except FileNotFoundError:
697
- print("Error: .env with credentials not found")
698
- return
119
+ click.echo("Error: Credentials files are incomplete. Try deleting the '.neuronum' folder or reconnecting.")
120
+ return None
699
121
  except Exception as e:
700
- print(f"Error reading .env file: {e}")
701
- return
702
-
703
- if pid_file.exists():
704
- try:
705
- with open(pid_file, "r") as f:
706
- pids = [int(line.strip()) for line in f if line.strip().isdigit()]
707
-
708
- for pid in pids:
709
- if system_name == "Windows":
710
- if psutil.pid_exists(pid):
711
- proc = psutil.Process(pid)
712
- proc.terminate()
713
- else:
714
- try:
715
- os.kill(pid, 15)
716
- except OSError:
717
- continue
718
-
719
- pid_file.unlink()
720
-
721
- click.echo(f"Terminated existing {nodeID} processes: {', '.join(map(str, pids))}")
722
-
723
- except Exception as e:
724
- click.echo(f"Failed to terminate processes: {e}")
725
- return
726
- else:
727
- click.echo(f"Node {nodeID} is not running")
728
-
729
- click.echo(f"Starting Node {nodeID}...")
730
- project_path = Path.cwd()
731
- script_files = glob.glob("app.py")
732
- processes = []
733
-
734
- for script in script_files:
735
- script_path = project_path / script
736
- if script_path.exists():
737
- if d:
738
- process = subprocess.Popen(
739
- ["nohup", sys.executable, str(script_path), "&"] if system_name != "Windows"
740
- else ["pythonw", str(script_path)],
741
- stdout=subprocess.DEVNULL,
742
- stderr=subprocess.DEVNULL,
743
- start_new_session=True
744
- )
745
- else:
746
- process = subprocess.Popen(
747
- [sys.executable, str(script_path)]
748
- )
749
-
750
- processes.append(process.pid)
751
-
752
- if not processes:
753
- click.echo("Error: No valid node script found.")
754
- return
122
+ click.echo(f"Error loading credentials: {e}")
123
+ return None
755
124
 
756
- with open(pid_file, "w") as f:
757
- f.write(f"Started at: {start_time}\n")
758
- f.write("\n".join(map(str, processes)))
125
+ # --- CLI Group ---
759
126
 
760
- 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
761
131
 
132
+ # --- CLI Commands ---
762
133
 
763
134
  @click.command()
764
- def stop_node():
765
- 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)
766
141
 
767
- async def async_stop_node():
768
- click.echo("Stopping Node...")
142
+ if not private_key:
143
+ return
769
144
 
770
- 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")}
771
149
 
772
150
  try:
773
- with open('config.json', 'r') as f:
774
- data = json.load(f)
775
-
776
- 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")
777
154
 
778
- except FileNotFoundError:
779
- print("Error: .env with credentials not found")
780
- return
781
- except Exception as e:
782
- print(f"Error reading .env file: {e}")
155
+ except requests.exceptions.RequestException as e:
156
+ click.echo(f"Error communicating with the server: {e}")
783
157
  return
784
158
 
785
- try:
786
- with open("status.txt", "r") as f:
787
- pids = [int(line.strip()) for line in f if line.strip().isdigit()]
788
-
789
- system_name = platform.system()
790
-
791
- for pid in pids:
792
- try:
793
- if system_name == "Windows":
794
- await asyncio.to_thread(subprocess.run, ["taskkill", "/F", "/PID", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
795
- else:
796
- await asyncio.to_thread(os.kill, pid, 9)
797
- except ProcessLookupError:
798
- click.echo(f"Warning: Process {pid} already stopped or does not exist.")
799
-
800
- await asyncio.to_thread(os.remove, node_pid_path)
801
- click.echo(f"Node {nodeID} stopped successfully!")
802
-
803
- except FileNotFoundError:
804
- click.echo("Error: No active node process found.")
805
- except subprocess.CalledProcessError:
806
- 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.")
807
175
 
808
176
 
809
177
  @click.command()
810
- def update_node():
811
- click.echo("Update your Node")
812
- try:
813
- env_path = Path.home() / ".neuronum" / ".env"
814
- env_data = {}
815
- with open(env_path, "r") as f:
816
- for line in f:
817
- if "=" in line:
818
- key, value = line.strip().split("=", 1)
819
- env_data[key] = value
820
-
821
- host = env_data.get("HOST", "")
822
-
823
- with open("config.json", "r") as f:
824
- config_data = json.load(f)
825
-
826
- audience = config_data.get("app_metadata", {}).get("audience", "")
827
- descr = config_data.get("app_metadata", {}).get("name", "")
178
+ def connect_cell():
179
+ """Connects to an existing Community Cell using a 12-word mnemonic."""
828
180
 
829
- if host.startswith("CMTY_") and audience != "private":
830
- raise click.ClickException(
831
- 'Community Cells can only create private Nodes. Set audience to "private".'
832
- )
833
- if descr and len(descr) > 25:
834
- raise click.ClickException(
835
- 'Description too long. Max 25 characters allowed.'
836
- )
181
+ # 1. Get and Validate Mnemonic
182
+ mnemonic = questionary.text("Enter your 12-word BIP-39 mnemonic (space separated):").ask()
837
183
 
838
- except FileNotFoundError as e:
839
- click.echo(f"Error: File not found - {e.filename}")
840
- return
841
- except click.ClickException as e:
842
- click.echo(e.format_message())
843
- return
844
- except Exception as e:
845
- click.echo(f"Error reading files: {e}")
184
+ if not mnemonic:
185
+ click.echo("Connection canceled.")
846
186
  return
847
187
 
848
- asyncio.run(async_update_node(env_data, config_data, audience, descr))
188
+ mnemonic = " ".join(mnemonic.strip().split())
189
+ words = mnemonic.split()
849
190
 
191
+ if len(words) != 12:
192
+ click.echo("❌ Mnemonic must be exactly 12 words.")
193
+ return
850
194
 
851
- async def async_update_node(env_data, config_data, audience: str, descr: str):
852
- try:
853
- host = env_data.get("HOST", "")
854
- password = env_data.get("PASSWORD", "")
855
- network = env_data.get("NETWORK", "")
856
- synapse = env_data.get("SYNAPSE", "")
857
-
858
- node_id = config_data.get("app_metadata", [{}]).get("node_id", "")
859
-
860
- with open("config.json", "r") as f:
861
- 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
862
198
 
863
- except FileNotFoundError:
864
- 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:
865
202
  return
866
- except Exception as e:
867
- 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:
868
211
  return
869
212
 
870
- url = f"https://{network}/api/update_node"
871
- node = {
872
- "nodeID": node_id,
873
- "host": host,
874
- "password": password,
875
- "synapse": synapse,
876
- "node_type": audience,
877
- "config_file": config_file_content,
878
- "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
879
220
  }
880
221
 
881
- async with aiohttp.ClientSession() as session:
882
- try:
883
- async with session.post(url, json=node) as response:
884
- response.raise_for_status()
885
- data = await response.json()
886
- updated_node_id = data.get("nodeID", node_id)
887
- click.echo(f"Neuronum Node '{updated_node_id}' updated!")
888
- except aiohttp.ClientError as e:
889
- click.echo(f"Error sending request: {e}")
890
-
891
-
892
-
893
- def update_node_at_start():
894
- click.echo("Update your Node")
895
222
  try:
896
- env_path = Path.home() / ".neuronum" / ".env"
897
- env_data = {}
898
- with open(env_path, "r") as f:
899
- for line in f:
900
- if "=" in line:
901
- key, value = line.strip().split("=", 1)
902
- 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
903
229
 
904
- with open("config.json", "r") as f:
905
- 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.")
906
237
 
907
- host = env_data.get("HOST", "")
908
- audience = config_data.get("app_metadata", {}).get("audience", "")
909
- descr = config_data.get("app_metadata", {}).get("name", "")
910
238
 
911
- if host.startswith("CMTY_") and audience != "private":
912
- raise click.ClickException(
913
- 'Community Cells can only start private Nodes. Node starting "privately".'
914
- )
915
- if descr and len(descr) > 25:
916
- raise click.ClickException(
917
- 'Description too long. Max 25 characters allowed.'
918
- )
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("----------------------------")
919
252
 
920
- asyncio.run(_async_update_node_at_start(env_data, config_data, audience, descr))
921
253
 
922
- except FileNotFoundError as e:
923
- click.echo(f"Error: File not found - {e.filename}")
924
- except click.ClickException as e:
925
- click.echo(e.format_message())
926
- except Exception as e:
927
- 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
928
263
 
264
+ host = credentials['host']
265
+ private_key = credentials['private_key']
929
266
 
930
- async def _async_update_node_at_start(env_data, config_data, audience, descr):
931
- host = env_data.get("HOST", "")
932
- password = env_data.get("PASSWORD", "")
933
- network = env_data.get("NETWORK", "")
934
- 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
935
272
 
936
- 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())
937
277
 
938
- try:
939
- with open("config.json", "r") as f:
940
- config_file_content = f.read()
941
- except Exception as e:
942
- click.echo(f"Error reading config.json content: {e}")
278
+ if not signature_b64:
943
279
  return
944
280
 
945
- url = f"https://{network}/api/update_node"
946
- node = {
947
- "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 = {
948
285
  "host": host,
949
- "password": password,
950
- "synapse": synapse,
951
- "node_type": audience,
952
- "config_file": config_file_content,
953
- "descr": descr,
286
+ "signed_message": signature_b64,
287
+ "message": message
954
288
  }
955
289
 
956
- async with aiohttp.ClientSession() as session:
957
- try:
958
- async with session.post(url, json=node) as response:
959
- response.raise_for_status()
960
- data = await response.json()
961
- updated_node_id = data.get("nodeID", node_id)
962
- click.echo(f"Neuronum Node '{updated_node_id}' updated!")
963
- except aiohttp.ClientError as e:
964
- click.echo(f"Error sending request: {e}")
965
-
966
-
967
- @click.command()
968
- def delete_node():
969
- asyncio.run(async_delete_node())
970
-
971
- async def async_delete_node():
972
- credentials_folder_path = Path.home() / ".neuronum"
973
- env_path = credentials_folder_path / ".env"
974
- env_data = {}
975
-
976
290
  try:
977
- with open(env_path, "r") as f:
978
- for line in f:
979
- key, value = line.strip().split("=")
980
- env_data[key] = value
981
-
982
- host = env_data.get("HOST", "")
983
- password = env_data.get("PASSWORD", "")
984
- network = env_data.get("NETWORK", "")
985
- synapse = env_data.get("SYNAPSE", "")
986
-
987
- with open('config.json', 'r') as f:
988
- data = json.load(f)
989
-
990
- nodeID = data['app_metadata']['node_id']
991
-
992
- except FileNotFoundError:
993
- click.echo("Error: .env with credentials not found")
994
- return
995
- except Exception as e:
996
- 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}")
997
296
  return
998
297
 
999
- url = f"https://{network}/api/delete_node"
1000
- node_payload = {
1001
- "nodeID": nodeID,
1002
- "host": host,
1003
- "password": password,
1004
- "synapse": synapse
1005
- }
1006
-
1007
- async with aiohttp.ClientSession() as session:
298
+ # 5. Cleanup Local Files
299
+ if status:
1008
300
  try:
1009
- async with session.post(url, json=node_payload) as response:
1010
- response.raise_for_status()
1011
- data = await response.json()
1012
- nodeID = data["nodeID"]
1013
- except aiohttp.ClientError as e:
1014
- click.echo(f"Error sending request: {e}")
1015
- return
1016
-
1017
- 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.")
1018
310
 
1019
311
 
312
+ # --- CLI Registration ---
1020
313
  cli.add_command(create_cell)
1021
- cli.add_command(connect_cell)
1022
314
  cli.add_command(view_cell)
1023
- cli.add_command(disconnect_cell)
315
+ cli.add_command(connect_cell)
1024
316
  cli.add_command(delete_cell)
1025
- cli.add_command(init_node)
1026
- cli.add_command(update_node)
1027
- cli.add_command(start_node)
1028
- cli.add_command(restart_node)
1029
- cli.add_command(stop_node)
1030
- cli.add_command(check_node)
1031
- cli.add_command(delete_node)
1032
-
1033
317
 
1034
318
  if __name__ == "__main__":
1035
319
  cli()