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.
@@ -0,0 +1,2 @@
1
+ """clilap codepush CLI client."""
2
+ __version__ = "1.0.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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ codepush = clilap_codepush.cli:main
3
+ cp = clilap_codepush.cli:main
@@ -0,0 +1 @@
1
+ clilap_codepush