synic 1.0.0__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.
- synic-1.0.0/PKG-INFO +32 -0
- synic-1.0.0/README.md +22 -0
- synic-1.0.0/pyproject.toml +19 -0
- synic-1.0.0/setup.cfg +4 -0
- synic-1.0.0/src/synic/__init__.py +1 -0
- synic-1.0.0/src/synic/cli.py +292 -0
- synic-1.0.0/src/synic/config.py +52 -0
- synic-1.0.0/src/synic/http.py +42 -0
- synic-1.0.0/src/synic/ui.py +133 -0
- synic-1.0.0/src/synic.egg-info/PKG-INFO +32 -0
- synic-1.0.0/src/synic.egg-info/SOURCES.txt +12 -0
- synic-1.0.0/src/synic.egg-info/dependency_links.txt +1 -0
- synic-1.0.0/src/synic.egg-info/entry_points.txt +2 -0
- synic-1.0.0/src/synic.egg-info/top_level.txt +1 -0
synic-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: synic
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Synic panel CLI — login, deploy, downloads, backups, and custom flows
|
|
5
|
+
Author: Synic
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: synic,cli,jimboshop,panel
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# synic
|
|
12
|
+
|
|
13
|
+
Synic panel CLI for pip.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install synic
|
|
17
|
+
synic login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then the prompt becomes `synic>` (light blue). You stay signed in until `logout`.
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
synic> proDeploy
|
|
24
|
+
synic> uploadFile
|
|
25
|
+
synic> download
|
|
26
|
+
synic> makeBackup
|
|
27
|
+
synic> userDetail
|
|
28
|
+
synic> help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
npm: `npm install -g synic`
|
|
32
|
+
yarn: `yarn global add synic`
|
synic-1.0.0/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# synic
|
|
2
|
+
|
|
3
|
+
Synic panel CLI for pip.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install synic
|
|
7
|
+
synic login
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Then the prompt becomes `synic>` (light blue). You stay signed in until `logout`.
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
synic> proDeploy
|
|
14
|
+
synic> uploadFile
|
|
15
|
+
synic> download
|
|
16
|
+
synic> makeBackup
|
|
17
|
+
synic> userDetail
|
|
18
|
+
synic> help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
npm: `npm install -g synic`
|
|
22
|
+
yarn: `yarn global add synic`
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "synic"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Synic panel CLI — login, deploy, downloads, backups, and custom flows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Synic" }]
|
|
13
|
+
keywords = ["synic", "cli", "jimboshop", "panel"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
synic = "synic.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
synic-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import (
|
|
10
|
+
DEFAULT_URL,
|
|
11
|
+
clear_session,
|
|
12
|
+
is_logged_in,
|
|
13
|
+
load_config,
|
|
14
|
+
load_flows,
|
|
15
|
+
save_config,
|
|
16
|
+
save_flows,
|
|
17
|
+
)
|
|
18
|
+
from .http import api, base_url
|
|
19
|
+
from .ui import ask, c, confirm, fail, info, ok, pick, progress, prompt_line
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
HELP = [
|
|
23
|
+
("login", "Sign in with username and password"),
|
|
24
|
+
("logout", "Sign out after a Yes/No confirm"),
|
|
25
|
+
("exit", "Leave the synic prompt (stay signed in)"),
|
|
26
|
+
("help", "Show commands you can use"),
|
|
27
|
+
("whoami", "Show the signed-in account"),
|
|
28
|
+
("sync", "Refresh commands from the panel"),
|
|
29
|
+
("userDetail", "Show your name, phone, and permissions"),
|
|
30
|
+
("proDeploy", "Pick a project, choose a zip, deploy with live progress"),
|
|
31
|
+
("uploadFile", "Upload a file into public downloads"),
|
|
32
|
+
("download", "Search public-download files by name and save"),
|
|
33
|
+
("makeBackup", "Pick a project and back it up on the server"),
|
|
34
|
+
("list", "List your workspaces"),
|
|
35
|
+
("init", "Create a workspace in this folder"),
|
|
36
|
+
("push", "Send this folder"),
|
|
37
|
+
("pull", "Get the latest files"),
|
|
38
|
+
("clone", "Copy a shared workspace"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def print_help(user=None, extra=None):
|
|
43
|
+
print(c("prompt", "Commands"))
|
|
44
|
+
width = max(len(name) for name, _ in HELP)
|
|
45
|
+
for name, text in HELP:
|
|
46
|
+
print(f" {c('success', name.ljust(width))} {c('option', text)}")
|
|
47
|
+
for cmd in extra or []:
|
|
48
|
+
print(f" {c('success', str(cmd.get('name', '')).ljust(width))} {c('option', cmd.get('help') or '')}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def require_login():
|
|
52
|
+
cfg = load_config()
|
|
53
|
+
if not is_logged_in(cfg):
|
|
54
|
+
raise RuntimeError("Run synic login first.")
|
|
55
|
+
return cfg
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def require_perm(user, key):
|
|
59
|
+
if not user:
|
|
60
|
+
raise RuntimeError("Run synic login first.")
|
|
61
|
+
if user.get("role") == "admin":
|
|
62
|
+
return
|
|
63
|
+
if key in (user.get("permissions") or []):
|
|
64
|
+
return
|
|
65
|
+
raise RuntimeError("You cannot use this command.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def win_dialog(kind: str, filename: str = "file.bin", filt: str = "All files (*.*)|*.*") -> str:
|
|
69
|
+
if sys.platform != "win32":
|
|
70
|
+
return ask("File path:")
|
|
71
|
+
if kind == "open":
|
|
72
|
+
script = f"""
|
|
73
|
+
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
|
74
|
+
$d = New-Object System.Windows.Forms.OpenFileDialog
|
|
75
|
+
$d.Filter = '{filt.replace("'", "''")}'
|
|
76
|
+
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $d.FileName }}
|
|
77
|
+
"""
|
|
78
|
+
else:
|
|
79
|
+
script = f"""
|
|
80
|
+
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
|
81
|
+
$d = New-Object System.Windows.Forms.SaveFileDialog
|
|
82
|
+
$d.FileName = '{filename.replace("'", "''")}'
|
|
83
|
+
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $d.FileName }}
|
|
84
|
+
"""
|
|
85
|
+
out = subprocess.check_output(["powershell.exe", "-NoProfile", "-STA", "-Command", script], text=True)
|
|
86
|
+
path = out.strip()
|
|
87
|
+
if not path:
|
|
88
|
+
raise RuntimeError("No file selected")
|
|
89
|
+
return path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def sync_catalog():
|
|
93
|
+
data = api("/cli/catalog")
|
|
94
|
+
commands = data.get("commands") or []
|
|
95
|
+
save_flows(commands)
|
|
96
|
+
if data.get("user"):
|
|
97
|
+
save_config({"user": data["user"]})
|
|
98
|
+
return commands
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_login(args):
|
|
102
|
+
url = DEFAULT_URL
|
|
103
|
+
if "--url" in args:
|
|
104
|
+
url = args[args.index("--url") + 1].rstrip("/")
|
|
105
|
+
username = ask("Username:")
|
|
106
|
+
password = ask("Password:", secret=True)
|
|
107
|
+
data = api("/cli/login", method="POST", body={"username": username, "password": password}, token="", cfg={"url": url})
|
|
108
|
+
save_config({"url": url, "token": data.get("token"), "user": data.get("user")})
|
|
109
|
+
flows = data.get("commands") or sync_catalog()
|
|
110
|
+
save_flows(flows)
|
|
111
|
+
ok(f"Logged in as {(data.get('user') or {}).get('username') or username}.")
|
|
112
|
+
print_help(data.get("user"), flows)
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_logout(_args):
|
|
117
|
+
require_login()
|
|
118
|
+
if not confirm():
|
|
119
|
+
info("Still signed in.")
|
|
120
|
+
return True
|
|
121
|
+
clear_session()
|
|
122
|
+
ok("Signed out.")
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_user_detail(_args):
|
|
127
|
+
require_login()
|
|
128
|
+
data = api("/auth/me")
|
|
129
|
+
user = data.get("user") or data
|
|
130
|
+
save_config({"user": user})
|
|
131
|
+
print(c("prompt", user.get("username") or ""))
|
|
132
|
+
print(c("loading", f"Phone {user['phone']}") if user.get("phone") else c("muted", "Phone —"))
|
|
133
|
+
print(c("stage", f"Role {user.get('role') or 'user'}"))
|
|
134
|
+
perms = user.get("permissions") or []
|
|
135
|
+
keys = ["files", "downloads", "editor", "logs", "firewall", "terminal", "packages", "deploy", "domains", "users", "settings", "repos", "serverPassword", "synicCli"]
|
|
136
|
+
for key in keys:
|
|
137
|
+
on = user.get("role") == "admin" or key in perms
|
|
138
|
+
print(f" {c('progress', key.ljust(16))} {c('success', 'yes') if on else c('error', 'no')}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def wait_job(job_id: str, kind: str = "deploy"):
|
|
142
|
+
path = f"/backups/jobs/{job_id}" if kind == "backup" else f"/deploy/jobs/{job_id}"
|
|
143
|
+
while True:
|
|
144
|
+
data = api(path)
|
|
145
|
+
job = data.get("job") or data
|
|
146
|
+
progress(job.get("percent") or 0, job.get("stage") or "", job.get("message") or "")
|
|
147
|
+
if job.get("stage") in ("done", "error", "cancelled") or job.get("ok") is False:
|
|
148
|
+
if job.get("ok") is False or job.get("stage") in ("error", "cancelled"):
|
|
149
|
+
raise RuntimeError(job.get("error") or job.get("message") or "Job failed")
|
|
150
|
+
return job
|
|
151
|
+
time.sleep(0.8)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_pro_deploy(_args):
|
|
155
|
+
cfg = require_login()
|
|
156
|
+
require_perm(cfg.get("user") or {}, "deploy")
|
|
157
|
+
data = api("/deploy/sites")
|
|
158
|
+
options = []
|
|
159
|
+
labels = {"api": "Main API", "panelServer": "PanelServer", "unavailable": "Unavailable page"}
|
|
160
|
+
for key in data.get("systemTargets") or []:
|
|
161
|
+
options.append({"label": labels.get(key, key), "value": {"kind": "system", "key": key}})
|
|
162
|
+
for domain in data.get("domains") or []:
|
|
163
|
+
options.append({"label": domain.get("domain"), "value": {"kind": "domain", "id": domain.get("id"), "key": domain.get("id")}})
|
|
164
|
+
if not options:
|
|
165
|
+
raise RuntimeError("No deploy targets for this account.")
|
|
166
|
+
target = pick(options, title="Choose a deploy")
|
|
167
|
+
info("Please upload the zip file you want.")
|
|
168
|
+
time.sleep(0.7)
|
|
169
|
+
file_path = win_dialog("open", filt="Zip files (*.zip)|*.zip|All files (*.*)|*.*")
|
|
170
|
+
info("Upload the zip from the panel if the CLI chunk upload is unavailable on this Python build.")
|
|
171
|
+
# Python stdlib chunk upload kept simple: tell user we post complete via Node-equivalent endpoints
|
|
172
|
+
raise RuntimeError(f"Selected {file_path}. Use the Node CLI (npm/yarn) for zip chunk upload, or deploy from the panel.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_download(_args):
|
|
176
|
+
cfg = require_login()
|
|
177
|
+
require_perm(cfg.get("user") or {}, "downloads")
|
|
178
|
+
name = ask("File name:")
|
|
179
|
+
if not name:
|
|
180
|
+
raise RuntimeError("File name is required")
|
|
181
|
+
info("Searching every public-download folder…")
|
|
182
|
+
data = api(f"/downloads/search?q={name}")
|
|
183
|
+
matches = data.get("matches") or data.get("items") or []
|
|
184
|
+
if not matches:
|
|
185
|
+
raise RuntimeError(f'No file named "{name}" in public downloads.')
|
|
186
|
+
chosen = matches[0]
|
|
187
|
+
if len(matches) > 1:
|
|
188
|
+
chosen = pick([{"label": f"{m.get('filename')} ({m.get('folder')})", "value": m} for m in matches], title="Several files matched")
|
|
189
|
+
dest = win_dialog("save", filename=chosen.get("filename") or "file.bin")
|
|
190
|
+
raw = api(
|
|
191
|
+
f"/downloads/file?folder={chosen.get('folder')}&filename={chosen.get('filename')}",
|
|
192
|
+
raw=True,
|
|
193
|
+
)
|
|
194
|
+
Path(dest).write_bytes(raw)
|
|
195
|
+
ok(f"Saved {dest}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_make_backup(_args):
|
|
199
|
+
cfg = require_login()
|
|
200
|
+
require_perm(cfg.get("user") or {}, "deploy")
|
|
201
|
+
data = api("/deploy/sites")
|
|
202
|
+
options = []
|
|
203
|
+
labels = {"api": "Main API", "panelServer": "PanelServer", "unavailable": "Unavailable page"}
|
|
204
|
+
for key in data.get("systemTargets") or []:
|
|
205
|
+
options.append({"label": labels.get(key, key), "value": {"kind": "system", "key": key}})
|
|
206
|
+
for domain in data.get("domains") or []:
|
|
207
|
+
options.append({"label": domain.get("domain"), "value": {"kind": "site", "folderName": domain.get("folderName"), "key": domain.get("id")}})
|
|
208
|
+
target = pick(options, title="Choose a project")
|
|
209
|
+
started = api("/backups/create", method="POST", body={"kind": "site" if target.get("kind") == "site" else target.get("key"), "folderName": target.get("folderName"), "key": target.get("key")})
|
|
210
|
+
job_id = started.get("jobId") or (started.get("job") or {}).get("id")
|
|
211
|
+
wait_job(job_id, "backup")
|
|
212
|
+
ok("Backup finished.")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_list(_args):
|
|
216
|
+
require_login()
|
|
217
|
+
data = api("/repos")
|
|
218
|
+
for repo in data.get("repos") or []:
|
|
219
|
+
print(f"{repo.get('slug') or repo.get('id')} {repo.get('name')}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
COMMANDS = {
|
|
223
|
+
"login": cmd_login,
|
|
224
|
+
"logout": cmd_logout,
|
|
225
|
+
"help": lambda _a: print_help(load_config().get("user"), load_flows()),
|
|
226
|
+
"whoami": lambda _a: print(json.dumps(api("/auth/me"), indent=2)),
|
|
227
|
+
"sync": lambda _a: (require_login(), print_help(load_config().get("user"), sync_catalog())),
|
|
228
|
+
"userdetail": cmd_user_detail,
|
|
229
|
+
"prodeploy": cmd_pro_deploy,
|
|
230
|
+
"download": cmd_download,
|
|
231
|
+
"makebackup": cmd_make_backup,
|
|
232
|
+
"list": cmd_list,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_command(cmd: str, args: list) -> bool | None:
|
|
237
|
+
if cmd in ("exit", "quit"):
|
|
238
|
+
info("Bye. You are still signed in.")
|
|
239
|
+
return False
|
|
240
|
+
fn = COMMANDS.get(cmd)
|
|
241
|
+
if not fn:
|
|
242
|
+
flows = load_flows()
|
|
243
|
+
flow = next((f for f in flows if str(f.get("name", "")).lower() == cmd), None)
|
|
244
|
+
if flow:
|
|
245
|
+
info(f"Custom command '{cmd}' is available. Use the Node CLI for full flow playback.")
|
|
246
|
+
return True
|
|
247
|
+
raise RuntimeError(f"Unknown command: {cmd}")
|
|
248
|
+
return fn(args)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def shell_loop():
|
|
252
|
+
while True:
|
|
253
|
+
try:
|
|
254
|
+
line = input(prompt_line()).strip()
|
|
255
|
+
except EOFError:
|
|
256
|
+
return
|
|
257
|
+
if not line:
|
|
258
|
+
continue
|
|
259
|
+
parts = line.split()
|
|
260
|
+
try:
|
|
261
|
+
cont = run_command(parts[0].lower(), parts[1:])
|
|
262
|
+
if cont is False:
|
|
263
|
+
return
|
|
264
|
+
except Exception as err:
|
|
265
|
+
fail(str(err))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main():
|
|
269
|
+
args = sys.argv[1:]
|
|
270
|
+
if is_logged_in():
|
|
271
|
+
try:
|
|
272
|
+
sync_catalog()
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
if not args:
|
|
276
|
+
if not is_logged_in():
|
|
277
|
+
print_help()
|
|
278
|
+
info("Run synic login to start.")
|
|
279
|
+
return
|
|
280
|
+
shell_loop()
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
cont = run_command(args[0].lower(), args[1:])
|
|
284
|
+
if cont is True:
|
|
285
|
+
shell_loop()
|
|
286
|
+
except Exception as err:
|
|
287
|
+
fail(str(err))
|
|
288
|
+
sys.exit(1)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_URL = "https://panel.jimboshop.xyz"
|
|
7
|
+
HOME = Path.home() / ".synic"
|
|
8
|
+
CFG = HOME / "config.json"
|
|
9
|
+
COMMANDS_DIR = HOME / "commands"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_home() -> None:
|
|
13
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config() -> dict:
|
|
18
|
+
try:
|
|
19
|
+
return json.loads(CFG.read_text(encoding="utf-8"))
|
|
20
|
+
except Exception:
|
|
21
|
+
return {"url": DEFAULT_URL}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save_config(cfg: dict) -> dict:
|
|
25
|
+
ensure_home()
|
|
26
|
+
next_cfg = {**load_config(), **cfg}
|
|
27
|
+
CFG.write_text(json.dumps(next_cfg, indent=2), encoding="utf-8")
|
|
28
|
+
return next_cfg
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clear_session() -> None:
|
|
32
|
+
ensure_home()
|
|
33
|
+
url = load_config().get("url") or DEFAULT_URL
|
|
34
|
+
CFG.write_text(json.dumps({"url": url}, indent=2), encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_logged_in(cfg: dict | None = None) -> bool:
|
|
38
|
+
data = cfg or load_config()
|
|
39
|
+
return bool(data.get("token"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_flows(flows: list) -> None:
|
|
43
|
+
ensure_home()
|
|
44
|
+
(COMMANDS_DIR / "catalog.json").write_text(json.dumps(flows or [], indent=2), encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_flows() -> list:
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads((COMMANDS_DIR / "catalog.json").read_text(encoding="utf-8"))
|
|
50
|
+
return data if isinstance(data, list) else []
|
|
51
|
+
except Exception:
|
|
52
|
+
return []
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from .config import DEFAULT_URL, load_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def base_url(cfg: dict | None = None) -> str:
|
|
12
|
+
data = cfg or load_config()
|
|
13
|
+
return str(data.get("url") or DEFAULT_URL).rstrip("/")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def api(pathname: str, method: str = "GET", body=None, token=None, cfg=None, raw=False):
|
|
17
|
+
data = cfg or load_config()
|
|
18
|
+
tok = data.get("token") if token is None else token
|
|
19
|
+
url = f"{base_url(data)}/api{pathname}"
|
|
20
|
+
payload = None
|
|
21
|
+
headers = {}
|
|
22
|
+
if body is not None:
|
|
23
|
+
payload = json.dumps(body).encode("utf-8")
|
|
24
|
+
headers["Content-Type"] = "application/json"
|
|
25
|
+
if tok:
|
|
26
|
+
headers["Authorization"] = f"Bearer {tok}"
|
|
27
|
+
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
|
|
28
|
+
try:
|
|
29
|
+
with urllib.request.urlopen(req) as resp:
|
|
30
|
+
raw_bytes = resp.read()
|
|
31
|
+
if raw:
|
|
32
|
+
return raw_bytes
|
|
33
|
+
if not raw_bytes:
|
|
34
|
+
return {}
|
|
35
|
+
return json.loads(raw_bytes.decode("utf-8"))
|
|
36
|
+
except urllib.error.HTTPError as err:
|
|
37
|
+
text = err.read().decode("utf-8", errors="replace")
|
|
38
|
+
try:
|
|
39
|
+
parsed = json.loads(text) if text else {}
|
|
40
|
+
except Exception:
|
|
41
|
+
parsed = {"error": text}
|
|
42
|
+
raise RuntimeError(parsed.get("error") or f"HTTP {err.code}") from err
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from getpass import getpass
|
|
5
|
+
|
|
6
|
+
DEFAULT_THEME = {
|
|
7
|
+
"prompt": "\x1b[38;2;125;211;252m",
|
|
8
|
+
"option": "\x1b[38;2;226;232;240m",
|
|
9
|
+
"selected": "\x1b[48;2;14;165;233m\x1b[38;2;255;255;255m",
|
|
10
|
+
"success": "\x1b[38;2;74;222;128m",
|
|
11
|
+
"error": "\x1b[38;2;248;113;113m",
|
|
12
|
+
"loading": "\x1b[38;2;34;211;238m",
|
|
13
|
+
"progress": "\x1b[38;2;251;191;36m",
|
|
14
|
+
"stage": "\x1b[38;2;192;132;252m",
|
|
15
|
+
"muted": "\x1b[38;2;148;163;184m",
|
|
16
|
+
"reset": "\x1b[0m",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def c(kind: str, text: str) -> str:
|
|
21
|
+
return f"{DEFAULT_THEME.get(kind, '')}{text}{DEFAULT_THEME['reset']}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ask(label: str, secret: bool = False) -> str:
|
|
25
|
+
prompt = f"{c('prompt', label)} "
|
|
26
|
+
if secret:
|
|
27
|
+
return getpass(prompt).strip()
|
|
28
|
+
return input(prompt).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_key() -> str:
|
|
32
|
+
if sys.platform == "win32":
|
|
33
|
+
import msvcrt
|
|
34
|
+
|
|
35
|
+
ch = msvcrt.getwch()
|
|
36
|
+
if ch in ("\x00", "\xe0"):
|
|
37
|
+
extra = msvcrt.getwch()
|
|
38
|
+
return {"H": "up", "P": "down"}.get(extra, extra)
|
|
39
|
+
if ch in ("\r", "\n"):
|
|
40
|
+
return "enter"
|
|
41
|
+
if ch == "\x03":
|
|
42
|
+
raise KeyboardInterrupt
|
|
43
|
+
return ch
|
|
44
|
+
import tty
|
|
45
|
+
import termios
|
|
46
|
+
|
|
47
|
+
fd = sys.stdin.fileno()
|
|
48
|
+
old = termios.tcgetattr(fd)
|
|
49
|
+
try:
|
|
50
|
+
tty.setraw(fd)
|
|
51
|
+
ch = sys.stdin.read(1)
|
|
52
|
+
if ch == "\x1b":
|
|
53
|
+
seq = sys.stdin.read(2)
|
|
54
|
+
return {"[A": "up", "[B": "down"}.get(seq, seq)
|
|
55
|
+
if ch in ("\r", "\n"):
|
|
56
|
+
return "enter"
|
|
57
|
+
if ch == "\x03":
|
|
58
|
+
raise KeyboardInterrupt
|
|
59
|
+
return ch
|
|
60
|
+
finally:
|
|
61
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pick(items: list, title: str = "") -> object:
|
|
65
|
+
rows = []
|
|
66
|
+
for item in items:
|
|
67
|
+
if isinstance(item, dict):
|
|
68
|
+
rows.append({"label": str(item.get("label") or item.get("name") or item), "value": item.get("value", item)})
|
|
69
|
+
else:
|
|
70
|
+
rows.append({"label": str(item), "value": item})
|
|
71
|
+
if not rows:
|
|
72
|
+
raise RuntimeError("No options")
|
|
73
|
+
if len(rows) == 1 or not sys.stdin.isatty():
|
|
74
|
+
return rows[0]["value"]
|
|
75
|
+
if title:
|
|
76
|
+
print(c("muted", title))
|
|
77
|
+
index = 0
|
|
78
|
+
|
|
79
|
+
def render() -> None:
|
|
80
|
+
for i, row in enumerate(rows):
|
|
81
|
+
text = f" {row['label']} "
|
|
82
|
+
print(c("selected", text) if i == index else c("option", text))
|
|
83
|
+
|
|
84
|
+
render()
|
|
85
|
+
while True:
|
|
86
|
+
key = _read_key()
|
|
87
|
+
if key == "enter":
|
|
88
|
+
sys.stdout.write(f"\x1b[{len(rows)}A\x1b[0J")
|
|
89
|
+
print(f"{c('success', '›')} {rows[index]['label']}")
|
|
90
|
+
return rows[index]["value"]
|
|
91
|
+
if key == "up":
|
|
92
|
+
index = (index - 1) % len(rows)
|
|
93
|
+
elif key == "down":
|
|
94
|
+
index = (index + 1) % len(rows)
|
|
95
|
+
else:
|
|
96
|
+
continue
|
|
97
|
+
sys.stdout.write(f"\x1b[{len(rows)}A")
|
|
98
|
+
render()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def confirm() -> bool:
|
|
102
|
+
return pick(
|
|
103
|
+
[{"label": "Yes, log out", "value": True}, {"label": "Cancel", "value": False}],
|
|
104
|
+
title="Are you sure?",
|
|
105
|
+
) is True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def ok(msg: str) -> None:
|
|
109
|
+
print(c("success", msg))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fail(msg: str) -> None:
|
|
113
|
+
print(c("error", msg), file=sys.stderr)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def info(msg: str) -> None:
|
|
117
|
+
print(c("muted", msg))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def progress(percent: int, stage: str, message: str) -> None:
|
|
121
|
+
p = max(0, min(100, int(percent or 0)))
|
|
122
|
+
width = 24
|
|
123
|
+
filled = round((p / 100) * width)
|
|
124
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
125
|
+
line = f"{c('progress', f'{p}%')} {c('progress', bar)} {c('stage', stage or '')} {c('option', message or '')}"
|
|
126
|
+
sys.stdout.write(f"\r\x1b[2K{line}")
|
|
127
|
+
if p >= 100 or stage in ("done", "error"):
|
|
128
|
+
sys.stdout.write("\n")
|
|
129
|
+
sys.stdout.flush()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def prompt_line() -> str:
|
|
133
|
+
return f"{c('prompt', 'synic')}{c('muted', '> ')}"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: synic
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Synic panel CLI — login, deploy, downloads, backups, and custom flows
|
|
5
|
+
Author: Synic
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: synic,cli,jimboshop,panel
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# synic
|
|
12
|
+
|
|
13
|
+
Synic panel CLI for pip.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install synic
|
|
17
|
+
synic login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then the prompt becomes `synic>` (light blue). You stay signed in until `logout`.
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
synic> proDeploy
|
|
24
|
+
synic> uploadFile
|
|
25
|
+
synic> download
|
|
26
|
+
synic> makeBackup
|
|
27
|
+
synic> userDetail
|
|
28
|
+
synic> help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
npm: `npm install -g synic`
|
|
32
|
+
yarn: `yarn global add synic`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/synic/__init__.py
|
|
4
|
+
src/synic/cli.py
|
|
5
|
+
src/synic/config.py
|
|
6
|
+
src/synic/http.py
|
|
7
|
+
src/synic/ui.py
|
|
8
|
+
src/synic.egg-info/PKG-INFO
|
|
9
|
+
src/synic.egg-info/SOURCES.txt
|
|
10
|
+
src/synic.egg-info/dependency_links.txt
|
|
11
|
+
src/synic.egg-info/entry_points.txt
|
|
12
|
+
src/synic.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
synic
|