akashcli 4.6.3__tar.gz → 4.6.4__tar.gz

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.
Files changed (33) hide show
  1. {akashcli-4.6.3 → akashcli-4.6.4}/PKG-INFO +2 -3
  2. akashcli-4.6.4/akashcli/__init__.py +7 -0
  3. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/_net.py +2 -0
  4. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/cli.py +48 -36
  5. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/exec.py +31 -23
  6. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/relay.py +70 -83
  7. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/restore.py +144 -56
  8. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/terminal.py +150 -22
  9. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/uploader.py +36 -0
  10. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/PKG-INFO +2 -3
  11. {akashcli-4.6.3 → akashcli-4.6.4}/pyproject.toml +7 -3
  12. akashcli-4.6.3/akashcli/__init__.py +0 -6
  13. {akashcli-4.6.3 → akashcli-4.6.4}/README.md +0 -0
  14. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/auth.py +0 -0
  15. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/client.py +0 -0
  16. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/config.py +0 -0
  17. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/crypto.py +0 -0
  18. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/env_manager.py +0 -0
  19. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/exceptions.py +0 -0
  20. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/integrity.py +0 -0
  21. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/logger.py +0 -0
  22. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/models.py +0 -0
  23. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/p2p.py +0 -0
  24. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/packages.py +0 -0
  25. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/snapshot_crypto.py +0 -0
  26. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/storage.py +0 -0
  27. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/utils.py +0 -0
  28. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/SOURCES.txt +0 -0
  29. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/dependency_links.txt +0 -0
  30. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/entry_points.txt +0 -0
  31. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/requires.txt +0 -0
  32. {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/top_level.txt +0 -0
  33. {akashcli-4.6.3 → akashcli-4.6.4}/setup.cfg +0 -0
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.6.3
3
+ Version: 4.6.4
4
4
  Summary: Akash CLI — secure remote shell relay, file vault, and device management
5
- License: MIT
5
+ License-Expression: MIT
6
6
  Keywords: akash,cli,relay,remote-shell,vault
7
7
  Classifier: Programming Language :: Python :: 3
8
- Classifier: License :: OSI Approved :: MIT License
9
8
  Classifier: Operating System :: OS Independent
10
9
  Classifier: Environment :: Console
11
10
  Classifier: Topic :: Utilities
@@ -0,0 +1,7 @@
1
+ try:
2
+ from importlib.metadata import version
3
+ __version__ = version("akashcli")
4
+ except Exception:
5
+ __version__ = "4.6.4"
6
+
7
+ AKASH_VERSION = __version__
@@ -112,6 +112,8 @@ def ws_connect(url: str, **kwargs):
112
112
 
113
113
  sock_timeout = kwargs.pop("_sock_timeout", 15)
114
114
  sock = make_socket(host, port, timeout=sock_timeout)
115
+ # Remove socket timeout so the WebSocket connection remains open permanently
116
+ sock.settimeout(None)
115
117
 
116
118
  # legacy=True: allow calling connect() outside of a `with` block.
117
119
  # websockets ≥ 13 requires context manager usage by default;
@@ -141,6 +141,13 @@ def _upload_with_progress(client: AkashClient, tmp_path: str, password=None) ->
141
141
  # `app` — primary commands (backup / restore / pull / run / shell …)
142
142
  # `cloud_app` — file management (akash cloud upload/files/stream/delete/manage/bulk)
143
143
 
144
+
145
+ def _version_callback(value: bool):
146
+ if value:
147
+ from . import __version__
148
+ print("akashcli v" + __version__)
149
+ raise typer.Exit(0)
150
+
144
151
  app = typer.Typer(
145
152
  invoke_without_command=True,
146
153
  rich_markup_mode=None,
@@ -150,6 +157,9 @@ app = typer.Typer(
150
157
 
151
158
  @app.callback(invoke_without_command=True)
152
159
  def _main(ctx: typer.Context,
160
+ version: bool = typer.Option(False, "--version", "-v", is_eager=True, expose_value=False,
161
+ callback=lambda v: (print("akashcli v1.5.0"), raise_exit()) if v else None,
162
+ help="Show version and exit."),
153
163
  help: bool = typer.Option(False, "--help", "-h", is_eager=True, expose_value=False,
154
164
  callback=lambda v: (_print_help(), raise_exit()) if v else None,
155
165
  help="Show this message and exit.")):
@@ -256,6 +266,24 @@ def login(
256
266
 
257
267
  # ── akash whoami ─────────────────────────────────────────────────────────────
258
268
 
269
+
270
+ @app.command()
271
+ def version(url: Optional[str] = typer.Option(None, "--url", help="Server URL to check")):
272
+ """Show akash CLI version and connected server version."""
273
+ from . import __version__
274
+ ver_tag = "v" + __version__
275
+ print(f"\n {_C}◈{_RS} {_W}akashcli{_RS} {_cy(ver_tag)}")
276
+ try:
277
+ _, base = _resolve_client(url, require_key=False)
278
+ resp = httpx.get(f"{base}/health", timeout=3.0)
279
+ if resp.status_code == 200:
280
+ s_ver = resp.json().get("version", "unknown")
281
+ srv_tag = "(" + s_ver + ")"
282
+ print(f" {_DM}server {_RS}{_cy(base)} {_dim(srv_tag)}")
283
+ except Exception:
284
+ pass
285
+ print()
286
+
259
287
  @app.command()
260
288
  def whoami():
261
289
  """Show current account and storage usage."""
@@ -842,41 +870,10 @@ def pull(
842
870
  except KeyError:
843
871
  pass
844
872
 
845
- for member in tar.getmembers():
846
- name = member.name
847
- if name in ("MANIFEST.json", "manifest_meta.json", "akash.pkgs") or not member.isfile():
848
- continue
849
-
850
- dest = None
851
- if name.startswith("home/"):
852
- dest = home / name[len("home/"):]
853
- elif name.startswith("abs/"):
854
- dest = Path(name[len("abs"):])
855
- elif name.startswith("extra/"):
856
- dest = home / name[len("extra/"):]
857
- else:
858
- skipped_list.append(name)
859
- continue
860
-
861
- if dry_run:
862
- restored_list.append(str(dest))
863
- continue
864
-
865
- try:
866
- dest.parent.mkdir(parents=True, exist_ok=True)
867
- file_data = tar.extractfile(member)
868
- if file_data is None:
869
- skipped_list.append(str(dest))
870
- continue
871
- dest.write_bytes(file_data.read())
872
- try:
873
- os.chmod(dest, member.mode)
874
- except OSError:
875
- pass
876
- restored_list.append(str(dest))
877
- except (PermissionError, OSError) as e:
878
- skipped_list.append(f"{dest} ({e})")
879
-
873
+ from .restore import validate_and_extract_members
874
+ restored_list, skipped_list = validate_and_extract_members(
875
+ tar, target_home=home, dry_run=dry_run
876
+ )
880
877
  except Exception as e:
881
878
  _err(f"extraction failed: {e}")
882
879
  raise typer.Exit(1)
@@ -1250,7 +1247,8 @@ def shell(
1250
1247
 
1251
1248
  @app.command()
1252
1249
  def devices(
1253
- revoke: Optional[str] = typer.Option(None, "--revoke", "-r", help="Revoke (unenroll) a device by label"),
1250
+ revoke: Optional[str] = typer.Option(None, "--revoke", "-r", help="Revoke (unenroll) a device by label"),
1251
+ authorize: Optional[str] = typer.Option(None, "--authorize", "-a", help="Grant server execution access to device"),
1254
1252
  raw: bool = typer.Option(False, "--json", help="Raw JSON output"),
1255
1253
  ):
1256
1254
  """List enrolled devices — or revoke one.
@@ -1292,6 +1290,20 @@ def devices(
1292
1290
 
1293
1291
  _hdrs = {"x-api-key": api_key, "Origin": "https://akash.cli"}
1294
1292
 
1293
+ if authorize:
1294
+ try:
1295
+ import httpx as _hx
1296
+ resp = _hx.post(
1297
+ f"{base}/api/exec/devices/{authorize}/authorize",
1298
+ headers=_hdrs, timeout=10,
1299
+ )
1300
+ resp.raise_for_status()
1301
+ _ok(f"authorized {_ye(authorize)} for server execution")
1302
+ except Exception as e:
1303
+ _err(f"authorize failed: {e}")
1304
+ raise typer.Exit(1)
1305
+ return
1306
+
1295
1307
  if revoke:
1296
1308
  try:
1297
1309
  import httpx as _hx
@@ -93,10 +93,21 @@ def _get_machine_key() -> bytes:
93
93
  return key
94
94
 
95
95
 
96
+ def _get_ed25519_key():
97
+ from cryptography.hazmat.primitives.asymmetric import ed25519 as _ed25519
98
+ raw = _get_machine_key()
99
+ return _ed25519.Ed25519PrivateKey.from_private_bytes(raw)
100
+
101
+ def _get_public_key_b64() -> str:
102
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
103
+ pub = _get_ed25519_key().public_key()
104
+ raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
105
+ return base64.b64encode(raw).decode("utf-8")
106
+
96
107
  def _sign_challenge(challenge: str) -> str:
97
- key = _get_machine_key()
98
- sig = _hmac.new(key, challenge.encode(), hashlib.sha256).digest()
99
- return base64.b64encode(sig).decode()
108
+ priv = _get_ed25519_key()
109
+ sig = priv.sign(challenge.encode("utf-8"))
110
+ return base64.b64encode(sig).decode("utf-8")
100
111
 
101
112
 
102
113
  def _device_id() -> str:
@@ -235,14 +246,6 @@ def _session_info() -> dict:
235
246
  # ── Authentication ────────────────────────────────────────────────────────────
236
247
 
237
248
  def acquire_exec_token(api_key: str, base_url: str, controller: bool = False) -> str:
238
- """
239
- Obtain a short-lived exec JWT.
240
-
241
- controller=True — skip device fingerprint enrollment (used by relay
242
- connect / run / shell). Just authenticates as owner.
243
- controller=False — full challenge/sign/enroll flow (used by akash serve).
244
- """
245
- # Patch socket DNS for this host — bypasses Android stale DNS cache
246
249
  from urllib.parse import urlparse as _up
247
250
  _patch_dns(_up(base_url).hostname or "")
248
251
 
@@ -251,22 +254,27 @@ def acquire_exec_token(api_key: str, base_url: str, controller: bool = False) ->
251
254
  return token
252
255
 
253
256
  with httpx.Client(base_url=base_url, timeout=10) as client:
254
- if controller:
255
- # Controller path: no device fingerprint needed — just owner auth.
256
- return _auth_key_only(client, api_key, base_url)
257
-
258
- ch = client.post("/api/exec/challenge",
259
- headers={"x-api-key": api_key, "Origin": "https://akash.cli"})
260
- if ch.status_code == 404:
261
- return _auth_key_only(client, api_key, base_url)
257
+ ch = client.post(
258
+ "/api/exec/challenge",
259
+ headers={"x-api-key": api_key, "Origin": "https://akash.cli"}
260
+ )
262
261
  ch.raise_for_status()
263
262
  nonce = ch.json()["nonce"]
264
263
 
265
264
  sig = _sign_challenge(nonce)
266
- ar = client.post("/api/exec/auth",
267
- headers={"x-api-key": api_key, "Origin": "https://akash.cli"},
268
- json={"nonce": nonce, "signature": sig,
269
- "device_id": _device_id(), "label": _device_label()})
265
+ pub_key = _get_public_key_b64()
266
+ label = _device_label()
267
+
268
+ ar = client.post(
269
+ "/api/exec/auth",
270
+ headers={"x-api-key": api_key, "Origin": "https://akash.cli"},
271
+ json={
272
+ "nonce": nonce,
273
+ "signature": sig,
274
+ "public_key": pub_key,
275
+ "label": label,
276
+ }
277
+ )
270
278
  if ar.status_code == 403:
271
279
  raise PermissionError(ar.json().get("detail", "Access denied."))
272
280
  ar.raise_for_status()
@@ -25,6 +25,8 @@ Wire protocol (all JSON frames over the relay WebSocket):
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
+ AKASH_VERSION = "4.6.4"
29
+
28
30
  import base64
29
31
  import json
30
32
  import os
@@ -36,6 +38,7 @@ from typing import Optional
36
38
 
37
39
  # ── ANSI (matches cli.py palette) ────────────────────────────────────────────
38
40
  _C = "\033[1;36m"
41
+ _W = "\033[1;37m"
39
42
  _G = "\033[1;32m"
40
43
  _R = "\033[1;31m"
41
44
  _Y = "\033[1;33m"
@@ -163,14 +166,31 @@ def _run_relay_device(ws, label: str) -> None:
163
166
  continue
164
167
 
165
168
 
169
+ def _clean_display_cmd(cmd_str: str) -> str:
170
+ """Extracts human-readable command, stripping shell wrapper sentinels."""
171
+ lines = [
172
+ line.strip() for line in cmd_str.strip().splitlines()
173
+ if line.strip() and not line.strip().startswith("cd ") and "__EC__" not in line and "__CWD__" not in line
174
+ ]
175
+ if lines:
176
+ clean = " ".join(lines)
177
+ return clean if len(clean) <= 60 else clean[:57] + "…"
178
+ return cmd_str[:50]
179
+
166
180
  def _handle_run(ws, frame: dict) -> None:
167
- """Execute a non-interactive command, stream output in real-time, send cwd."""
181
+ """Execute a non-interactive command, stream output in real-time with live host logging."""
168
182
  import subprocess as _sp
169
183
  import select as _sel
170
184
 
171
185
  command = frame.get("command", "")
172
- cwd = frame.get("cwd", os.path.expanduser("~"))
186
+ raw_cwd = frame.get("cwd", "~")
187
+ cwd = os.path.expanduser(raw_cwd) if os.path.isdir(os.path.expanduser(raw_cwd)) else os.path.expanduser("~")
173
188
  env = {**os.environ, "TERM": "dumb"}
189
+ disp_cmd = _clean_display_cmd(command)
190
+
191
+ SPIN_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
192
+ spin_idx = 0
193
+ t0 = time.monotonic()
174
194
 
175
195
  try:
176
196
  proc = _sp.Popen(
@@ -180,9 +200,16 @@ def _handle_run(ws, frame: dict) -> None:
180
200
  env=env,
181
201
  )
182
202
  fd = proc.stdout.fileno()
183
- # Non-blocking read: send chunks as soon as they arrive
203
+
184
204
  while True:
185
- r, _, _ = _sel.select([fd], [], [], 0.02)
205
+ r, _, _ = _sel.select([fd], [], [], 0.06)
206
+
207
+ # Live host animation
208
+ f = SPIN_FRAMES[spin_idx % len(SPIN_FRAMES)]
209
+ spin_idx += 1
210
+ sys.stdout.write(f"\r {_C}{f}{_RS} {_DM}executing:{_RS} {_W}{disp_cmd}{_RS} ")
211
+ sys.stdout.flush()
212
+
186
213
  if r:
187
214
  chunk = os.read(fd, 16384)
188
215
  if not chunk:
@@ -192,7 +219,6 @@ def _handle_run(ws, frame: dict) -> None:
192
219
  "data": base64.b64encode(chunk).decode(),
193
220
  }))
194
221
  elif proc.poll() is not None:
195
- # Drain any remaining bytes
196
222
  try:
197
223
  while True:
198
224
  r2, _, _ = _sel.select([fd], [], [], 0)
@@ -209,20 +235,25 @@ def _handle_run(ws, frame: dict) -> None:
209
235
  pass
210
236
  break
211
237
  proc.wait()
212
- # Resolve final cwd: try to read it from the shell
213
- try:
214
- result = _sp.run(
215
- ["sh", "-c", f"cd {json.dumps(cwd)} 2>/dev/null; true"],
216
- capture_output=True, timeout=1,
217
- )
218
- except Exception:
219
- pass
238
+
239
+ elapsed_ms = (time.monotonic() - t0) * 1000
240
+ ec = proc.returncode or 0
241
+ sys.stdout.write("\r\033[2K")
242
+ if ec == 0:
243
+ print(f" {_G}✓{_RS} {_W}{disp_cmd}{_RS} {_G}[0]{_RS} {_DM}{elapsed_ms:.1f}ms{_RS}")
244
+ else:
245
+ print(f" {_R}✗{_RS} {_W}{disp_cmd}{_RS} {_R}[{ec}]{_RS} {_DM}{elapsed_ms:.1f}ms{_RS}")
246
+ sys.stdout.flush()
247
+
220
248
  ws.send(json.dumps({
221
249
  "type": "exit",
222
- "code": proc.returncode or 0,
250
+ "code": ec,
223
251
  "cwd": cwd,
224
252
  }))
225
253
  except Exception as e:
254
+ sys.stdout.write("\r\033[2K")
255
+ print(f" {_R}✗{_RS} {_W}{disp_cmd}{_RS} {_R}[error]{_RS} {_DM}{e}{_RS}")
256
+ sys.stdout.flush()
226
257
  try:
227
258
  ws.send(json.dumps({"type": "error", "message": str(e)}))
228
259
  ws.send(json.dumps({"type": "exit", "code": 1, "cwd": cwd}))
@@ -652,10 +683,10 @@ def _guest_code_url(base: str) -> str:
652
683
  return f"{base.rstrip('/')}/api/guest/relay/code"
653
684
 
654
685
  def _guest_host_url(base: str, code: str) -> str:
655
- return f"{_ws_base(base)}/api/guest/relay/host?code={code}"
686
+ return f"{_ws_base(base)}/api/guest/relay/host?code={code}&version={AKASH_VERSION}"
656
687
 
657
688
  def _guest_join_url(base: str, code: str) -> str:
658
- return f"{_ws_base(base)}/api/guest/relay/join?code={code}"
689
+ return f"{_ws_base(base)}/api/guest/relay/join?code={code}&version={AKASH_VERSION}"
659
690
 
660
691
 
661
692
  def guest_serve(base_url: str) -> None:
@@ -703,7 +734,7 @@ def guest_serve(base_url: str) -> None:
703
734
  # Wait for either a knock (someone wants in) or a ping
704
735
  while True:
705
736
  try:
706
- raw = ws.recv(timeout=35)
737
+ raw = ws.recv()
707
738
  except Exception:
708
739
  # Timeout or disconnect — break to reconnect loop
709
740
  break
@@ -983,7 +1014,7 @@ def guest_connect_shell(base_url: str, code: str) -> int:
983
1014
  print(f" {_DM}fingerprint {_C}{fp}{_RS}", flush=True)
984
1015
  print(f" {_DM}host has 30 s to respond{_RS}\n", flush=True)
985
1016
  try:
986
- second = json.loads(ws.recv(timeout=35))
1017
+ second = json.loads(ws.recv())
987
1018
  except Exception:
988
1019
  ws.close()
989
1020
  raise RuntimeError("Timed out waiting for host approval.")
@@ -1022,7 +1053,7 @@ def guest_connect_shell(base_url: str, code: str) -> int:
1022
1053
  def _recv_worker():
1023
1054
  while not _stop_recv.is_set():
1024
1055
  try:
1025
- raw = ws.recv(timeout=1.0)
1056
+ raw = ws.recv()
1026
1057
  _q_frames.put(raw)
1027
1058
  except Exception as exc:
1028
1059
  if not _stop_recv.is_set():
@@ -1168,33 +1199,22 @@ def guest_connect_shell(base_url: str, code: str) -> int:
1168
1199
 
1169
1200
  # ── REPL command runner ───────────────────────────────────────────────────
1170
1201
  def _run_cmd(cmd: str) -> tuple:
1171
- """
1172
- Send `run` frame, collect stdout in real-time, return (lines, ec, new_cwd).
1173
- Output is printed live as │ lines while collecting for the footer.
1174
- """
1175
- # Wrap: cd to cwd, run command, emit sentinels
1176
- actual = (
1177
- f'cd {json.dumps(cwd)} 2>/dev/null\n'
1178
- f'{cmd}\n'
1179
- f'printf "\\x00__EC__%d\\x00" "$?"\n'
1180
- f'printf "\\x00__CWD__%s\\x00" "$PWD"\n'
1181
- )
1202
+ """Send command cleanly and stream uncorrupted stdout lines in real-time."""
1182
1203
  try:
1183
1204
  ws.send(json.dumps({
1184
1205
  "type": "run",
1185
- "command": actual,
1206
+ "command": cmd,
1186
1207
  "cwd": cwd,
1187
1208
  }))
1188
1209
  except Exception:
1189
1210
  return [], 1, cwd
1190
1211
 
1191
- cols = _term_cols()
1192
- buf = b""
1193
- lines : list[str] = []
1194
- ec = 0
1195
- new_cwd = cwd
1196
- deadline = time.time() + 60.0
1197
- started = False
1212
+ cols = _term_cols()
1213
+ buf = b""
1214
+ lines : list[str] = []
1215
+ ec = 0
1216
+ new_cwd = cwd
1217
+ deadline = time.time() + 60.0
1198
1218
 
1199
1219
  while time.time() < deadline:
1200
1220
  try:
@@ -1209,51 +1229,22 @@ def guest_connect_shell(base_url: str, code: str) -> int:
1209
1229
  continue
1210
1230
  ft = frame.get("type")
1211
1231
 
1212
- if ft == "stdout":
1232
+ if ft in ("stdout", "stderr"):
1213
1233
  chunk = base64.b64decode(frame.get("data", ""))
1214
1234
  buf += chunk
1215
- # Print lines as they arrive — real-time streaming
1216
1235
  while b"\n" in buf:
1217
1236
  line_b, buf = buf.split(b"\n", 1)
1218
- line = _strip_ansi(line_b.decode("utf-8", errors="replace"))
1219
- # Parse null-delimited sentinels
1220
- if "\x00__EC__" in line:
1221
- import re as _re
1222
- m = _re.search(r'\x00__EC__(\d+)\x00', line)
1223
- if m:
1224
- ec = int(m.group(1))
1225
- continue
1226
- if "\x00__CWD__" in line:
1227
- import re as _re
1228
- m = _re.search(r'\x00__CWD__([^\x00]+)\x00', line)
1229
- if m:
1230
- new_cwd = m.group(1).strip()
1231
- continue
1232
- if not started:
1233
- # Print blank separator before first output line
1234
- sys.stdout.write("\n")
1235
- started = True
1236
- # Print live
1237
- display = line.rstrip()
1238
- # wrap
1239
- wrap = max(cols - 6, 40)
1240
- while len(display) > wrap:
1241
- sys.stdout.write(f" {_SH_GR}│{_SH_RS} {display[:wrap]}\n")
1242
- display = display[wrap:]
1243
- sys.stdout.write(f" {_SH_GR}│{_SH_RS} {display}\n")
1237
+ line = _strip_ansi(line_b.decode("utf-8", errors="replace")).rstrip()
1238
+ sys.stdout.write(f" {_SH_GR}│{_SH_RS} {line}\n")
1244
1239
  sys.stdout.flush()
1245
- lines.append(line.rstrip())
1240
+ lines.append(line)
1246
1241
 
1247
1242
  elif ft == "exit":
1248
- ec = frame.get("code", ec)
1249
- new_cwd = frame.get("cwd", new_cwd) or new_cwd
1250
- # Flush remaining buffer
1243
+ ec = frame.get("code", 0)
1244
+ new_cwd = frame.get("cwd", cwd) or cwd
1251
1245
  if buf:
1252
1246
  line = _strip_ansi(buf.decode("utf-8", errors="replace")).rstrip()
1253
- if line and "\x00__EC__" not in line and "\x00__CWD__" not in line:
1254
- if not started:
1255
- sys.stdout.write("\n")
1256
- started = True
1247
+ if line:
1257
1248
  sys.stdout.write(f" {_SH_GR}│{_SH_RS} {line}\n")
1258
1249
  sys.stdout.flush()
1259
1250
  lines.append(line)
@@ -1261,9 +1252,6 @@ def guest_connect_shell(base_url: str, code: str) -> int:
1261
1252
 
1262
1253
  elif ft == "error":
1263
1254
  msg = frame.get("message", "?")
1264
- if not started:
1265
- sys.stdout.write("\n")
1266
- started = True
1267
1255
  sys.stdout.write(f" {_SH_GR}│{_RS} {_R}error:{_RS} {msg}\n")
1268
1256
  sys.stdout.flush()
1269
1257
  lines.append(f"error: {msg}")
@@ -1271,10 +1259,8 @@ def guest_connect_shell(base_url: str, code: str) -> int:
1271
1259
  break
1272
1260
 
1273
1261
  elif ft == "ping":
1274
- try:
1275
- ws.send(json.dumps({"type": "pong"}))
1276
- except Exception:
1277
- pass
1262
+ try: ws.send(json.dumps({"type": "pong"}))
1263
+ except Exception: pass
1278
1264
 
1279
1265
  return lines, ec, new_cwd
1280
1266
 
@@ -1368,8 +1354,9 @@ def guest_connect_shell(base_url: str, code: str) -> int:
1368
1354
  sys.stdout.flush()
1369
1355
  _sh_footer(ec, elapsed_ms)
1370
1356
 
1371
- except Exception:
1372
- pass
1357
+ except Exception as exc:
1358
+ sys.stderr.write(f"\r\n [REPL EXCEPTION]: {type(exc).__name__}: {exc}\r\n")
1359
+ sys.stderr.flush()
1373
1360
  finally:
1374
1361
  _stop_recv.set()
1375
1362
  try:
@@ -12,7 +12,7 @@ import os
12
12
  import tarfile
13
13
  import httpx
14
14
  from pathlib import Path
15
- from typing import Optional, Callable
15
+ from typing import Optional, Callable, List, Tuple, Union
16
16
 
17
17
 
18
18
  def _download_archive(client, file_code: str, progress_cb: Optional[Callable] = None) -> bytes:
@@ -41,6 +41,143 @@ def _download_archive(client, file_code: str, progress_cb: Optional[Callable] =
41
41
  return b"".join(chunks)
42
42
 
43
43
 
44
+
45
+ def _is_safe_subpath(path: Path, base: Path) -> bool:
46
+ """Verify that path resolves strictly inside base directory."""
47
+ try:
48
+ resolved_path = path.resolve()
49
+ resolved_base = base.resolve()
50
+ return resolved_path.is_relative_to(resolved_base)
51
+ except (ValueError, AttributeError):
52
+ try:
53
+ return os.path.commonpath([str(path.resolve()), str(base.resolve())]) == str(base.resolve())
54
+ except ValueError:
55
+ return False
56
+
57
+
58
+ def validate_and_extract_members(
59
+ tar: tarfile.TarFile,
60
+ target_home: Optional[Union[str, Path]] = None,
61
+ dry_run: bool = False,
62
+ allowed_abs_prefixes: Optional[List[str]] = None,
63
+ ) -> Tuple[List[str], List[str]]:
64
+ """
65
+ Two-pass secure extractor:
66
+ Pass 1: Inspect and validate 100% of archive members before writing anything.
67
+ Pass 2: Safely extract approved files directly into the destination.
68
+ """
69
+ home = Path(target_home).expanduser().resolve() if target_home else Path.home().resolve()
70
+
71
+ if allowed_abs_prefixes is None:
72
+ try:
73
+ from .terminal import _DEFAULT_ABS_PATHS
74
+ allowed_abs_prefixes = list(_DEFAULT_ABS_PATHS)
75
+ except Exception:
76
+ allowed_abs_prefixes = []
77
+
78
+ restored: List[str] = []
79
+ skipped: List[str] = []
80
+ planned: List[Tuple[tarfile.TarInfo, Path, Path]] = []
81
+
82
+ # ── PASS 1: Strict Pre-validation ────────────────────────────────────────
83
+ for member in tar.getmembers():
84
+ name = member.name
85
+
86
+ # Permitted metadata files
87
+ if name in ("MANIFEST.json", "manifest_meta.json", "akash.pkgs", "akash.largefiles"):
88
+ continue
89
+ if name.startswith("largeref/"):
90
+ continue
91
+
92
+ # Reject unsafe link entries
93
+ if member.issym() or member.islnk():
94
+ raise ValueError(f"Unsafe link entry rejected: {name}")
95
+
96
+ # Reject traversal or null bytes in member name
97
+ parts = Path(name).parts
98
+ if ".." in parts:
99
+ raise ValueError(f"Path traversal rejected in archive member: {name}")
100
+ if "\x00" in name:
101
+ raise ValueError(f"Null byte rejected in archive member: {name}")
102
+
103
+ if not member.isfile():
104
+ continue
105
+
106
+ dest: Optional[Path] = None
107
+ effective_root: Path = home
108
+
109
+ if name.startswith("home/"):
110
+ rel_str = name[len("home/"):].lstrip("/")
111
+ if not rel_str or ".." in Path(rel_str).parts:
112
+ raise ValueError(f"Traversal in home member: {name}")
113
+ dest = home / rel_str
114
+ effective_root = home
115
+
116
+ elif name.startswith("extra/"):
117
+ rel_str = name[len("extra/"):].lstrip("/")
118
+ if not rel_str or ".." in Path(rel_str).parts:
119
+ raise ValueError(f"Traversal in extra member: {name}")
120
+ dest = home / rel_str
121
+ effective_root = home
122
+
123
+ elif name.startswith("abs/"):
124
+ rel_str = name[len("abs/"):].lstrip("/")
125
+ if not rel_str or ".." in Path(rel_str).parts:
126
+ raise ValueError(f"Traversal in abs member: {name}")
127
+
128
+ if target_home:
129
+ # Sandboxed restore: map abs/ paths safely under target directory
130
+ dest = home / rel_str
131
+ effective_root = home
132
+ else:
133
+ # Root restore: only permitted if path is in authorized prefix list
134
+ abs_dest = (Path("/") / rel_str).resolve()
135
+ matched = None
136
+ for prefix in allowed_abs_prefixes:
137
+ if _is_safe_subpath(abs_dest, Path(prefix)):
138
+ matched = prefix
139
+ break
140
+ if matched:
141
+ dest = abs_dest
142
+ effective_root = Path(matched).resolve()
143
+ else:
144
+ raise ValueError(f"Unexpected absolute path rejected: {name} ({abs_dest} is not authorized)")
145
+ else:
146
+ skipped.append(name)
147
+ continue
148
+
149
+ if not _is_safe_subpath(dest, effective_root):
150
+ raise ValueError(f"Extraction destination {dest} escapes root {effective_root}")
151
+
152
+ planned.append((member, dest, effective_root))
153
+
154
+ # ── PASS 2: Extraction ───────────────────────────────────────────────────
155
+ for member, dest, effective_root in planned:
156
+ if dry_run:
157
+ restored.append(str(dest))
158
+ continue
159
+
160
+ try:
161
+ dest.parent.mkdir(parents=True, exist_ok=True)
162
+ if not _is_safe_subpath(dest.parent, effective_root):
163
+ raise ValueError(f"Parent directory {dest.parent} escapes authorized root")
164
+
165
+ data = tar.extractfile(member)
166
+ if data is None:
167
+ skipped.append(str(dest))
168
+ continue
169
+ dest.write_bytes(data.read())
170
+ try:
171
+ os.chmod(dest, member.mode)
172
+ except OSError:
173
+ pass
174
+ restored.append(str(dest))
175
+ except (PermissionError, OSError) as e:
176
+ skipped.append(f"{dest} ({e})")
177
+
178
+ return restored, skipped
179
+
180
+
44
181
  def restore_snapshot(
45
182
  client,
46
183
  file_code: str,
@@ -112,61 +249,12 @@ def restore_snapshot(
112
249
  except KeyError:
113
250
  pass # no large-file manifest in this snapshot
114
251
 
115
- # ── 3c. Restore each member ───────────────────────────────────────────
116
- for member in tar.getmembers():
117
- name = member.name
118
-
119
- # Skip metadata-only files
120
- if name in ("MANIFEST.json", "manifest_meta.json",
121
- "akash.pkgs", "akash.largefiles"):
122
- continue
123
- # Skip large-file placeholder prefix (only references, not data)
124
- if name.startswith("largeref/"):
125
- continue
126
-
127
- if not member.isfile():
128
- continue
129
-
130
- # ── Map archive path → real filesystem path ──────────────────────
131
- dest: Optional[Path] = None
132
-
133
- if name.startswith("home/"):
134
- rel = name[len("home/"):]
135
- dest = home / rel
136
-
137
- elif name.startswith("abs/"):
138
- # Stored as "abs/absolute/path/to/file"
139
- abs_str = name[len("abs"):] # "/absolute/path/to/file"
140
- dest = Path(abs_str)
141
-
142
- elif name.startswith("extra/"):
143
- # Extra files land directly in home
144
- dest = home / name[len("extra/"):]
145
-
146
- else:
147
- skipped.append(name)
148
- continue
149
-
150
- if dry_run:
151
- restored.append(str(dest))
152
- continue
153
-
154
- # ── Write file ───────────────────────────────────────────────────
155
- try:
156
- dest.parent.mkdir(parents=True, exist_ok=True)
157
- data = tar.extractfile(member)
158
- if data is None:
159
- skipped.append(str(dest))
160
- continue
161
- dest.write_bytes(data.read())
162
- # Preserve original permissions where possible
163
- try:
164
- os.chmod(dest, member.mode)
165
- except OSError:
166
- pass
167
- restored.append(str(dest))
168
- except (PermissionError, OSError) as e:
169
- skipped.append(f"{dest} ({e})")
252
+ # ── 3c. Restore each member (safe validation & extraction) ───────────
253
+ r_list, s_list = validate_and_extract_members(
254
+ tar, target_home=home, dry_run=dry_run
255
+ )
256
+ restored.extend(r_list)
257
+ skipped.extend(s_list)
170
258
 
171
259
  # ── 4. Reinstall packages from akash.pkgs ────────────────────────────────
172
260
  pkg_results = None
@@ -19,7 +19,7 @@ import subprocess
19
19
  import tempfile
20
20
  from datetime import datetime, timezone
21
21
  from pathlib import Path
22
- from typing import List, Optional, Callable
22
+ from typing import List, Optional, Callable, Tuple
23
23
 
24
24
 
25
25
  # ---------------------------------------------------------------------------
@@ -49,8 +49,6 @@ _DEFAULT_HOME_GLOBS = [
49
49
  ".pip/pip.conf", ".config/pip/pip.conf",
50
50
  # Misc tools
51
51
  ".curlrc", ".wgetrc", ".ripgreprc", ".alacritty.yml",
52
- # akashcli own config (so login survives the reset)
53
- ".akash",
54
52
  ]
55
53
 
56
54
  # Absolute paths always collected (non-home)
@@ -61,6 +59,120 @@ _DEFAULT_ABS_PATHS: List[str] = []
61
59
  # .akashignore loader
62
60
  # ---------------------------------------------------------------------------
63
61
 
62
+
63
+ import re
64
+
65
+ PRIVATE_KEY_BANNERS = (
66
+ b"-----BEGIN OPENSSH PRIVATE KEY-----",
67
+ b"-----BEGIN RSA PRIVATE KEY-----",
68
+ b"-----BEGIN DSA PRIVATE KEY-----",
69
+ b"-----BEGIN EC PRIVATE KEY-----",
70
+ b"-----BEGIN PRIVATE KEY-----",
71
+ b"-----BEGIN ENCRYPTED PRIVATE KEY-----",
72
+ b"PuTTY-User-Key-File-",
73
+ b"-----BEGIN PGP PRIVATE KEY BLOCK-----",
74
+ )
75
+
76
+ EXCLUDED_CREDENTIAL_FILENAMES = {
77
+ ".git-credentials", ".git-credentials.helper",
78
+ ".netrc", "_netrc", ".pypirc",
79
+ }
80
+
81
+ EXCLUDED_CREDENTIAL_DIR_FILES = {
82
+ ".aws": {"credentials", "config"},
83
+ ".kube": {"config", "config.json"},
84
+ ".docker": {"config.json"},
85
+ ".gh": {"hosts.yml", "config.yml"},
86
+ ".akash": {
87
+ "machine.key", "config.enc", "exec_token.json",
88
+ "exec_session.json", "identity.json", "serve.pid"
89
+ },
90
+ }
91
+
92
+ def is_credential_file(fpath: Path, home: Path, include_ssh_private: bool = False) -> Tuple[bool, str]:
93
+ """Inspect path and content to identify credentials. Never trust extension alone."""
94
+ name = fpath.name.lower()
95
+ parts = fpath.parts
96
+
97
+ # 1. Akash authentication and machine secrets
98
+ if ".akash" in parts:
99
+ if name in EXCLUDED_CREDENTIAL_DIR_FILES[".akash"] or "envs" in parts:
100
+ return True, "Akash authentication secret or session file"
101
+
102
+ # 2. Known credential files
103
+ if name in EXCLUDED_CREDENTIAL_FILENAMES:
104
+ return True, "Authentication credentials file"
105
+
106
+ for dname, fnames in EXCLUDED_CREDENTIAL_DIR_FILES.items():
107
+ if dname in parts and name in fnames:
108
+ return True, f"{dname} configuration/credentials file"
109
+
110
+ if ".gnupg" in parts and (name in ("secring.gpg", "trustdb.gpg") or "private-keys-v1.d" in parts):
111
+ return True, "GPG private key material"
112
+
113
+ # 3. Environment secrets (.env, .env.local, .env.production, etc., except examples)
114
+ if name == ".env" or (name.startswith(".env.") and not name.endswith((".example", ".sample", ".template", ".dist"))):
115
+ return True, "Environment secrets file"
116
+
117
+ # 4. SSH directory checks
118
+ if ".ssh" in parts:
119
+ if name not in ("config", "known_hosts", "authorized_keys") and not name.endswith(".pub"):
120
+ if not include_ssh_private:
121
+ return True, "SSH private key"
122
+
123
+ # 5. Content-level inspection (first 4KB): detect private key signatures regardless of extension
124
+ try:
125
+ if fpath.is_file() and fpath.stat().st_size > 0:
126
+ with open(fpath, "rb") as fh:
127
+ head = fh.read(4096)
128
+ for banner in PRIVATE_KEY_BANNERS:
129
+ if banner in head:
130
+ if include_ssh_private and ".ssh" in parts:
131
+ return False, ""
132
+ return True, "File content contains private key signature"
133
+ except (OSError, PermissionError):
134
+ pass
135
+
136
+ return False, ""
137
+
138
+
139
+ def redact_url_credentials(text: str) -> str:
140
+ """Redact passwords embedded in URLs (e.g. postgres://user:pass@host)."""
141
+ return re.sub(
142
+ r'(?i)([a-z][a-z0-9+.-]*://)([^:@\s]*):([^@\s]+)@',
143
+ r'\1\2:[REDACTED]@',
144
+ text
145
+ )
146
+
147
+ def redact_connection_strings(text: str) -> str:
148
+ """Redact password attributes in database connection strings."""
149
+ return re.sub(
150
+ r'(?i)\b(password|passwd|pwd|secret|token|api_?key)\s*=\s*([^;\s\n]+)',
151
+ r'\1=[REDACTED]',
152
+ text
153
+ )
154
+
155
+ def redact_serialized_secrets(text: str) -> str:
156
+ pattern = r"""(?i)(["']?(?:password|passwd|pwd|secret|token|api[_-]?key|auth[_-]?token|private[_-]?key|credentials?)["']?\s*[:=]\s*["']?)([^"'\s,;{}]+)(["']?)"""
157
+ return re.sub(pattern, r"\1[REDACTED]\3", text)
158
+
159
+ def redact_token_signatures(text: str) -> str:
160
+ """Redact high-entropy token formats (JWT, AWS keys, tokens)."""
161
+ text = re.sub(r'\beyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\b', '[REDACTED_JWT]', text)
162
+ text = re.sub(r'\bAKIA[0-9A-Z]{16}\b', '[REDACTED_AWS_KEY]', text)
163
+ text = re.sub(r'\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b', '[REDACTED_TOKEN]', text)
164
+ text = re.sub(r'\bakash_[A-Za-z0-9_-]{16,}\b', '[REDACTED_API_KEY]', text)
165
+ return text
166
+
167
+ def sanitize_secret_text(text: str) -> str:
168
+ if not text:
169
+ return text
170
+ text = redact_url_credentials(text)
171
+ text = redact_connection_strings(text)
172
+ text = redact_serialized_secrets(text)
173
+ text = redact_token_signatures(text)
174
+ return text
175
+
64
176
  def _load_ignore_patterns(home: Path) -> List[str]:
65
177
  """
66
178
  Load exclusion patterns from ~/.akashignore (or .akashignore in CWD).
@@ -119,7 +231,23 @@ def _run(cmd: str) -> str:
119
231
 
120
232
 
121
233
  def _collect_metadata() -> dict:
122
- """Gather environment metadata stored in the manifest."""
234
+ """Gather environment metadata with strict redaction of sensitive credentials."""
235
+ raw_cron = _run("crontab -l 2>/dev/null")
236
+ sanitized_cron = sanitize_secret_text(raw_cron)
237
+
238
+ sensitive_key_needles = (
239
+ "SECRET", "TOKEN", "PASSWORD", "PASSWD", "PWD", "KEY", "PASS",
240
+ "HASH", "SALT", "PRIVATE", "CREDENTIAL", "AUTH", "BEARER",
241
+ "APIKEY", "SIGNATURE", "SESSION"
242
+ )
243
+
244
+ clean_env = {}
245
+ for k, v in os.environ.items():
246
+ k_upper = k.upper()
247
+ if any(s in k_upper for s in sensitive_key_needles):
248
+ continue
249
+ clean_env[k] = sanitize_secret_text(v)
250
+
123
251
  return {
124
252
  "hostname": platform.node(),
125
253
  "system": platform.system(),
@@ -128,15 +256,9 @@ def _collect_metadata() -> dict:
128
256
  "shell": os.getenv("SHELL", "unknown"),
129
257
  "user": os.getenv("USER") or os.getenv("LOGNAME") or "unknown",
130
258
  "snapshot_at": datetime.now(timezone.utc).isoformat(),
131
- "crontab": _run("crontab -l 2>/dev/null"),
259
+ "crontab": sanitized_cron,
132
260
  "path_env": os.getenv("PATH", ""),
133
- "env_vars": {
134
- k: v for k, v in os.environ.items()
135
- if not any(s in k.upper() for s in (
136
- "SECRET", "TOKEN", "PASSWORD", "KEY", "PASS",
137
- "HASH", "SALT", "PRIVATE", "CREDENTIAL"
138
- ))
139
- },
261
+ "env_vars": clean_env,
140
262
  }
141
263
 
142
264
 
@@ -260,13 +382,10 @@ def build_snapshot(
260
382
  if _is_ignored_fn(fpath):
261
383
  skipped.append(f"{fpath} (ignored)")
262
384
  continue
263
- # Safety: skip private SSH keys unless explicitly requested
264
- if not include_ssh_private and fpath.suffix in ("", ".pem", ".ppk"):
265
- if ".ssh" in fpath.parts and fpath.name not in (
266
- "config", "known_hosts", "authorized_keys"
267
- ):
268
- skipped.append(str(fpath))
269
- continue
385
+ is_cred, reason = is_credential_file(fpath, home, include_ssh_private=include_ssh_private)
386
+ if is_cred:
387
+ skipped.append(f"{fpath} ({reason})")
388
+ continue
270
389
  # Large home-dir files (unusual but possible) → treat as ref
271
390
  tier = classify_file(fpath)
272
391
  if tier != FileTier.INLINE:
@@ -314,6 +433,10 @@ def build_snapshot(
314
433
 
315
434
  # ── 4. Extra paths — inline tier only (Tier B/C handled after tar) ─
316
435
  for fpath in extra_inline:
436
+ is_cred, reason = is_credential_file(fpath, home, include_ssh_private=include_ssh_private)
437
+ if is_cred:
438
+ skipped.append(f"{fpath} ({reason})")
439
+ continue
317
440
  arc_path = f"extra/{fpath.name}"
318
441
  try:
319
442
  tar.add(str(fpath), arcname=arc_path)
@@ -392,8 +515,13 @@ def build_snapshot(
392
515
  manifest["security"] = detect_tier(final_bytes)
393
516
  else:
394
517
  final_bytes = raw_bytes
395
- manifest["security"] = {"tier": 0, "tier_label": "Public (no encryption)",
396
- "encrypted": False, "requires_password": False,
397
- "device_locked": False}
518
+ manifest["security"] = {
519
+ "tier": 0,
520
+ "tier_label": "Unencrypted (Public)",
521
+ "encrypted": False,
522
+ "requires_password": False,
523
+ "device_locked": False,
524
+ "warning": "Snapshot is unencrypted and public. Protect sensitive backups using --password or --device-lock."
525
+ }
398
526
 
399
527
  return final_bytes, manifest, archive_name
@@ -34,6 +34,42 @@ class VaultUploader:
34
34
  if password:
35
35
  headers["x-vault-password"] = password
36
36
 
37
+ # 0. Distributed Upload Worker Integration: Attempt direct worker ticket ingestion
38
+ if content is None and file_path and os.path.exists(file_path):
39
+ try:
40
+ ticket_url = f"{self.client.base_url}/api/upload/ticket"
41
+ t_resp = httpx.post(
42
+ ticket_url,
43
+ headers=self.client._get_headers(headers),
44
+ timeout=10.0
45
+ )
46
+ if t_resp.status_code == 200:
47
+ t_data = t_resp.json()
48
+ ingest_url = t_data.get("ingest_url")
49
+ ticket_jwt = t_data.get("ticket")
50
+ file_code = t_data.get("file_code")
51
+ if ingest_url and ticket_jwt:
52
+ file_size = os.path.getsize(file_path)
53
+ with open(file_path, "rb") as fh:
54
+ w_files = {"file": (os.path.basename(file_path), fh)}
55
+ w_headers = {"x-upload-ticket": ticket_jwt}
56
+ w_resp = httpx.post(ingest_url, files=w_files, headers=w_headers, timeout=None)
57
+ if w_resp.status_code == 200:
58
+ d = w_resp.json()
59
+ res = UploadResult(
60
+ status=d.get("status", "processing"),
61
+ file_code=file_code,
62
+ embed_url=f"{self.client.base_url}/embed/{file_code}",
63
+ direct_url=f"{self.client.base_url}/download/{file_code}",
64
+ input_type="file",
65
+ dna=d.get("dna", {})
66
+ )
67
+ if res.status == "processing":
68
+ res = self._poll_until_done(res)
69
+ return res
70
+ except Exception:
71
+ pass # Fall back to standard monolithic upload
72
+
37
73
  url = f"{self.client.base_url}/api/upload"
38
74
 
39
75
  try:
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.6.3
3
+ Version: 4.6.4
4
4
  Summary: Akash CLI — secure remote shell relay, file vault, and device management
5
- License: MIT
5
+ License-Expression: MIT
6
6
  Keywords: akash,cli,relay,remote-shell,vault
7
7
  Classifier: Programming Language :: Python :: 3
8
- Classifier: License :: OSI Approved :: MIT License
9
8
  Classifier: Operating System :: OS Independent
10
9
  Classifier: Environment :: Console
11
10
  Classifier: Topic :: Utilities
@@ -1,14 +1,13 @@
1
1
  [project]
2
2
  name = "akashcli"
3
- version = "4.6.3"
3
+ version = "4.6.4"
4
4
  description = "Akash CLI — secure remote shell relay, file vault, and device management"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
7
- license = { text = "MIT" }
7
+ license = "MIT"
8
8
  keywords = ["akash", "cli", "relay", "remote-shell", "vault"]
9
9
  classifiers = [
10
10
  "Programming Language :: Python :: 3",
11
- "License :: OSI Approved :: MIT License",
12
11
  "Operating System :: OS Independent",
13
12
  "Environment :: Console",
14
13
  "Topic :: Utilities",
@@ -28,3 +27,8 @@ akash = "akashcli.cli:app"
28
27
  [build-system]
29
28
  requires = ["setuptools", "wheel"]
30
29
  build-backend = "setuptools.build_meta"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["akashcli*"]
34
+ exclude = ["patches*", "dist*", "tests*"]
@@ -1,6 +0,0 @@
1
- # akashcli/__init__.py
2
- from .client import AkashClient
3
- from .models import UploadResult, FileInfo, UserInfo
4
- from .exceptions import AkashError
5
-
6
- __version__ = "2.0.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes