clilap-codepush 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clilap_codepush/__init__.py +2 -0
- clilap_codepush/api.py +119 -0
- clilap_codepush/cli.py +547 -0
- clilap_codepush/ui.py +248 -0
- clilap_codepush-1.0.0.dist-info/METADATA +41 -0
- clilap_codepush-1.0.0.dist-info/RECORD +9 -0
- clilap_codepush-1.0.0.dist-info/WHEEL +5 -0
- clilap_codepush-1.0.0.dist-info/entry_points.txt +3 -0
- clilap_codepush-1.0.0.dist-info/top_level.txt +1 -0
clilap_codepush/api.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""HTTP client for codepush.clilap.org API — stdlib only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json, os, urllib.request, urllib.parse, urllib.error
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
BASE_URL = os.environ.get("CODEPUSH_URL", "https://codepush.clilap.org")
|
|
7
|
+
ADMIN_URL = os.environ.get("CODEPUSH_ADMIN_URL", "https://admin.clilap.org")
|
|
8
|
+
|
|
9
|
+
class ApiError(Exception):
|
|
10
|
+
def __init__(self, msg: str, status: int = 0):
|
|
11
|
+
super().__init__(msg)
|
|
12
|
+
self.status = status
|
|
13
|
+
|
|
14
|
+
def _req(method: str, url: str, *, data: bytes | None = None,
|
|
15
|
+
headers: dict | None = None) -> tuple[int, bytes]:
|
|
16
|
+
req = urllib.request.Request(url, data=data, method=method,
|
|
17
|
+
headers=headers or {})
|
|
18
|
+
try:
|
|
19
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
20
|
+
return r.status, r.read()
|
|
21
|
+
except urllib.error.HTTPError as e:
|
|
22
|
+
return e.code, e.read()
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise ApiError(str(e))
|
|
25
|
+
|
|
26
|
+
def _json(method: str, url: str, **kw) -> Any:
|
|
27
|
+
status, body = _req(method, url, **kw)
|
|
28
|
+
try:
|
|
29
|
+
data = json.loads(body)
|
|
30
|
+
except Exception:
|
|
31
|
+
raise ApiError(body.decode(errors="replace"), status)
|
|
32
|
+
if status >= 400:
|
|
33
|
+
raise ApiError(data.get("error", str(data)), status)
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def upload(content: bytes, filename: str, *, ttl: int | None = None,
|
|
39
|
+
group: str | None = None, token: str | None = None) -> dict:
|
|
40
|
+
qs: dict[str, str] = {"filename": filename}
|
|
41
|
+
if ttl is not None: qs["ttl"] = str(ttl)
|
|
42
|
+
if group: qs["group"] = group
|
|
43
|
+
if token: qs["token"] = token
|
|
44
|
+
url = f"{BASE_URL}/paste?{urllib.parse.urlencode(qs)}"
|
|
45
|
+
hdrs = {"Content-Type": "application/octet-stream"}
|
|
46
|
+
return _json("POST", url, data=content, headers=hdrs)
|
|
47
|
+
|
|
48
|
+
def get_raw(paste_id: str) -> bytes:
|
|
49
|
+
url = f"{BASE_URL}/paste/{paste_id}/raw"
|
|
50
|
+
status, body = _req("GET", url)
|
|
51
|
+
if status >= 400:
|
|
52
|
+
raise ApiError(body.decode(errors="replace"), status)
|
|
53
|
+
return body
|
|
54
|
+
|
|
55
|
+
def delete_paste(paste_id: str, token: str) -> dict:
|
|
56
|
+
url = f"{BASE_URL}/paste/{paste_id}?token={urllib.parse.quote(token)}"
|
|
57
|
+
return _json("DELETE", url)
|
|
58
|
+
|
|
59
|
+
def health() -> dict:
|
|
60
|
+
return _json("GET", f"{BASE_URL}/health")
|
|
61
|
+
|
|
62
|
+
# ── Admin API ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
class AdminApi:
|
|
65
|
+
def __init__(self, token: str):
|
|
66
|
+
self.token = token
|
|
67
|
+
|
|
68
|
+
def _url(self, path: str, **extra) -> str:
|
|
69
|
+
params = {"token": self.token, **extra}
|
|
70
|
+
return f"{ADMIN_URL}/admin/cp{path}?{urllib.parse.urlencode(params)}"
|
|
71
|
+
|
|
72
|
+
def _get(self, path: str, **extra) -> Any:
|
|
73
|
+
return _json("GET", self._url(path, **extra))
|
|
74
|
+
|
|
75
|
+
def _delete(self, path: str) -> Any:
|
|
76
|
+
return _json("DELETE", self._url(path))
|
|
77
|
+
|
|
78
|
+
def _post(self, path: str, body: dict) -> Any:
|
|
79
|
+
data = json.dumps(body).encode()
|
|
80
|
+
url = self._url(path)
|
|
81
|
+
return _json("POST", url, data=data,
|
|
82
|
+
headers={"Content-Type": "application/json"})
|
|
83
|
+
|
|
84
|
+
def stats(self) -> dict:
|
|
85
|
+
return self._get("/stats")
|
|
86
|
+
|
|
87
|
+
def pastes(self, *, page: int = 1, limit: int = 20,
|
|
88
|
+
search: str = "") -> dict:
|
|
89
|
+
kw: dict = {"page": page, "limit": limit}
|
|
90
|
+
if search: kw["search"] = search
|
|
91
|
+
return self._get("/pastes", **kw)
|
|
92
|
+
|
|
93
|
+
def paste(self, pid: str) -> dict:
|
|
94
|
+
return self._get(f"/paste/{pid}")
|
|
95
|
+
|
|
96
|
+
def paste_content(self, pid: str) -> str:
|
|
97
|
+
status, body = _req("GET", self._url(f"/paste/{pid}/content"))
|
|
98
|
+
if status >= 400:
|
|
99
|
+
raise ApiError(body.decode(errors="replace"), status)
|
|
100
|
+
return body.decode(errors="replace")
|
|
101
|
+
|
|
102
|
+
def delete_paste(self, pid: str) -> dict:
|
|
103
|
+
return self._delete(f"/paste/{pid}")
|
|
104
|
+
|
|
105
|
+
def groups(self, *, page: int = 1, limit: int = 20) -> dict:
|
|
106
|
+
return self._get("/groups", page=page, limit=limit)
|
|
107
|
+
|
|
108
|
+
def group(self, gid: str) -> dict:
|
|
109
|
+
return self._get(f"/group/{gid}")
|
|
110
|
+
|
|
111
|
+
def delete_group(self, gid: str) -> dict:
|
|
112
|
+
return self._delete(f"/group/{gid}")
|
|
113
|
+
|
|
114
|
+
def purge(self, *, expired: bool = True, orphan: bool = False) -> dict:
|
|
115
|
+
kw: dict = {}
|
|
116
|
+
if expired: kw["expired"] = "1"
|
|
117
|
+
if orphan: kw["orphan"] = "1"
|
|
118
|
+
url = self._url("/purge", **kw)
|
|
119
|
+
return _json("POST", url, data=b"")
|
clilap_codepush/cli.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""clilap codepush CLI — interactive TUI entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys, os, json, pathlib, datetime, re
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from . import ui
|
|
7
|
+
from .api import (
|
|
8
|
+
AdminApi, ApiError, upload, get_raw, delete_paste, health,
|
|
9
|
+
BASE_URL, ADMIN_URL,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# ── Config ────────────────────────────────────────────────────────────────────
|
|
13
|
+
CONFIG_PATH = pathlib.Path.home() / ".config" / "clilap-codepush" / "config.json"
|
|
14
|
+
|
|
15
|
+
def _load_cfg() -> dict:
|
|
16
|
+
if CONFIG_PATH.exists():
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
def _save_cfg(cfg: dict) -> None:
|
|
24
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
|
26
|
+
|
|
27
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
28
|
+
R = ui.R; D = ui.D; BC = ui.BC; BG = ui.BG; BY = ui.BY; BW = ui.BW
|
|
29
|
+
BR = ui.BR; DC = ui.DC
|
|
30
|
+
|
|
31
|
+
def _ts(iso: str | None) -> str:
|
|
32
|
+
if not iso: return f"{D}—{R}"
|
|
33
|
+
try:
|
|
34
|
+
dt = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
35
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
36
|
+
except Exception:
|
|
37
|
+
return iso[:16]
|
|
38
|
+
|
|
39
|
+
def _size(n: int | None) -> str:
|
|
40
|
+
if n is None: return f"{D}—{R}"
|
|
41
|
+
if n < 1024: return f"{n}B"
|
|
42
|
+
if n < 1048576: return f"{n/1024:.1f}KB"
|
|
43
|
+
return f"{n/1048576:.1f}MB"
|
|
44
|
+
|
|
45
|
+
def _short(s: str | None, n: int = 8) -> str:
|
|
46
|
+
if not s: return f"{D}—{R}"
|
|
47
|
+
return s[:n]
|
|
48
|
+
|
|
49
|
+
# ── Screens ───────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def screen_setup() -> None:
|
|
52
|
+
ui.clear()
|
|
53
|
+
ui.wl(ui.header("clilap codepush セットアップ"))
|
|
54
|
+
ui.wl()
|
|
55
|
+
ui.wl(f" 管理者トークンを入力してください。")
|
|
56
|
+
ui.wl(f" {D}admin.clilap.org/cp?token=... のトークン{R}")
|
|
57
|
+
ui.wl()
|
|
58
|
+
ui.show_cursor()
|
|
59
|
+
token = input(f" {BC}トークン:{R} ").strip()
|
|
60
|
+
if not token:
|
|
61
|
+
ui.wl(f" {BR}キャンセル{R}")
|
|
62
|
+
return
|
|
63
|
+
cfg = _load_cfg()
|
|
64
|
+
cfg["admin_token"] = token
|
|
65
|
+
_save_cfg(cfg)
|
|
66
|
+
ui.wl(f" {BG}✓ 保存しました{R}: {CONFIG_PATH}")
|
|
67
|
+
|
|
68
|
+
def screen_health() -> None:
|
|
69
|
+
with ui.Spinner("health チェック中..."):
|
|
70
|
+
try:
|
|
71
|
+
data = health()
|
|
72
|
+
except ApiError as e:
|
|
73
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
74
|
+
return
|
|
75
|
+
ui.clear()
|
|
76
|
+
ui.wl(ui.sep())
|
|
77
|
+
ui.wl(f" {BC}Health Check{R}")
|
|
78
|
+
ui.wl(ui.div())
|
|
79
|
+
ui.wl(ui.detail_row("Status", f"{BG}OK{R}"))
|
|
80
|
+
for k, v in (data.items() if isinstance(data, dict) else []):
|
|
81
|
+
if k != "status":
|
|
82
|
+
ui.wl(ui.detail_row(k, str(v)))
|
|
83
|
+
ui.wl(ui.sep())
|
|
84
|
+
input(f" {D}Enterで戻る{R}")
|
|
85
|
+
|
|
86
|
+
def screen_upload(args_file: str | None = None) -> None:
|
|
87
|
+
ui.clear()
|
|
88
|
+
ui.wl(ui.sep())
|
|
89
|
+
ui.wl(f" {BC}ファイルアップロード{R}")
|
|
90
|
+
ui.wl(ui.div())
|
|
91
|
+
ui.show_cursor()
|
|
92
|
+
|
|
93
|
+
if args_file:
|
|
94
|
+
path = pathlib.Path(args_file)
|
|
95
|
+
else:
|
|
96
|
+
raw = input(f" {BC}ファイルパス:{R} ").strip()
|
|
97
|
+
path = pathlib.Path(raw)
|
|
98
|
+
|
|
99
|
+
if not path.exists():
|
|
100
|
+
ui.wl(f" {BR}✗ ファイルが見つかりません: {path}{R}")
|
|
101
|
+
input(f" {D}Enterで戻る{R}")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
filename = path.name
|
|
105
|
+
ttl_raw = input(f" {BC}TTL (秒, 空=無期限):{R} ").strip()
|
|
106
|
+
ttl = int(ttl_raw) if ttl_raw.isdigit() else None
|
|
107
|
+
group = input(f" {BC}グループID (空=なし):{R} ").strip() or None
|
|
108
|
+
cfg = _load_cfg()
|
|
109
|
+
token = cfg.get("admin_token") or input(f" {BC}トークン:{R} ").strip() or None
|
|
110
|
+
|
|
111
|
+
with ui.Spinner(f"アップロード中: {filename}"):
|
|
112
|
+
try:
|
|
113
|
+
result = upload(path.read_bytes(), filename, ttl=ttl, group=group, token=token)
|
|
114
|
+
except ApiError as e:
|
|
115
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
116
|
+
input(f" {D}Enterで戻る{R}")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
ui.wl(f" {BG}✓ アップロード完了{R}")
|
|
120
|
+
pid = result.get("id", "")
|
|
121
|
+
ui.wl(ui.detail_row("ID", pid))
|
|
122
|
+
ui.wl(ui.detail_row("URL", f"{BASE_URL}/paste/{pid}/raw"))
|
|
123
|
+
input(f" {D}Enterで戻る{R}")
|
|
124
|
+
|
|
125
|
+
def screen_get(args_id: str | None = None) -> None:
|
|
126
|
+
ui.clear()
|
|
127
|
+
ui.wl(ui.sep())
|
|
128
|
+
ui.wl(f" {BC}ペースト取得{R}")
|
|
129
|
+
ui.wl(ui.div())
|
|
130
|
+
ui.show_cursor()
|
|
131
|
+
|
|
132
|
+
pid = args_id or input(f" {BC}Paste ID:{R} ").strip()
|
|
133
|
+
if not pid:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
with ui.Spinner("取得中..."):
|
|
137
|
+
try:
|
|
138
|
+
data = get_raw(pid)
|
|
139
|
+
except ApiError as e:
|
|
140
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
141
|
+
input(f" {D}Enterで戻る{R}")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
out_raw = input(f" {BC}保存先ファイル (空=stdout):{R} ").strip()
|
|
145
|
+
if out_raw:
|
|
146
|
+
pathlib.Path(out_raw).write_bytes(data)
|
|
147
|
+
ui.wl(f" {BG}✓ 保存:{R} {out_raw}")
|
|
148
|
+
input(f" {D}Enterで戻る{R}")
|
|
149
|
+
else:
|
|
150
|
+
ui.clear()
|
|
151
|
+
sys.stdout.buffer.write(data)
|
|
152
|
+
sys.stdout.buffer.flush()
|
|
153
|
+
|
|
154
|
+
# ── Admin screens ─────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
def _get_admin(cfg: dict) -> AdminApi | None:
|
|
157
|
+
token = cfg.get("admin_token")
|
|
158
|
+
if not token:
|
|
159
|
+
ui.wl(f" {BR}管理者トークン未設定。/setup を先に実行してください。{R}")
|
|
160
|
+
input(f" {D}Enterで戻る{R}")
|
|
161
|
+
return None
|
|
162
|
+
return AdminApi(token)
|
|
163
|
+
|
|
164
|
+
def screen_stats(cfg: dict) -> None:
|
|
165
|
+
adm = _get_admin(cfg)
|
|
166
|
+
if not adm: return
|
|
167
|
+
with ui.Spinner("stats 取得中..."):
|
|
168
|
+
try:
|
|
169
|
+
data = adm.stats()
|
|
170
|
+
except ApiError as e:
|
|
171
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
172
|
+
input(f" {D}Enterで戻る{R}")
|
|
173
|
+
return
|
|
174
|
+
ui.clear()
|
|
175
|
+
ui.wl(ui.sep())
|
|
176
|
+
ui.wl(f" {BC}Stats{R}")
|
|
177
|
+
ui.wl(ui.div())
|
|
178
|
+
for k, v in (data.items() if isinstance(data, dict) else []):
|
|
179
|
+
ui.wl(ui.detail_row(k, str(v)))
|
|
180
|
+
ui.wl(ui.sep())
|
|
181
|
+
input(f" {D}Enterで戻る{R}")
|
|
182
|
+
|
|
183
|
+
def screen_pastes(cfg: dict) -> None:
|
|
184
|
+
adm = _get_admin(cfg)
|
|
185
|
+
if not adm: return
|
|
186
|
+
|
|
187
|
+
page = 1
|
|
188
|
+
page_size = 15
|
|
189
|
+
search = ""
|
|
190
|
+
|
|
191
|
+
while True:
|
|
192
|
+
with ui.Spinner("ペースト一覧取得中..."):
|
|
193
|
+
try:
|
|
194
|
+
data = adm.pastes(page=page, limit=page_size, search=search)
|
|
195
|
+
except ApiError as e:
|
|
196
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
197
|
+
input(f" {D}Enterで戻る{R}")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
items = data.get("pastes", [])
|
|
201
|
+
total = data.get("total", len(items))
|
|
202
|
+
|
|
203
|
+
cols = [
|
|
204
|
+
{"header": "ID", "width": 10, "render": lambda x, s: f"{DC}{x.get('id','')[:8]}{R}"},
|
|
205
|
+
{"header": "ファイル名", "width": 24, "render": lambda x, s: x.get("filename","")[:24]},
|
|
206
|
+
{"header": "サイズ", "width": 8, "render": lambda x, s: _size(x.get("size"))},
|
|
207
|
+
{"header": "作成日時", "width": 17, "render": lambda x, s: _ts(x.get("created_at"))},
|
|
208
|
+
{"header": "有効期限", "width": 17, "render": lambda x, s: _ts(x.get("expires_at")) if x.get("expires_at") else f"{D}無期限{R}"},
|
|
209
|
+
]
|
|
210
|
+
extra = [
|
|
211
|
+
{"key": "d", "label": "削除", "action": "delete"},
|
|
212
|
+
{"key": "/", "label": "検索", "action": "search"},
|
|
213
|
+
]
|
|
214
|
+
r = ui.table(
|
|
215
|
+
f"ペースト一覧 検索:{search or '—'}",
|
|
216
|
+
items, cols,
|
|
217
|
+
page=page, total=total, page_size=page_size,
|
|
218
|
+
extra_keys=extra,
|
|
219
|
+
hint="Enter 詳細 d 削除 / 検索 n/p ページ",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if r.action == "quit": return
|
|
223
|
+
if r.action == "back": return
|
|
224
|
+
if r.action == "next": page += 1
|
|
225
|
+
if r.action == "prev" and page > 1: page -= 1
|
|
226
|
+
if r.action == "refresh": pass
|
|
227
|
+
if r.action == "search":
|
|
228
|
+
ui.show_cursor()
|
|
229
|
+
search = input(f" {BC}検索キーワード:{R} ").strip()
|
|
230
|
+
page = 1
|
|
231
|
+
if r.action == "select" and r.item:
|
|
232
|
+
screen_paste_detail(adm, r.item.get("id", ""))
|
|
233
|
+
if r.action == "delete" and r.item:
|
|
234
|
+
pid = r.item.get("id", "")
|
|
235
|
+
if ui.confirm(f"削除: {pid[:8]}...?"):
|
|
236
|
+
try:
|
|
237
|
+
adm.delete_paste(pid)
|
|
238
|
+
ui.wl(f" {BG}✓ 削除完了{R}")
|
|
239
|
+
except ApiError as e:
|
|
240
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
241
|
+
import time; time.sleep(0.8)
|
|
242
|
+
|
|
243
|
+
def screen_paste_detail(adm: AdminApi, pid: str) -> None:
|
|
244
|
+
with ui.Spinner("ペースト詳細取得中..."):
|
|
245
|
+
try:
|
|
246
|
+
data = adm.paste(pid)
|
|
247
|
+
except ApiError as e:
|
|
248
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
249
|
+
input(f" {D}Enterで戻る{R}")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
while True:
|
|
253
|
+
ui.clear()
|
|
254
|
+
ui.wl(ui.sep())
|
|
255
|
+
ui.wl(f" {BC}ペースト詳細{R}")
|
|
256
|
+
ui.wl(ui.div())
|
|
257
|
+
ui.wl(ui.detail_row("ID", data.get("id", "")))
|
|
258
|
+
ui.wl(ui.detail_row("ファイル名", data.get("filename", "")))
|
|
259
|
+
ui.wl(ui.detail_row("サイズ", _size(data.get("size"))))
|
|
260
|
+
ui.wl(ui.detail_row("グループ", data.get("group_id") or f"{D}—{R}"))
|
|
261
|
+
ui.wl(ui.detail_row("作成日時", _ts(data.get("created_at"))))
|
|
262
|
+
ui.wl(ui.detail_row("有効期限", _ts(data.get("expires_at")) if data.get("expires_at") else f"{D}無期限{R}"))
|
|
263
|
+
ui.wl(ui.detail_row("RAW URL", f"{BASE_URL}/paste/{data.get('id','')}/raw"))
|
|
264
|
+
ui.wl(ui.sep())
|
|
265
|
+
ui.wl(f" {D}c コンテンツ表示 d 削除 q 戻る{R}")
|
|
266
|
+
|
|
267
|
+
key = ui.getch()
|
|
268
|
+
if key in ("q", "esc", "ctrl_c"): return
|
|
269
|
+
if key == "d":
|
|
270
|
+
if ui.confirm("このペーストを削除しますか?"):
|
|
271
|
+
try:
|
|
272
|
+
adm.delete_paste(pid)
|
|
273
|
+
ui.wl(f" {BG}✓ 削除完了{R}")
|
|
274
|
+
import time; time.sleep(0.8)
|
|
275
|
+
return
|
|
276
|
+
except ApiError as e:
|
|
277
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
278
|
+
import time; time.sleep(1)
|
|
279
|
+
if key == "c":
|
|
280
|
+
screen_paste_content(adm, pid, data.get("filename", ""))
|
|
281
|
+
|
|
282
|
+
def screen_paste_content(adm: AdminApi, pid: str, filename: str) -> None:
|
|
283
|
+
with ui.Spinner("コンテンツ取得中..."):
|
|
284
|
+
try:
|
|
285
|
+
content = adm.paste_content(pid)
|
|
286
|
+
except ApiError as e:
|
|
287
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
288
|
+
input(f" {D}Enterで戻る{R}")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
ui.clear()
|
|
292
|
+
ui.wl(ui.sep())
|
|
293
|
+
ui.wl(f" {BC}{filename}{R} {D}({pid[:8]}){R}")
|
|
294
|
+
ui.wl(ui.div())
|
|
295
|
+
lines = content.splitlines()
|
|
296
|
+
visible = ui.rows() - 8
|
|
297
|
+
for line in lines[:visible]:
|
|
298
|
+
ui.wl(" " + line[:ui.cols()-4])
|
|
299
|
+
if len(lines) > visible:
|
|
300
|
+
ui.wl(f" {D}... 残り {len(lines)-visible} 行 (保存して全文を確認){R}")
|
|
301
|
+
ui.wl(ui.sep())
|
|
302
|
+
ui.show_cursor()
|
|
303
|
+
save = input(f" {D}保存先ファイル (空=スキップ):{R} ").strip()
|
|
304
|
+
if save:
|
|
305
|
+
pathlib.Path(save).write_text(content)
|
|
306
|
+
ui.wl(f" {BG}✓ 保存:{R} {save}")
|
|
307
|
+
input(f" {D}Enterで戻る{R}")
|
|
308
|
+
|
|
309
|
+
def screen_groups(cfg: dict) -> None:
|
|
310
|
+
adm = _get_admin(cfg)
|
|
311
|
+
if not adm: return
|
|
312
|
+
|
|
313
|
+
page = 1
|
|
314
|
+
page_size = 15
|
|
315
|
+
|
|
316
|
+
while True:
|
|
317
|
+
with ui.Spinner("グループ一覧取得中..."):
|
|
318
|
+
try:
|
|
319
|
+
data = adm.groups(page=page, limit=page_size)
|
|
320
|
+
except ApiError as e:
|
|
321
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
322
|
+
input(f" {D}Enterで戻る{R}")
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
items = data.get("groups", [])
|
|
326
|
+
total = data.get("total", len(items))
|
|
327
|
+
|
|
328
|
+
cols = [
|
|
329
|
+
{"header": "グループID", "width": 12, "render": lambda x, s: f"{DC}{x.get('group_id','')[:10]}{R}"},
|
|
330
|
+
{"header": "ファイル数", "width": 8, "render": lambda x, s: str(x.get("count", 0))},
|
|
331
|
+
{"header": "合計サイズ", "width": 10, "render": lambda x, s: _size(x.get("total_size"))},
|
|
332
|
+
{"header": "作成日時", "width": 17, "render": lambda x, s: _ts(x.get("created_at"))},
|
|
333
|
+
]
|
|
334
|
+
extra = [
|
|
335
|
+
{"key": "d", "label": "削除", "action": "delete"},
|
|
336
|
+
]
|
|
337
|
+
r = ui.table(
|
|
338
|
+
"グループ一覧",
|
|
339
|
+
items, cols,
|
|
340
|
+
page=page, total=total, page_size=page_size,
|
|
341
|
+
extra_keys=extra,
|
|
342
|
+
hint="Enter 詳細 d 削除 n/p ページ",
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if r.action in ("quit", "back"): return
|
|
346
|
+
if r.action == "next": page += 1
|
|
347
|
+
if r.action == "prev" and page > 1: page -= 1
|
|
348
|
+
if r.action == "refresh": pass
|
|
349
|
+
if r.action == "select" and r.item:
|
|
350
|
+
screen_group_detail(adm, r.item.get("group_id", ""))
|
|
351
|
+
if r.action == "delete" and r.item:
|
|
352
|
+
gid = r.item.get("group_id", "")
|
|
353
|
+
if ui.confirm(f"グループ全体を削除: {gid}?"):
|
|
354
|
+
try:
|
|
355
|
+
adm.delete_group(gid)
|
|
356
|
+
ui.wl(f" {BG}✓ 削除完了{R}")
|
|
357
|
+
except ApiError as e:
|
|
358
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
359
|
+
import time; time.sleep(0.8)
|
|
360
|
+
|
|
361
|
+
def screen_group_detail(adm: AdminApi, gid: str) -> None:
|
|
362
|
+
with ui.Spinner("グループ詳細取得中..."):
|
|
363
|
+
try:
|
|
364
|
+
data = adm.group(gid)
|
|
365
|
+
except ApiError as e:
|
|
366
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
367
|
+
input(f" {D}Enterで戻る{R}")
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
pastes = data.get("pastes", [])
|
|
371
|
+
|
|
372
|
+
while True:
|
|
373
|
+
cols = [
|
|
374
|
+
{"header": "ID", "width": 10, "render": lambda x, s: f"{DC}{x.get('id','')[:8]}{R}"},
|
|
375
|
+
{"header": "ファイル名", "width": 30, "render": lambda x, s: x.get("filename","")[:30]},
|
|
376
|
+
{"header": "サイズ", "width": 8, "render": lambda x, s: _size(x.get("size"))},
|
|
377
|
+
]
|
|
378
|
+
extra = [
|
|
379
|
+
{"key": "d", "label": "削除", "action": "delete"},
|
|
380
|
+
{"key": "D", "label": "グループ全体削除", "action": "delete_group"},
|
|
381
|
+
]
|
|
382
|
+
r = ui.table(
|
|
383
|
+
f"グループ: {gid} ({len(pastes)} ファイル)",
|
|
384
|
+
pastes, cols,
|
|
385
|
+
extra_keys=extra,
|
|
386
|
+
hint="Enter 詳細 d 削除 D グループ削除 q 戻る",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
if r.action in ("quit", "back"): return
|
|
390
|
+
if r.action == "refresh": pass
|
|
391
|
+
if r.action == "select" and r.item:
|
|
392
|
+
screen_paste_detail(adm, r.item.get("id", ""))
|
|
393
|
+
# refresh after potential delete
|
|
394
|
+
try:
|
|
395
|
+
data = adm.group(gid)
|
|
396
|
+
pastes = data.get("pastes", [])
|
|
397
|
+
except ApiError:
|
|
398
|
+
return
|
|
399
|
+
if r.action == "delete" and r.item:
|
|
400
|
+
pid = r.item.get("id", "")
|
|
401
|
+
if ui.confirm(f"削除: {pid[:8]}...?"):
|
|
402
|
+
try:
|
|
403
|
+
adm.delete_paste(pid)
|
|
404
|
+
pastes = [p for p in pastes if p.get("id") != pid]
|
|
405
|
+
ui.wl(f" {BG}✓ 削除完了{R}")
|
|
406
|
+
except ApiError as e:
|
|
407
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
408
|
+
import time; time.sleep(0.6)
|
|
409
|
+
if r.action == "delete_group":
|
|
410
|
+
if ui.confirm(f"グループ全体を削除: {gid}?"):
|
|
411
|
+
try:
|
|
412
|
+
adm.delete_group(gid)
|
|
413
|
+
ui.wl(f" {BG}✓ グループ削除完了{R}")
|
|
414
|
+
import time; time.sleep(0.8)
|
|
415
|
+
return
|
|
416
|
+
except ApiError as e:
|
|
417
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
418
|
+
import time; time.sleep(1)
|
|
419
|
+
|
|
420
|
+
def screen_purge(cfg: dict) -> None:
|
|
421
|
+
adm = _get_admin(cfg)
|
|
422
|
+
if not adm: return
|
|
423
|
+
ui.clear()
|
|
424
|
+
ui.wl(ui.sep())
|
|
425
|
+
ui.wl(f" {BC}Purge{R} {BR}不要データ削除{R}")
|
|
426
|
+
ui.wl(ui.div())
|
|
427
|
+
expired = ui.confirm("期限切れペーストを削除しますか?")
|
|
428
|
+
orphan = ui.confirm("孤立ファイルを削除しますか?")
|
|
429
|
+
if not expired and not orphan:
|
|
430
|
+
ui.wl(f" {D}キャンセル{R}")
|
|
431
|
+
input(f" {D}Enterで戻る{R}")
|
|
432
|
+
return
|
|
433
|
+
with ui.Spinner("Purge 実行中..."):
|
|
434
|
+
try:
|
|
435
|
+
result = adm.purge(expired=expired, orphan=orphan)
|
|
436
|
+
except ApiError as e:
|
|
437
|
+
ui.wl(f" {BR}✗ {e}{R}")
|
|
438
|
+
input(f" {D}Enterで戻る{R}")
|
|
439
|
+
return
|
|
440
|
+
ui.wl(f" {BG}✓ 完了{R}")
|
|
441
|
+
for k, v in (result.items() if isinstance(result, dict) else []):
|
|
442
|
+
ui.wl(ui.detail_row(k, str(v)))
|
|
443
|
+
input(f" {D}Enterで戻る{R}")
|
|
444
|
+
|
|
445
|
+
# ── Main menu ─────────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
MAIN_ITEMS = [
|
|
448
|
+
{"label": "アップロード", "value": "upload", "hint": "ファイルをアップロード"},
|
|
449
|
+
{"label": "ダウンロード / 表示", "value": "get", "hint": "ペーストを取得"},
|
|
450
|
+
{"label": "Health チェック", "value": "health", "hint": "サーバー状態確認"},
|
|
451
|
+
{"label": "─────────────────", "value": "__sep__"},
|
|
452
|
+
{"label": "管理: Stats", "value": "stats", "hint": "統計情報"},
|
|
453
|
+
{"label": "管理: ペースト一覧", "value": "pastes", "hint": "ペースト管理"},
|
|
454
|
+
{"label": "管理: グループ一覧", "value": "groups", "hint": "グループ管理"},
|
|
455
|
+
{"label": "管理: Purge", "value": "purge", "hint": "不要データ削除", "danger": True},
|
|
456
|
+
{"label": "─────────────────", "value": "__sep__"},
|
|
457
|
+
{"label": "セットアップ", "value": "setup", "hint": "トークン設定"},
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
def _run_action(action: str, cfg: dict) -> None:
|
|
461
|
+
if action == "upload": screen_upload()
|
|
462
|
+
elif action == "get": screen_get()
|
|
463
|
+
elif action == "health": screen_health()
|
|
464
|
+
elif action == "stats": screen_stats(cfg)
|
|
465
|
+
elif action == "pastes": screen_pastes(cfg)
|
|
466
|
+
elif action == "groups": screen_groups(cfg)
|
|
467
|
+
elif action == "purge": screen_purge(cfg)
|
|
468
|
+
elif action == "setup": screen_setup()
|
|
469
|
+
|
|
470
|
+
def interactive_menu() -> None:
|
|
471
|
+
items = [i for i in MAIN_ITEMS if i["value"] != "__sep__"]
|
|
472
|
+
cfg = _load_cfg()
|
|
473
|
+
while True:
|
|
474
|
+
action = ui.menu(
|
|
475
|
+
f"clilap codepush {DC}v{__version__}{R}",
|
|
476
|
+
items,
|
|
477
|
+
)
|
|
478
|
+
if action is None:
|
|
479
|
+
ui.clear()
|
|
480
|
+
ui.show_cursor()
|
|
481
|
+
return
|
|
482
|
+
cfg = _load_cfg()
|
|
483
|
+
_run_action(action, cfg)
|
|
484
|
+
|
|
485
|
+
# ── CLI entrypoint ────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
def _print_help() -> None:
|
|
488
|
+
ui.wl(f"""\
|
|
489
|
+
{BC}clilap codepush{R} {D}v{__version__}{R}
|
|
490
|
+
|
|
491
|
+
Usage:
|
|
492
|
+
codepush [command] [args]
|
|
493
|
+
|
|
494
|
+
Commands:
|
|
495
|
+
(none) インタラクティブメニュー起動
|
|
496
|
+
upload <file> ファイルをアップロード
|
|
497
|
+
get <id> ペーストを stdout に出力
|
|
498
|
+
health サーバー状態確認
|
|
499
|
+
setup 管理者トークンを設定
|
|
500
|
+
stats 管理: 統計情報
|
|
501
|
+
pastes 管理: ペースト一覧
|
|
502
|
+
groups 管理: グループ一覧
|
|
503
|
+
purge 管理: 不要データ削除
|
|
504
|
+
help, --help このヘルプを表示
|
|
505
|
+
|
|
506
|
+
Environment:
|
|
507
|
+
CODEPUSH_URL API ベース URL (default: {BASE_URL})
|
|
508
|
+
CODEPUSH_ADMIN_URL 管理 URL (default: {ADMIN_URL})
|
|
509
|
+
""")
|
|
510
|
+
|
|
511
|
+
def main() -> None:
|
|
512
|
+
args = sys.argv[1:]
|
|
513
|
+
|
|
514
|
+
if not args:
|
|
515
|
+
# Windows compat: tty unavailable
|
|
516
|
+
try:
|
|
517
|
+
import tty, termios
|
|
518
|
+
interactive_menu()
|
|
519
|
+
except Exception:
|
|
520
|
+
_print_help()
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
cmd = args[0].lower()
|
|
524
|
+
cfg = _load_cfg()
|
|
525
|
+
|
|
526
|
+
if cmd in ("help", "--help", "-h"):
|
|
527
|
+
_print_help()
|
|
528
|
+
elif cmd == "setup":
|
|
529
|
+
screen_setup()
|
|
530
|
+
elif cmd == "health":
|
|
531
|
+
screen_health()
|
|
532
|
+
elif cmd == "upload":
|
|
533
|
+
screen_upload(args[1] if len(args) > 1 else None)
|
|
534
|
+
elif cmd == "get":
|
|
535
|
+
screen_get(args[1] if len(args) > 1 else None)
|
|
536
|
+
elif cmd == "stats":
|
|
537
|
+
screen_stats(cfg)
|
|
538
|
+
elif cmd == "pastes":
|
|
539
|
+
screen_pastes(cfg)
|
|
540
|
+
elif cmd == "groups":
|
|
541
|
+
screen_groups(cfg)
|
|
542
|
+
elif cmd == "purge":
|
|
543
|
+
screen_purge(cfg)
|
|
544
|
+
else:
|
|
545
|
+
ui.wl(f" {BR}不明なコマンド: {cmd}{R}")
|
|
546
|
+
_print_help()
|
|
547
|
+
sys.exit(1)
|
clilap_codepush/ui.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Terminal UI primitives — pure stdlib, no dependencies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys, os, shutil, tty, termios
|
|
4
|
+
from typing import Any, Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
# ── ANSI ──────────────────────────────────────────────────────────────────────
|
|
9
|
+
R = "\x1b[0m"
|
|
10
|
+
D = "\x1b[2m"
|
|
11
|
+
DC = "\x1b[2;36m"
|
|
12
|
+
BC = "\x1b[1;36m"
|
|
13
|
+
BG = "\x1b[1;32m"
|
|
14
|
+
BY = "\x1b[1;33m"
|
|
15
|
+
BW = "\x1b[1;37m"
|
|
16
|
+
BR = "\x1b[1;31m"
|
|
17
|
+
BB = "\x1b[1;34m"
|
|
18
|
+
SEL = "\x1b[48;5;24m" # selected row bg
|
|
19
|
+
|
|
20
|
+
def _strip(s: str) -> str:
|
|
21
|
+
import re
|
|
22
|
+
return re.sub(r'\x1b\[[0-9;]*m', '', s)
|
|
23
|
+
|
|
24
|
+
def _pad(s: str, n: int) -> str:
|
|
25
|
+
extra = len(s) - len(_strip(s))
|
|
26
|
+
return s.ljust(n + extra)
|
|
27
|
+
|
|
28
|
+
def cols() -> int: return shutil.get_terminal_size().columns
|
|
29
|
+
def rows() -> int: return shutil.get_terminal_size().lines
|
|
30
|
+
|
|
31
|
+
def w(s: str) -> None: sys.stdout.write(s); sys.stdout.flush()
|
|
32
|
+
def wl(s: str = "") -> None: sys.stdout.write(s + "\n"); sys.stdout.flush()
|
|
33
|
+
|
|
34
|
+
def clear() -> None: w("\x1b[2J\x1b[H")
|
|
35
|
+
def hide_cursor() -> None: w("\x1b[?25l")
|
|
36
|
+
def show_cursor() -> None: w("\x1b[?25h")
|
|
37
|
+
def clear_line() -> None: w("\x1b[2K\r")
|
|
38
|
+
|
|
39
|
+
def sep(ch: str = "═") -> str:
|
|
40
|
+
return DC + ch * min(cols(), 90) + R
|
|
41
|
+
|
|
42
|
+
def div(ch: str = "─") -> str:
|
|
43
|
+
return DC + " " + ch * min(cols() - 2, 88) + R
|
|
44
|
+
|
|
45
|
+
def header(title: str) -> str:
|
|
46
|
+
width = min(cols(), 90)
|
|
47
|
+
inner = f" {title} "
|
|
48
|
+
lp = (width - len(inner)) // 2
|
|
49
|
+
rp = width - len(inner) - lp
|
|
50
|
+
lines = [
|
|
51
|
+
DC + "╔" + "═" * (width - 2) + "╗" + R,
|
|
52
|
+
DC + "║" + R + " " * (lp - 1) + BC + inner + R + " " * (rp - 1) + DC + "║" + R,
|
|
53
|
+
DC + "╚" + "═" * (width - 2) + "╝" + R,
|
|
54
|
+
]
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
# ── Keypress (Unix only) ──────────────────────────────────────────────────────
|
|
58
|
+
_KEY_MAP = {
|
|
59
|
+
"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left",
|
|
60
|
+
"\r": "enter", "\n": "enter", "\x7f": "backspace", "\x1b": "esc",
|
|
61
|
+
"\x03": "ctrl_c", "\x04": "ctrl_d",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def getch() -> str:
|
|
65
|
+
fd = sys.stdin.fileno()
|
|
66
|
+
old = termios.tcgetattr(fd)
|
|
67
|
+
try:
|
|
68
|
+
tty.setraw(fd)
|
|
69
|
+
ch = sys.stdin.read(1)
|
|
70
|
+
if ch == "\x1b":
|
|
71
|
+
# read up to 4 more bytes for escape sequences
|
|
72
|
+
seq = ch
|
|
73
|
+
try:
|
|
74
|
+
tty.setraw(fd)
|
|
75
|
+
for _ in range(4):
|
|
76
|
+
c = sys.stdin.read(1)
|
|
77
|
+
seq += c
|
|
78
|
+
if seq in _KEY_MAP or (len(seq) > 2 and c.isalpha()):
|
|
79
|
+
break
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return _KEY_MAP.get(seq, seq)
|
|
83
|
+
return _KEY_MAP.get(ch, ch)
|
|
84
|
+
finally:
|
|
85
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
86
|
+
|
|
87
|
+
def readline_prompt(prompt: str, default: str = "") -> str:
|
|
88
|
+
show_cursor()
|
|
89
|
+
w(prompt)
|
|
90
|
+
if default:
|
|
91
|
+
w(default)
|
|
92
|
+
sys.stdout.flush()
|
|
93
|
+
line = sys.stdin.readline().rstrip("\n")
|
|
94
|
+
return line if line else default
|
|
95
|
+
|
|
96
|
+
def confirm(msg: str) -> bool:
|
|
97
|
+
w(f" {BY}?{R} {msg} {D}[y/N]{R} ")
|
|
98
|
+
sys.stdout.flush()
|
|
99
|
+
try:
|
|
100
|
+
key = getch()
|
|
101
|
+
except Exception:
|
|
102
|
+
key = input()
|
|
103
|
+
result = key.lower() == "y"
|
|
104
|
+
wl(f"{BG}y{R}" if result else f"{BR}n{R}")
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
# ── Spinner ───────────────────────────────────────────────────────────────────
|
|
108
|
+
import threading, time
|
|
109
|
+
|
|
110
|
+
FRAMES = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]
|
|
111
|
+
|
|
112
|
+
class Spinner:
|
|
113
|
+
def __init__(self, msg: str):
|
|
114
|
+
self.msg = msg
|
|
115
|
+
self._stop = threading.Event()
|
|
116
|
+
self._t = threading.Thread(target=self._run, daemon=True)
|
|
117
|
+
|
|
118
|
+
def _run(self):
|
|
119
|
+
i = 0
|
|
120
|
+
hide_cursor()
|
|
121
|
+
while not self._stop.is_set():
|
|
122
|
+
clear_line()
|
|
123
|
+
w(f" {BC}{FRAMES[i % len(FRAMES)]}{R} {self.msg}")
|
|
124
|
+
i += 1
|
|
125
|
+
time.sleep(0.08)
|
|
126
|
+
|
|
127
|
+
def __enter__(self):
|
|
128
|
+
self._t.start()
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *_):
|
|
132
|
+
self._stop.set()
|
|
133
|
+
self._t.join()
|
|
134
|
+
clear_line()
|
|
135
|
+
show_cursor()
|
|
136
|
+
|
|
137
|
+
# ── Menu ──────────────────────────────────────────────────────────────────────
|
|
138
|
+
def menu(title: str, items: list[dict], back: bool = False) -> str | None:
|
|
139
|
+
"""Arrow-key driven menu. Returns selected value or None (back/quit)."""
|
|
140
|
+
opts = list(items)
|
|
141
|
+
if back:
|
|
142
|
+
opts.append({"label": "← 戻る", "value": "__back__"})
|
|
143
|
+
|
|
144
|
+
idx = 0
|
|
145
|
+
|
|
146
|
+
def draw():
|
|
147
|
+
clear()
|
|
148
|
+
wl(sep())
|
|
149
|
+
wl(f" {BC}{title}{R}")
|
|
150
|
+
wl(div())
|
|
151
|
+
for i, item in enumerate(opts):
|
|
152
|
+
sel = i == idx
|
|
153
|
+
arrow = f"{BC}▶{R}" if sel else " "
|
|
154
|
+
bg = SEL if sel else ""
|
|
155
|
+
end = R if sel else ""
|
|
156
|
+
label = item["label"]
|
|
157
|
+
if item.get("danger"):
|
|
158
|
+
label = f"{BR}{label}{R}"
|
|
159
|
+
hint = f" {D}{item['hint']}{R}" if item.get("hint") else ""
|
|
160
|
+
wl(f" {arrow} {bg}{BW if sel else ''}{label}{end}{hint}")
|
|
161
|
+
wl(sep())
|
|
162
|
+
wl(f" {D}↑↓ 移動 Enter 選択 q 終了{R}")
|
|
163
|
+
|
|
164
|
+
hide_cursor()
|
|
165
|
+
draw()
|
|
166
|
+
try:
|
|
167
|
+
while True:
|
|
168
|
+
key = getch()
|
|
169
|
+
if key in ("ctrl_c", "ctrl_d", "q"):
|
|
170
|
+
return None
|
|
171
|
+
if key == "up" and idx > 0: idx -= 1; draw()
|
|
172
|
+
elif key == "down" and idx < len(opts)-1: idx += 1; draw()
|
|
173
|
+
elif key == "enter":
|
|
174
|
+
val = opts[idx]["value"]
|
|
175
|
+
return None if val == "__back__" else val
|
|
176
|
+
finally:
|
|
177
|
+
show_cursor()
|
|
178
|
+
|
|
179
|
+
# ── Table ─────────────────────────────────────────────────────────────────────
|
|
180
|
+
class TableResult:
|
|
181
|
+
def __init__(self, action: str, item: Any = None):
|
|
182
|
+
self.action = action
|
|
183
|
+
self.item = item
|
|
184
|
+
|
|
185
|
+
def table(
|
|
186
|
+
title: str,
|
|
187
|
+
items: list[T],
|
|
188
|
+
columns: list[dict],
|
|
189
|
+
*,
|
|
190
|
+
page: int = 1,
|
|
191
|
+
total: int | None = None,
|
|
192
|
+
page_size: int = 15,
|
|
193
|
+
extra_keys: list[dict] | None = None,
|
|
194
|
+
hint: str = "",
|
|
195
|
+
) -> TableResult:
|
|
196
|
+
idx = 0
|
|
197
|
+
|
|
198
|
+
def draw():
|
|
199
|
+
clear()
|
|
200
|
+
wl(sep())
|
|
201
|
+
pinfo = f" {D}{page}/{max(1,(total or 0+page_size-1)//page_size)} ページ 合計: {total}{R}" if total is not None else ""
|
|
202
|
+
wl(f" {BC}{title}{R}{pinfo}")
|
|
203
|
+
wl(div())
|
|
204
|
+
hdr = " " + " ".join(_pad(f"{BW}{c['header']}{R}", c["width"]) for c in columns)
|
|
205
|
+
wl(hdr)
|
|
206
|
+
wl(div("─"))
|
|
207
|
+
if not items:
|
|
208
|
+
wl(f" {D}(データなし){R}")
|
|
209
|
+
for i, item in enumerate(items):
|
|
210
|
+
sel = i == idx
|
|
211
|
+
bg = SEL if sel else ""
|
|
212
|
+
end = R if sel else ""
|
|
213
|
+
arrow = f"{BC}▶{R}" if sel else " "
|
|
214
|
+
cells = " ".join(_pad(c["render"](item, sel), c["width"]) for c in columns)
|
|
215
|
+
wl(f"{arrow} {bg}{cells}{end}")
|
|
216
|
+
wl(sep())
|
|
217
|
+
keys_parts = ["↑↓ 移動", "Enter 選択"]
|
|
218
|
+
if total and page > 1: keys_parts.append("p 前")
|
|
219
|
+
if total and page * page_size < total: keys_parts.append("n 次")
|
|
220
|
+
for ek in (extra_keys or []):
|
|
221
|
+
keys_parts.append(f"{ek['key']} {ek['label']}")
|
|
222
|
+
keys_parts += ["r 更新", "q 戻る"]
|
|
223
|
+
wl(f" {D}{' '.join(keys_parts)}{R}")
|
|
224
|
+
if hint:
|
|
225
|
+
wl(f" {D}{hint}{R}")
|
|
226
|
+
|
|
227
|
+
hide_cursor()
|
|
228
|
+
draw()
|
|
229
|
+
try:
|
|
230
|
+
while True:
|
|
231
|
+
key = getch()
|
|
232
|
+
if key == "ctrl_c": return TableResult("quit")
|
|
233
|
+
if key == "q": return TableResult("back")
|
|
234
|
+
if key == "r": return TableResult("refresh")
|
|
235
|
+
if key == "n": return TableResult("next")
|
|
236
|
+
if key == "p": return TableResult("prev")
|
|
237
|
+
if key == "up" and idx > 0: idx -= 1; draw()
|
|
238
|
+
elif key == "down" and idx < len(items)-1: idx += 1; draw()
|
|
239
|
+
elif key == "enter" and items:
|
|
240
|
+
return TableResult("select", items[idx])
|
|
241
|
+
for ek in (extra_keys or []):
|
|
242
|
+
if key == ek["key"]:
|
|
243
|
+
return TableResult(ek["action"], items[idx] if items else None)
|
|
244
|
+
finally:
|
|
245
|
+
show_cursor()
|
|
246
|
+
|
|
247
|
+
def detail_row(label: str, value: str) -> str:
|
|
248
|
+
return f" {D}{label:<14}{R} {value}"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clilap-codepush
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: clilap.org codepush — CLI client with interactive TUI
|
|
5
|
+
Author-email: Lapius7 <20071209ryo@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://clilap.org/codepush
|
|
8
|
+
Project-URL: Repository, https://github.com/Lapius7/clilap-codepush
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# clilap-codepush
|
|
13
|
+
|
|
14
|
+
[clilap.org](https://clilap.org) codepush サービスの CLI クライアント。
|
|
15
|
+
|
|
16
|
+
## インストール
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install clilap-codepush
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 使い方
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
codepush # インタラクティブメニュー
|
|
26
|
+
codepush upload file.py
|
|
27
|
+
codepush get <id>
|
|
28
|
+
codepush setup # 管理者トークン設定
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`cp` コマンドも同様に使えます(システムの cp と競合する場合は `codepush` を推奨)。
|
|
32
|
+
|
|
33
|
+
## 管理機能
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
codepush setup # トークン設定
|
|
37
|
+
codepush stats # 統計
|
|
38
|
+
codepush pastes # ペースト一覧
|
|
39
|
+
codepush groups # グループ一覧
|
|
40
|
+
codepush purge # 不要データ削除
|
|
41
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
clilap_codepush/__init__.py,sha256=8kMIiT0z_7MFnoIw6oE_Fh6S3yfzcpGQ3GAR_OSoYQU,56
|
|
2
|
+
clilap_codepush/api.py,sha256=LEbTkY03-kP-YIJ4lZD7lz0gW922tI7hKC4wW2sgLd8,4610
|
|
3
|
+
clilap_codepush/cli.py,sha256=BEF8t-frI8TiB3qM6O-989xtovecco3KuTy9UZkN6i0,20568
|
|
4
|
+
clilap_codepush/ui.py,sha256=abTj7i34T5jVkj4z1IkOFi6Dgd6Q65GtbNATYrzYXPo,8643
|
|
5
|
+
clilap_codepush-1.0.0.dist-info/METADATA,sha256=4Daq4QsTRgd1h0w8f91PG9piYUIe7e8fRl0jElyHyIo,1046
|
|
6
|
+
clilap_codepush-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
clilap_codepush-1.0.0.dist-info/entry_points.txt,sha256=oSwDySr0lEWcvfPLYGFpm_IkjubaiUrSg4wlLcmf3RY,84
|
|
8
|
+
clilap_codepush-1.0.0.dist-info/top_level.txt,sha256=0bCHoirrbC65zhEYFdILdackb7-IqSUIh5QMJ1l0pnI,16
|
|
9
|
+
clilap_codepush-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clilap_codepush
|