izteamslots 1.1.0

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,368 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from . import PROJECT_ROOT
10
+ from .account_store import AccountStore
11
+ from .dto import AdminRow, AppStateDTO, MailAccountRow, WorkerRow
12
+ from .mail import Mailbox, create_provider_for_mailbox
13
+ from .openai_web_auth import (
14
+ close_browser as close_br,
15
+ )
16
+ from .openai_web_auth import (
17
+ oauth_login,
18
+ oauth_login_manual,
19
+ open_browser,
20
+ save_codex_file,
21
+ wait_for_browser_close,
22
+ )
23
+ from .slot_orchestrator import SlotManager
24
+
25
+ LogFunc = Callable[[str], Any]
26
+ ProgressFunc = Callable[[int, int, str | None], None]
27
+
28
+
29
+ def _has_profile_files(profile_dir: Path) -> bool:
30
+ return profile_dir.exists() and any(profile_dir.iterdir())
31
+
32
+
33
+ class UIFacade:
34
+ def __init__(self, store: AccountStore | None = None) -> None:
35
+ self.store = store or AccountStore()
36
+ self.manager: SlotManager | None = None
37
+ self.bootstrap()
38
+
39
+ def bootstrap(self) -> None:
40
+ profiles_json = PROJECT_ROOT / "profiles.json"
41
+ admin_index_exists = (self.store.admin_dir / "index.json").exists()
42
+ worker_index_exists = (self.store.worker_dir / "index.json").exists()
43
+
44
+ auto_migrate_profiles = os.environ.get("IZTEAMSLOTS_AUTOMIGRATE_PROFILES") == "1"
45
+ if auto_migrate_profiles and profiles_json.exists() and not admin_index_exists and not worker_index_exists:
46
+ self.store.migrate_from_profiles()
47
+
48
+ self.store.doctor()
49
+ self.sync_codex_files()
50
+
51
+ def shutdown(self) -> None:
52
+ self.sync_codex_files()
53
+
54
+ def sync_codex_files(self) -> None:
55
+ codex_dir = PROJECT_ROOT / "codex"
56
+ codex_dir.mkdir(parents=True, exist_ok=True)
57
+ for src_dir in (self.store.admin_dir, self.store.worker_dir):
58
+ if not src_dir.exists():
59
+ continue
60
+ for account_dir in src_dir.iterdir():
61
+ if not account_dir.is_dir():
62
+ continue
63
+ for f in account_dir.glob("codex-*.json"):
64
+ dst = codex_dir / f.name
65
+ if not dst.exists() or f.stat().st_mtime > dst.stat().st_mtime:
66
+ dst.write_text(f.read_text())
67
+
68
+ def get_state(self) -> dict[str, Any]:
69
+ raw_admins = self.store.list_admins()
70
+ raw_workers = self.store.list_workers()
71
+ admins = [
72
+ AdminRow.from_account(
73
+ a,
74
+ has_browser_profile=_has_profile_files(self.store.get_admin_profile_dir(a)),
75
+ )
76
+ for a in raw_admins
77
+ ]
78
+ workers = [
79
+ WorkerRow.from_account(
80
+ w,
81
+ has_browser_profile=_has_profile_files(self.store.get_worker_profile_dir(w)),
82
+ )
83
+ for w in raw_workers
84
+ ]
85
+ accounts: list[MailAccountRow] = [
86
+ *(MailAccountRow(kind="admin", email=a.email) for a in raw_admins),
87
+ *(MailAccountRow(kind="worker", email=w.email) for w in raw_workers),
88
+ ]
89
+ return AppStateDTO(admins=admins, workers=workers, accounts=accounts).to_dict()
90
+
91
+ def list_admins(self) -> list[dict[str, Any]]:
92
+ return [
93
+ AdminRow.from_account(
94
+ a,
95
+ has_browser_profile=_has_profile_files(self.store.get_admin_profile_dir(a)),
96
+ ).__dict__
97
+ for a in self.store.list_admins()
98
+ ]
99
+
100
+ def list_workers(self) -> list[dict[str, Any]]:
101
+ return [
102
+ WorkerRow.from_account(
103
+ w,
104
+ has_browser_profile=_has_profile_files(self.store.get_worker_profile_dir(w)),
105
+ ).__dict__
106
+ for w in self.store.list_workers()
107
+ ]
108
+
109
+ def list_workers_by_admin(self, admin_email: str) -> list[dict[str, Any]]:
110
+ workers = [w for w in self.store.list_workers() if w.admin_email == admin_email]
111
+ return [
112
+ WorkerRow.from_account(
113
+ w,
114
+ has_browser_profile=_has_profile_files(self.store.get_worker_profile_dir(w)),
115
+ ).__dict__
116
+ for w in workers
117
+ ]
118
+
119
+ def list_mail_accounts(self) -> list[dict[str, str]]:
120
+ out: list[dict[str, str]] = []
121
+ for a in self.store.list_admins():
122
+ out.append({"kind": "admin", "email": a.email})
123
+ for w in self.store.list_workers():
124
+ out.append({"kind": "worker", "email": w.email})
125
+ return out
126
+
127
+ def add_admin(self, email: str, password: str) -> dict[str, Any]:
128
+ admin = self.store.add_admin(email, password)
129
+ return AdminRow.from_account(admin).__dict__
130
+
131
+ def add_admin_manual(self, log: LogFunc) -> dict[str, Any]:
132
+ temp_root = self.store.admin_dir / f"_manual_{uuid.uuid4().hex}"
133
+ profile_dir = temp_root / "browser_profile"
134
+ page = None
135
+ created_admin_email: str | None = None
136
+
137
+ try:
138
+ page, session = oauth_login_manual(
139
+ profile_dir=profile_dir,
140
+ log=log,
141
+ )
142
+ email = str(session.get("email") or "").strip()
143
+ if not email:
144
+ raise RuntimeError("OAuth не вернул email, админ не создан")
145
+ if self.store.get_admin(email):
146
+ raise RuntimeError(f"Админ {email} уже существует")
147
+
148
+ admin = self.store.add_admin(email, "")
149
+ created_admin_email = email
150
+
151
+ manager = SlotManager(store=self.store, admin_email=email, log=log)
152
+ manager.finalize_admin_session(page, session)
153
+ page = None
154
+
155
+ target_profile_dir = self.store.get_admin_profile_dir(admin)
156
+ if target_profile_dir.exists():
157
+ shutil.rmtree(target_profile_dir)
158
+ shutil.move(str(profile_dir), str(target_profile_dir))
159
+ log(f"Профиль сохранён: {target_profile_dir}")
160
+
161
+ self.sync_codex_files()
162
+ log(f"Админ {email} добавлен через ручной логин")
163
+ log("[предупреждение] Пароль почты не сохранён. Для почты и авто-входа добавьте его позже.")
164
+ return AdminRow.from_account(
165
+ self.store.get_admin(email) or admin,
166
+ has_browser_profile=_has_profile_files(target_profile_dir),
167
+ ).__dict__
168
+ except Exception:
169
+ if page is not None:
170
+ try:
171
+ close_br(page, log=log)
172
+ except Exception:
173
+ pass
174
+ if created_admin_email:
175
+ try:
176
+ self.store.delete_admin(created_admin_email)
177
+ except Exception:
178
+ pass
179
+ raise
180
+ finally:
181
+ if temp_root.exists():
182
+ shutil.rmtree(temp_root, ignore_errors=True)
183
+
184
+ def delete_admin(self, email: str) -> None:
185
+ self.store.delete_admin(email)
186
+ for w in self.store.list_workers():
187
+ if w.admin_email == email:
188
+ w.admin_email = None
189
+ self.store.update_worker(w)
190
+ if self.manager and self.manager.admin_email == email:
191
+ self.manager = None
192
+
193
+ def delete_worker(self, email: str) -> None:
194
+ self.store.delete_worker(email)
195
+
196
+ def open_admin_browser(self, email: str, log: LogFunc) -> None:
197
+ admin = self.store.get_admin(email)
198
+ if not admin:
199
+ raise RuntimeError("Админ не найден")
200
+ profile_dir = self.store.get_admin_profile_dir(admin)
201
+ if not any(profile_dir.iterdir()):
202
+ raise RuntimeError("Нет browser_profile, сначала перелогиньте админа")
203
+ self._open_profile_browser(
204
+ profile_dir, email, log,
205
+ url="https://chatgpt.com/admin/members?tab=members",
206
+ )
207
+
208
+ def open_worker_browser(self, email: str, log: LogFunc) -> None:
209
+ worker = self.store.get_worker(email)
210
+ if not worker:
211
+ raise RuntimeError("Слот не найден")
212
+ profile_dir = self.store.get_worker_profile_dir(worker)
213
+ if not any(profile_dir.iterdir()):
214
+ raise RuntimeError("Нет browser_profile, сначала перелогиньте слот")
215
+ self._open_profile_browser(profile_dir, email, log)
216
+
217
+ def _open_profile_browser(
218
+ self, profile_dir: Path, label: str, log: LogFunc, url: str = "https://chatgpt.com/",
219
+ ) -> None:
220
+ _page, context = open_browser(profile_dir, url=url, log=log)
221
+ log(f"Браузер {label} открыт. Закройте окно браузера для возврата.")
222
+ wait_for_browser_close(context, log=log)
223
+
224
+ def login_admin(self, email: str, log: LogFunc) -> None:
225
+ self.manager = SlotManager(store=self.store, admin_email=email, log=log)
226
+ self.manager.login_admin()
227
+ self.sync_codex_files()
228
+
229
+ def login_admin_manual(self, email: str, log: LogFunc) -> None:
230
+ self.manager = SlotManager(store=self.store, admin_email=email, log=log)
231
+ self.manager.login_admin_manual()
232
+ self.sync_codex_files()
233
+
234
+ def run_slots_pipeline(
235
+ self,
236
+ admin_email: str,
237
+ count: int,
238
+ log: LogFunc,
239
+ progress: ProgressFunc | None = None,
240
+ ) -> dict[str, int]:
241
+ self.manager = SlotManager(store=self.store, admin_email=admin_email, log=log, headless=False)
242
+ ok = 0
243
+ for i in range(count):
244
+ slot_no = i + 1
245
+ log(f"--- Слот {slot_no}/{count} ---")
246
+ if progress:
247
+ progress(slot_no, count, f"slot {slot_no}/{count}")
248
+ try:
249
+ self.manager.create_invite_login_one()
250
+ ok += 1
251
+ except Exception as e:
252
+ log(f"Ошибка: {e}")
253
+ self.manager._close_admin_page()
254
+ log(f"Готово: {ok}/{count} слотов")
255
+ self.sync_codex_files()
256
+ return {"ok": ok, "total": count}
257
+
258
+ def relogin_worker_email(self, email: str, log: LogFunc) -> bool:
259
+ worker = self.store.get_worker(email)
260
+ if not worker:
261
+ log(f"Worker {email} не найден")
262
+ return False
263
+ if not worker.openai_password:
264
+ log(f"{email} — нет openai_password")
265
+ return False
266
+
267
+ mail = create_provider_for_mailbox(Mailbox(email=worker.email, password=worker.password))
268
+ try:
269
+ mailbox = Mailbox(email=worker.email, password=worker.password)
270
+ profile_dir = self.store.get_worker_profile_dir(worker)
271
+
272
+ page, session = oauth_login(
273
+ email=worker.email,
274
+ password=worker.openai_password or worker.password,
275
+ mail_client=mail,
276
+ mailbox=mailbox,
277
+ profile_dir=profile_dir,
278
+ log=log,
279
+ headless=False,
280
+ )
281
+
282
+ if session.get("access_token"):
283
+ worker.access_token = session["access_token"]
284
+ if session.get("account_id"):
285
+ worker.workspace_id = session["account_id"]
286
+ self.store.update_worker(worker)
287
+
288
+ worker_dir = self.store.worker_dir / worker.id
289
+ codex_path = save_codex_file(worker_dir, session, email)
290
+ log(f"Codex обновлён: {codex_path.name}")
291
+ else:
292
+ log("Нет access_token — codex не сохранён")
293
+ close_br(page, log=log)
294
+ return False
295
+
296
+ close_br(page, log=log)
297
+ log(f"{email} перелогинен")
298
+ self.sync_codex_files()
299
+ return True
300
+ finally:
301
+ mail.close()
302
+
303
+ def relogin_all_workers(
304
+ self,
305
+ log: LogFunc,
306
+ progress: ProgressFunc | None = None,
307
+ ) -> dict[str, int]:
308
+ workers = self.store.list_workers()
309
+ eligible = [w.email for w in workers if w.openai_password]
310
+ if not eligible:
311
+ log("Нет слотов с openai_password")
312
+ return {"ok": 0, "total": 0}
313
+
314
+ ok = 0
315
+ total = len(eligible)
316
+ for i, email in enumerate(eligible, start=1):
317
+ log(f"--- Перелогин {i}/{total}: {email} ---")
318
+ if progress:
319
+ progress(i, total, email)
320
+ if self.relogin_worker_email(email, log):
321
+ ok += 1
322
+ log(f"Готово: {ok}/{total}")
323
+ self.sync_codex_files()
324
+ return {"ok": ok, "total": total}
325
+
326
+ def fetch_mail(self, email: str, log: LogFunc) -> dict[str, Any]:
327
+ password = self._resolve_password(email)
328
+ if password is None:
329
+ raise RuntimeError("Аккаунт не найден")
330
+
331
+ mailbox = Mailbox(email=email, password=password)
332
+ mail = create_provider_for_mailbox(mailbox)
333
+ try:
334
+ inbox = mail.inbox(mailbox)
335
+ finally:
336
+ mail.close()
337
+
338
+ messages = inbox.messages
339
+ if not messages:
340
+ log(f"{email}: нет писем")
341
+ else:
342
+ log(f"{email}: писем {len(messages)}")
343
+ for m in messages[:50]:
344
+ log(f"{m.date} | {m.sender} | {m.subject}")
345
+
346
+ return {
347
+ "email": email,
348
+ "count": len(messages),
349
+ "messages": [
350
+ {
351
+ "id": m.id,
352
+ "date": m.date,
353
+ "sender": m.sender,
354
+ "subject": m.subject,
355
+ "body": m.body,
356
+ }
357
+ for m in messages[:50]
358
+ ],
359
+ }
360
+
361
+ def _resolve_password(self, email: str) -> str | None:
362
+ admin = self.store.get_admin(email)
363
+ if admin:
364
+ return admin.password
365
+ worker = self.store.get_worker(email)
366
+ if worker:
367
+ return worker.password
368
+ return None
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+
6
+ # Ensure bun is available
7
+ if ! command -v bun &>/dev/null; then
8
+ if [ -d "$HOME/.bun/bin" ]; then
9
+ export PATH="$HOME/.bun/bin:$PATH"
10
+ else
11
+ echo "Bun not found. Run: npm run setup" >&2
12
+ exit 1
13
+ fi
14
+ fi
15
+
16
+ exec bun run --cwd "$ROOT/ui" src/main.ts "$@"
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "izteamslots",
3
+ "version": "1.1.0",
4
+ "description": "ChatGPT Team slot management — automated invite, register, OAuth login & codex token pipeline",
5
+ "bin": {
6
+ "izteamslots": "bin/izteamslots.sh"
7
+ },
8
+ "scripts": {
9
+ "start": "bun run --cwd ui src/main.ts",
10
+ "setup": "bash scripts/setup.sh",
11
+ "postinstall": "bash scripts/setup.sh",
12
+ "typecheck:ui": "npm --prefix ui run typecheck"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/izzzzzi/izTeamSlots.git"
20
+ },
21
+ "license": "MIT",
22
+ "keywords": [
23
+ "chatgpt",
24
+ "team",
25
+ "slots",
26
+ "openai",
27
+ "codex",
28
+ "automation"
29
+ ]
30
+ }
@@ -0,0 +1,2 @@
1
+ requests>=2.31
2
+ seleniumbase>=4.32
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ RED='\033[0;31m'
5
+ GREEN='\033[0;32m'
6
+ YELLOW='\033[1;33m'
7
+ NC='\033[0m'
8
+
9
+ ok() { echo -e " ${GREEN}✓${NC} $1"; }
10
+ warn() { echo -e " ${YELLOW}!${NC} $1"; }
11
+ fail() { echo -e " ${RED}✗${NC} $1"; exit 1; }
12
+
13
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
14
+ echo ""
15
+ echo "izTeamSlots setup"
16
+ echo "================="
17
+ echo ""
18
+
19
+ # ── Python ──────────────────────────────────────────────
20
+ echo "Checking Python..."
21
+ PYTHON=""
22
+ for cmd in python3 python; do
23
+ if command -v "$cmd" &>/dev/null; then
24
+ ver=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
25
+ major=$(echo "$ver" | cut -d. -f1)
26
+ minor=$(echo "$ver" | cut -d. -f2)
27
+ if [ "$major" -ge 3 ] && [ "$minor" -ge 11 ]; then
28
+ PYTHON="$cmd"
29
+ break
30
+ fi
31
+ fi
32
+ done
33
+ [ -z "$PYTHON" ] && fail "Python 3.11+ not found. Install: https://python.org"
34
+ ok "Python: $($PYTHON --version)"
35
+
36
+ # ── uv ──────────────────────────────────────────────────
37
+ echo "Checking uv..."
38
+ if ! command -v uv &>/dev/null; then
39
+ warn "uv not found. Installing..."
40
+ curl -LsSf https://astral.sh/uv/install.sh | sh
41
+ export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
42
+ fi
43
+ command -v uv &>/dev/null || fail "uv install failed. Install manually: https://docs.astral.sh/uv"
44
+ ok "uv: $(uv --version)"
45
+
46
+ # ── Python deps ─────────────────────────────────────────
47
+ echo "Installing Python dependencies..."
48
+ uv pip install --system -q -r "$ROOT/requirements.txt"
49
+ ok "Python deps installed"
50
+
51
+ # ── Bun ─────────────────────────────────────────────────
52
+ echo "Checking Bun..."
53
+ if ! command -v bun &>/dev/null; then
54
+ warn "Bun not found. Installing..."
55
+ curl -fsSL https://bun.sh/install | bash
56
+ export PATH="$HOME/.bun/bin:$PATH"
57
+ fi
58
+ command -v bun &>/dev/null || fail "Bun install failed. Install manually: https://bun.sh"
59
+ ok "Bun: $(bun --version)"
60
+
61
+ # ── UI deps ─────────────────────────────────────────────
62
+ echo "Installing UI dependencies..."
63
+ cd "$ROOT/ui"
64
+ bun install --frozen-lockfile 2>/dev/null || bun install
65
+ ok "UI deps installed"
66
+ cd "$ROOT"
67
+
68
+ # ── .env ────────────────────────────────────────────────
69
+ if [ ! -f "$ROOT/.env" ]; then
70
+ if [ -f "$ROOT/.env.example" ]; then
71
+ cp "$ROOT/.env.example" "$ROOT/.env"
72
+ warn ".env created from .env.example — edit it with your API keys"
73
+ fi
74
+ fi
75
+
76
+ # ── Done ────────────────────────────────────────────────
77
+ echo ""
78
+ echo -e "${GREEN}Setup complete!${NC}"
79
+ echo ""
80
+ echo " Start: npm start"
81
+ echo " Or: bun run --cwd ui src/main.ts"
82
+ echo ""
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "izteamslots-opentui",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "bun run src/main.ts",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "dependencies": {
11
+ "@opentui/core": "^0.1.86"
12
+ },
13
+ "devDependencies": {
14
+ "@types/bun": "^1.2.18",
15
+ "@types/node": "^22.10.2",
16
+ "tsx": "^4.19.2",
17
+ "typescript": "^5.7.2"
18
+ }
19
+ }
package/ui/src/main.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { MainScreen } from "./screens/MainScreen"
2
+
3
+ const screen = new MainScreen()
4
+ await screen.start()
@@ -0,0 +1,163 @@
1
+ import { StyledText, bg, bold, fg, stringToStyledText, t } from "@opentui/core"
2
+ import type { DashboardData, MenuOption } from "./types"
3
+
4
+ function truncate(value: string, width: number): string {
5
+ if (width <= 1) return value.slice(0, width)
6
+ if (value.length <= width) return value
7
+ return `${value.slice(0, width - 1)}…`
8
+ }
9
+
10
+ function pad(value: string, width: number): string {
11
+ const v = truncate(value, width)
12
+ if (v.length >= width) return v
13
+ return v + " ".repeat(width - v.length)
14
+ }
15
+
16
+ export function formatTable(
17
+ headers: string[],
18
+ rows: string[][],
19
+ maxWidth = 120,
20
+ emptyMessage = "Нет данных для отображения.",
21
+ ): StyledText {
22
+ const allRows = [headers, ...rows]
23
+ if (allRows.length === 0) return stringToStyledText("")
24
+
25
+ const cols = headers.length
26
+ const widths = new Array(cols).fill(0)
27
+ for (const row of allRows) {
28
+ for (let i = 0; i < cols; i++) {
29
+ const cell = row[i] ?? ""
30
+ widths[i] = Math.max(widths[i], cell.length)
31
+ }
32
+ }
33
+
34
+ const minCol = 8
35
+ for (let i = 0; i < cols; i++) widths[i] = Math.max(minCol, Math.min(widths[i], 48))
36
+
37
+ const separatorWidth = (cols - 1) * 3
38
+ const total = widths.reduce((a: number, b: number) => a + b, 0) + separatorWidth
39
+ if (total > maxWidth) {
40
+ const overflow = total - maxWidth
41
+ const perColCut = Math.ceil(overflow / cols)
42
+ for (let i = 0; i < cols; i++) widths[i] = Math.max(minCol, widths[i] - perColCut)
43
+ }
44
+
45
+ const rowToLine = (row: string[]): string => row.map((v, i) => pad(v ?? "", widths[i])).join(" │ ")
46
+ const headerLine = rowToLine(headers)
47
+ const divider = widths.map((w: number) => "─".repeat(w)).join("─┼─")
48
+
49
+ const lines: StyledText[] = [
50
+ t`${bg("#334155")(fg("#f8fafc")(bold(headerLine)))}`,
51
+ t`${fg("#64748b")(divider)}`,
52
+ ]
53
+
54
+ if (rows.length === 0) {
55
+ lines.push(t`${fg("#64748b")(truncate(emptyMessage, Math.max(12, maxWidth)))}`)
56
+ return joinStyledLines(lines)
57
+ }
58
+
59
+ lines.push(
60
+ ...rows.map((row, i) => {
61
+ const line = rowToLine(row)
62
+ return i % 2 === 0 ? t`${fg("#cbd5e1")(line)}` : t`${bg("#111827")(fg("#cbd5e1")(line))}`
63
+ }),
64
+ )
65
+
66
+ return joinStyledLines(lines)
67
+ }
68
+
69
+ export function formatMenu(options: MenuOption[], selected: number, maxWidth = 80): StyledText {
70
+ if (options.length === 0) {
71
+ return t`${fg("#94a3b8")("(пусто)")}`
72
+ }
73
+
74
+ const lines: StyledText[] = []
75
+ const longestLabel = options.reduce((max, option) => Math.max(max, option.label.length), 0)
76
+ const labelWidth = Math.max(18, Math.min(longestLabel + 2, 28))
77
+ const hintWidth = Math.max(20, maxWidth - labelWidth - 8)
78
+
79
+ for (let i = 0; i < options.length; i++) {
80
+ const opt = options[i]
81
+ const num = i < 9 ? `${i + 1}.` : "• "
82
+ const label = pad(opt.label, labelWidth)
83
+ const hint = opt.hint ? ` ${truncate(opt.hint, hintWidth)}` : ""
84
+ const activeBg = opt.destructive ? "#7f1d1d" : "#2563eb"
85
+ const activeFg = opt.destructive ? "#fee2e2" : "#eff6ff"
86
+ const inline = `${label}${hint}`
87
+
88
+ if (i === selected) {
89
+ lines.push(t`${bg(activeBg)(fg(activeFg)(bold(` ${num} ${inline} `)))}`)
90
+ } else if (opt.destructive) {
91
+ lines.push(t`${fg("#64748b")(` ${num}`)} ${fg("#f87171")(label)}${fg("#64748b")(hint)}`)
92
+ } else {
93
+ lines.push(t`${fg("#64748b")(` ${num}`)} ${fg("#cbd5e1")(label)}${fg("#64748b")(hint)}`)
94
+ }
95
+ }
96
+
97
+ return joinStyledLines(lines)
98
+ }
99
+
100
+ export function formatDashboard(data: DashboardData, maxWidth = 72): StyledText {
101
+ const lines: StyledText[] = []
102
+
103
+ const adminHealth = data.admins_total === 0
104
+ ? "нужен первый админ"
105
+ : `${data.admins_ready}/${data.admins_total} готовы`
106
+ const adminHealthColor = data.admins_ready === data.admins_total ? "#4ade80" : "#fbbf24"
107
+
108
+ lines.push(
109
+ t`${fg("#94a3b8")("Админы ")}${fg("#f8fafc")(bold(pad(String(data.admins_total), 4)))}${fg(adminHealthColor)(truncate(adminHealth, Math.max(16, maxWidth - 22)))}`,
110
+ )
111
+
112
+ const detailParts: string[] = []
113
+ if (data.workers_ready > 0) detailParts.push(`${data.workers_ready} готовы`)
114
+ if (data.workers_registered > 0) detailParts.push(`${data.workers_registered} готово`)
115
+ if (data.workers_invited > 0) detailParts.push(`${data.workers_invited} приглашено`)
116
+ if (data.workers_created > 0) detailParts.push(`${data.workers_created} создано`)
117
+ if (data.workers_with_password > 0) detailParts.push(`${data.workers_with_password} с паролем`)
118
+
119
+ const detail = detailParts.length > 0 ? detailParts.join(" / ") : ""
120
+
121
+ if (detail) {
122
+ lines.push(
123
+ t`${fg("#94a3b8")("Слоты ")}${fg("#f8fafc")(bold(pad(String(data.workers_total), 4)))}${fg("#94a3b8")(truncate(detail, Math.max(16, maxWidth - 22)))}`,
124
+ )
125
+ } else {
126
+ lines.push(t`${fg("#94a3b8")("Слоты ")}${fg("#f8fafc")(bold(String(data.workers_total)))}`)
127
+ }
128
+
129
+ if (data.admins_total === 0) {
130
+ lines.push(t`${fg("#fca5a5")("Нужно добавить первого администратора.")}`)
131
+ } else if (data.admins_ready < data.admins_total) {
132
+ lines.push(t`${fg("#fbbf24")("Не все админы готовы: проверьте токен и браузерный профиль.")}`)
133
+ } else if (data.workers_total === 0) {
134
+ lines.push(t`${fg("#93c5fd")("Система готова к созданию первых слотов.")}`)
135
+ } else {
136
+ lines.push(t`${fg("#4ade80")("Операционный контур выглядит готовым к работе.")}`)
137
+ }
138
+
139
+ return joinStyledLines(lines)
140
+ }
141
+
142
+ export function formatLogs(logs: string[], max = 200, maxWidth = 120): StyledText {
143
+ const tail = logs.slice(Math.max(0, logs.length - max))
144
+ if (tail.length === 0) return t`${fg("#64748b")("Ожидание событий...")}`
145
+
146
+ const lines: StyledText[] = tail.map((line, i) => {
147
+ const value = truncate(line, Math.max(24, maxWidth))
148
+ if (line.includes("Ошибка:")) return t`${fg("#fca5a5")(value)}`
149
+ if (line.includes("▶") || line.includes("Готово")) return t`${fg("#93c5fd")(bold(value))}`
150
+ return i % 2 === 0 ? t`${fg("#cbd5e1")(value)}` : t`${fg("#94a3b8")(value)}`
151
+ })
152
+
153
+ return joinStyledLines(lines)
154
+ }
155
+
156
+ export function joinStyledLines(lines: StyledText[]): StyledText {
157
+ const chunks: StyledText["chunks"] = []
158
+ for (let i = 0; i < lines.length; i++) {
159
+ chunks.push(...lines[i].chunks)
160
+ if (i < lines.length - 1) chunks.push(...stringToStyledText("\n").chunks)
161
+ }
162
+ return new StyledText(chunks)
163
+ }