aether-mkt 0.1.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.
- aether/__init__.py +12 -0
- aether/cli.py +525 -0
- aether/deploy/com.aether.web.plist +81 -0
- aether/deploy/db/schema.sql +155 -0
- aether_mkt-0.1.0.dist-info/METADATA +79 -0
- aether_mkt-0.1.0.dist-info/RECORD +89 -0
- aether_mkt-0.1.0.dist-info/WHEEL +4 -0
- aether_mkt-0.1.0.dist-info/entry_points.txt +2 -0
- blocks/README.md +103 -0
- blocks/__init__.py +2 -0
- blocks/_templates/README.md +14 -0
- blocks/publish/README.md +28 -0
- blocks/publish/__init__.py +2 -0
- blocks/rewrite/README.md +25 -0
- blocks/rewrite/__init__.py +3 -0
- blocks/rewrite/platform_creator.py +307 -0
- blocks/rewrite/state.py +30 -0
- blocks/rewrite/tests/__init__.py +0 -0
- blocks/rewrite/tests/test_rewrite.py +300 -0
- blocks/rewrite/translation_launcher.py +397 -0
- blocks/rewrite/wechat/README.md +137 -0
- blocks/rewrite/wechat/__init__.py +3 -0
- blocks/rewrite/wechat/expected/README.md +35 -0
- blocks/rewrite/wechat/prompt.v1.md +137 -0
- blocks/rewrite/wechat/samples/README.md +31 -0
- blocks/rewrite/xiaohongshu/__init__.py +2 -0
- blocks/rewrite/xiaohongshu/prompt.v1.md +142 -0
- blocks/sources/README.md +22 -0
- blocks/sources/__init__.py +2 -0
- blocks/sources/basic/README.md +98 -0
- blocks/sources/basic/__init__.py +1 -0
- blocks/sources/basic/parser.py +125 -0
- blocks/sources/basic/sources.yaml +103 -0
- blocks/sources/basic/state.py +33 -0
- blocks/sources/basic/subgraph.py +162 -0
- blocks/sources/basic/tests/__init__.py +1 -0
- blocks/sources/basic/tests/test_subgraph.py +67 -0
- blocks/sources/multi/__init__.py +7 -0
- blocks/sources/multi/__main__.py +7 -0
- blocks/sources/multi/content_fetch.py +408 -0
- blocks/sources/multi/strategies/__init__.py +41 -0
- blocks/sources/multi/strategies/contentmarketinginstitute.py +103 -0
- blocks/sources/multi/strategies/searchengineland.py +25 -0
- blocks/sources/multi/strategies/socialmediaexaminer.py +25 -0
- blocks/sources/multi/strategies/wordstream.py +20 -0
- blocks/sources/multi/subgraph.py +363 -0
- blocks/sources/multi/tests/__init__.py +0 -0
- blocks/sources/multi/tests/test_content_fetch.py +398 -0
- blocks/sources/multi/tests/test_subgraph_filter.py +167 -0
- blocks/sources/multi/tests/walkthrough.py +359 -0
- blocks/sources/scheduler/__init__.py +1 -0
- src/__init__.py +20 -0
- src/base_subgraph.py +109 -0
- src/browser.py +84 -0
- src/config.py +258 -0
- src/db.py +394 -0
- src/extractor.py +473 -0
- src/fetcher.py +36 -0
- src/image_handler.py +147 -0
- src/llm.py +299 -0
- src/parsers.py +275 -0
- src/registry.py +204 -0
- src/single_getter.py +61 -0
- src/strategies.py +109 -0
- web/README.md +175 -0
- web/__init__.py +2 -0
- web/app.py +88 -0
- web/queries.py +277 -0
- web/routes/__init__.py +1 -0
- web/routes/actions.py +164 -0
- web/routes/articles.py +87 -0
- web/routes/drafts.py +52 -0
- web/routes/placeholders.py +48 -0
- web/routes/settings.py +157 -0
- web/routes/sources.py +57 -0
- web/settings_store.py +90 -0
- web/static/style.css +418 -0
- web/templates/articles/list.html +96 -0
- web/templates/articles/translate.html +91 -0
- web/templates/base.html +43 -0
- web/templates/drafts/detail.html +76 -0
- web/templates/drafts/list.html +90 -0
- web/templates/placeholders/coming_soon.html +16 -0
- web/templates/settings/edit.html +118 -0
- web/templates/sources/fetch.html +114 -0
- web/templates/sources/list.html +77 -0
- web/tests/__init__.py +1 -0
- web/tests/test_app.py +341 -0
- web/tests/test_settings.py +289 -0
aether/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""aether · 内容分发 v0.1.
|
|
2
|
+
|
|
3
|
+
PyPI package: ``aether-mkt`` (the hyphenated form is reserved on PyPI)
|
|
4
|
+
CLI command: ``aether`` (cleaner to type)
|
|
5
|
+
|
|
6
|
+
Internal modules live under the ``aether`` Python package (Python
|
|
7
|
+
identifiers can't have hyphens), so ``import aether.cli`` works after
|
|
8
|
+
``pip install aether-mkt``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
aether/cli.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""aether CLI · 驱动 macOS / Linux 上的 aether 服务生命周期。
|
|
2
|
+
|
|
3
|
+
发布版本(v0.1)走**本机 PostgreSQL + launchd**,不依赖 Docker:
|
|
4
|
+
|
|
5
|
+
- Postgres 通过 brew services / systemctl / 系统服务管理器运行
|
|
6
|
+
- aether web 服务通过 launchd plist 在 macOS 后台跑(开机自起、崩溃重启)
|
|
7
|
+
- 首次 `aether init` 自动跑 schema.sql 建表
|
|
8
|
+
|
|
9
|
+
设计上:
|
|
10
|
+
- idempotent + fail-fast(任何步骤失败立即退出,不留半成品)
|
|
11
|
+
- 数据 / 配置持久化到 XDG-style 目录(macOS: ~/Library/Application Support/aether)
|
|
12
|
+
- 7 个子命令:init / serve / stop / update / status / logs / uninstall
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import contextlib
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# Note: pyproject.toml [project.scripts] exposes ``aether = "aether.cli:main"``
|
|
27
|
+
# so this file is the entry point installed by `pip install aether-mkt`.
|
|
28
|
+
|
|
29
|
+
APP_NAME = "aether"
|
|
30
|
+
VERSION = "0.1.0"
|
|
31
|
+
LAUNCHD_LABEL = "com.aether.web"
|
|
32
|
+
LAUNCHD_PLIST_NAME = f"{LAUNCHD_LABEL}.plist"
|
|
33
|
+
|
|
34
|
+
# Default DATABASE_URL points at a local Postgres reachable via unix socket
|
|
35
|
+
# (peer auth on macOS — no password needed when the macOS user owns the DB).
|
|
36
|
+
DEFAULT_DATABASE_URL = "postgresql:///aether"
|
|
37
|
+
DEFAULT_LLM_BASE_URL = "https://api.anthropic.com"
|
|
38
|
+
DEFAULT_LLM_MODEL = "claude-sonnet-4-6"
|
|
39
|
+
|
|
40
|
+
ENV_TEMPLATE = """\
|
|
41
|
+
# aether v0.1 · env config (also editable at runtime via /settings UI)
|
|
42
|
+
|
|
43
|
+
# DB — local Postgres (peer auth on macOS when DB owner = macOS user).
|
|
44
|
+
DATABASE_URL={default_db}
|
|
45
|
+
|
|
46
|
+
# LLM three-piece (overridable at runtime via /settings).
|
|
47
|
+
LLM_BASE_URL={default_llm_base}
|
|
48
|
+
LLM_MODEL={default_llm_model}
|
|
49
|
+
LLM_API_KEY=sk-ant-REPLACE_ME
|
|
50
|
+
|
|
51
|
+
# Fetcher
|
|
52
|
+
USER_AGENT=AetherBot/0.1
|
|
53
|
+
REQUEST_TIMEOUT=30
|
|
54
|
+
DEFAULT_TOP_N=3
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# Keys from .env that get injected into the launchd plist's EnvironmentVariables.
|
|
58
|
+
INJECTED_ENV_KEYS = (
|
|
59
|
+
"DATABASE_URL",
|
|
60
|
+
"LLM_BASE_URL",
|
|
61
|
+
"LLM_MODEL",
|
|
62
|
+
"LLM_API_KEY",
|
|
63
|
+
"USER_AGENT",
|
|
64
|
+
"REQUEST_TIMEOUT",
|
|
65
|
+
"DEFAULT_TOP_N",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
# Paths
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
|
|
73
|
+
def data_dir() -> Path:
|
|
74
|
+
"""XDG-style per-user data directory.
|
|
75
|
+
|
|
76
|
+
- macOS: ~/Library/Application Support/aether
|
|
77
|
+
- Linux: $XDG_DATA_HOME/aether or ~/.local/share/aether
|
|
78
|
+
- Windows: %APPDATA%\\aether (dev only)
|
|
79
|
+
"""
|
|
80
|
+
system = platform.system()
|
|
81
|
+
if system == "Darwin":
|
|
82
|
+
return Path.home() / "Library" / "Application Support" / APP_NAME
|
|
83
|
+
if system == "Linux":
|
|
84
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
85
|
+
return Path(xdg) / APP_NAME if xdg else Path.home() / ".local" / "share" / APP_NAME
|
|
86
|
+
appdata = os.environ.get("APPDATA")
|
|
87
|
+
return Path(appdata) / APP_NAME if appdata else Path.home() / f".{APP_NAME}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def runtime_dir() -> Path:
|
|
91
|
+
"""log / launchd staging directory."""
|
|
92
|
+
return data_dir() / "runtime"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def launchd_agents_dir() -> Path:
|
|
96
|
+
"""~/Library/LaunchAgents (macOS only)."""
|
|
97
|
+
return Path.home() / "Library" / "LaunchAgents"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def launchd_plist_path() -> Path:
|
|
101
|
+
return launchd_agents_dir() / LAUNCHD_PLIST_NAME
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def deploy_dir() -> Path:
|
|
105
|
+
"""Bundled deploy assets shipped inside the wheel."""
|
|
106
|
+
return Path(__file__).parent / "deploy"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def env_file_path() -> Path:
|
|
110
|
+
return data_dir() / ".env"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- #
|
|
114
|
+
# Subprocess helpers
|
|
115
|
+
# --------------------------------------------------------------------------- #
|
|
116
|
+
|
|
117
|
+
def _run(cmd: Sequence[str], **kw) -> subprocess.CompletedProcess:
|
|
118
|
+
"""subprocess.run with check=True; bubble stderr on failure."""
|
|
119
|
+
try:
|
|
120
|
+
return subprocess.run(cmd, check=True, **kw)
|
|
121
|
+
except subprocess.CalledProcessError as e:
|
|
122
|
+
sys.stderr.write(f"command failed (rc={e.returncode}): {' '.join(e.cmd)}\n")
|
|
123
|
+
if e.stderr:
|
|
124
|
+
sys.stderr.write(e.stderr.decode("utf-8", errors="replace") + "\n")
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _open_in_editor(path: Path) -> None:
|
|
129
|
+
editor = os.environ.get("EDITOR")
|
|
130
|
+
if editor:
|
|
131
|
+
subprocess.run([editor, str(path)])
|
|
132
|
+
return
|
|
133
|
+
system = platform.system()
|
|
134
|
+
if system == "Darwin":
|
|
135
|
+
subprocess.run(["open", str(path)])
|
|
136
|
+
elif system == "Linux" and shutil.which("xdg-open"):
|
|
137
|
+
subprocess.run(["xdg-open", str(path)])
|
|
138
|
+
# Windows / no editor: no-op; user edits via /settings later.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
# Postgres connectivity + schema bootstrap
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
|
|
145
|
+
def _check_postgres() -> None:
|
|
146
|
+
"""fail-fast if Postgres unreachable."""
|
|
147
|
+
try:
|
|
148
|
+
from src.db import get_conn
|
|
149
|
+
with get_conn(connect_timeout=3) as conn, conn.cursor() as cur:
|
|
150
|
+
cur.execute("SELECT 1")
|
|
151
|
+
cur.fetchone()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
sys.stderr.write(
|
|
154
|
+
f"ERROR: cannot connect to Postgres ({e.__class__.__name__}).\n"
|
|
155
|
+
"\n"
|
|
156
|
+
"macOS quick fix:\n"
|
|
157
|
+
" brew install postgresql@16\n"
|
|
158
|
+
" brew services start postgresql@16\n"
|
|
159
|
+
f" createdb {APP_NAME}\n"
|
|
160
|
+
" aether init # retry\n"
|
|
161
|
+
"\n"
|
|
162
|
+
"Linux quick fix:\n"
|
|
163
|
+
" sudo apt install postgresql\n"
|
|
164
|
+
" sudo systemctl start postgresql\n"
|
|
165
|
+
f" sudo -u postgres createdb {APP_NAME}\n"
|
|
166
|
+
" aether init # retry\n"
|
|
167
|
+
)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _schema_exists() -> bool:
|
|
172
|
+
"""True if the `sources` table is present (proxy for 'bootstrap done')."""
|
|
173
|
+
try:
|
|
174
|
+
from src.db import get_conn
|
|
175
|
+
with get_conn(connect_timeout=3) as conn, conn.cursor() as cur:
|
|
176
|
+
cur.execute(
|
|
177
|
+
"SELECT 1 FROM information_schema.tables "
|
|
178
|
+
"WHERE table_schema = 'public' AND table_name = 'sources'"
|
|
179
|
+
)
|
|
180
|
+
return cur.fetchone() is not None
|
|
181
|
+
except Exception:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _bootstrap_schema() -> None:
|
|
186
|
+
"""Run db/schema.sql. Idempotent: uses CREATE TABLE IF NOT EXISTS etc."""
|
|
187
|
+
schema_path = deploy_dir() / "db" / "schema.sql"
|
|
188
|
+
if not schema_path.exists():
|
|
189
|
+
sys.stderr.write(f"ERROR: schema.sql missing in wheel: {schema_path}\n")
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
sql = schema_path.read_text(encoding="utf-8")
|
|
192
|
+
print("[init] applying schema.sql (first boot)...")
|
|
193
|
+
try:
|
|
194
|
+
from src.db import get_conn
|
|
195
|
+
with get_conn(connect_timeout=10) as conn:
|
|
196
|
+
with conn.cursor() as cur:
|
|
197
|
+
cur.execute(sql)
|
|
198
|
+
conn.commit()
|
|
199
|
+
except Exception as e:
|
|
200
|
+
sys.stderr.write(f"ERROR: schema bootstrap failed: {e}\n")
|
|
201
|
+
sys.exit(1)
|
|
202
|
+
print("[init] schema applied.")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
# launchd plist rendering
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
|
|
209
|
+
def _read_env_file(path: Path) -> dict[str, str]:
|
|
210
|
+
"""Minimal .env parser: KEY=VALUE lines, # comments, no expansion."""
|
|
211
|
+
out: dict[str, str] = {}
|
|
212
|
+
if not path.exists():
|
|
213
|
+
return out
|
|
214
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
215
|
+
line = raw.strip()
|
|
216
|
+
if not line or line.startswith("#"):
|
|
217
|
+
continue
|
|
218
|
+
if "=" not in line:
|
|
219
|
+
continue
|
|
220
|
+
k, v = line.split("=", 1)
|
|
221
|
+
out[k.strip()] = v.strip()
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _render_plist() -> str:
|
|
226
|
+
"""Substitute placeholders in com.aether.web.plist template."""
|
|
227
|
+
template = (deploy_dir() / LAUNCHD_PLIST_NAME).read_text(encoding="utf-8")
|
|
228
|
+
python_exe = sys.executable
|
|
229
|
+
work_dir = str(data_dir())
|
|
230
|
+
log_dir = str(runtime_dir())
|
|
231
|
+
# PATH must include the venv site-packages bin so uvicorn resolves;
|
|
232
|
+
# otherwise launchd inherits a stripped PATH.
|
|
233
|
+
path_env = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
234
|
+
|
|
235
|
+
# Inject user-provided env (DATABASE_URL / LLM_* / etc.) into plist dict.
|
|
236
|
+
user_env = _read_env_file(env_file_path())
|
|
237
|
+
injected_xml_lines = []
|
|
238
|
+
for key in INJECTED_ENV_KEYS:
|
|
239
|
+
if key in user_env and user_env[key]:
|
|
240
|
+
from xml.sax.saxutils import escape
|
|
241
|
+
injected_xml_lines.append(
|
|
242
|
+
f" <key>{escape(key)}</key>\n"
|
|
243
|
+
f" <string>{escape(user_env[key])}</string>"
|
|
244
|
+
)
|
|
245
|
+
injected = "\n".join(injected_xml_lines) if injected_xml_lines else ""
|
|
246
|
+
|
|
247
|
+
rendered = (
|
|
248
|
+
template
|
|
249
|
+
.replace("__PYTHON__", python_exe)
|
|
250
|
+
.replace("__WORKDIR__", work_dir)
|
|
251
|
+
.replace("__PATH__", path_env)
|
|
252
|
+
.replace("__LOG_DIR__", log_dir)
|
|
253
|
+
.replace("__INJECTED_ENV__", injected)
|
|
254
|
+
)
|
|
255
|
+
return rendered
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _install_launchd_plist() -> Path:
|
|
259
|
+
"""Render + write plist to ~/Library/LaunchAgents/. Idempotent."""
|
|
260
|
+
if platform.system() != "Darwin":
|
|
261
|
+
sys.stderr.write(
|
|
262
|
+
"ERROR: launchd integration is macOS-only.\n"
|
|
263
|
+
"On Linux, manage the web process manually (systemd / nohup).\n"
|
|
264
|
+
)
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
launchd_agents_dir().mkdir(parents=True, exist_ok=True)
|
|
267
|
+
target = launchd_plist_path()
|
|
268
|
+
target.write_text(_render_plist(), encoding="utf-8")
|
|
269
|
+
return target
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _launchctl(*args: str) -> None:
|
|
273
|
+
"""Run launchctl and let exceptions bubble."""
|
|
274
|
+
_run(["launchctl"] + list(args))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _launchd_uid_target() -> str:
|
|
278
|
+
"""gui/<uid>/<label> — the per-user launchd domain.
|
|
279
|
+
|
|
280
|
+
On macOS uses os.getuid(); on Windows / dev we use a stable placeholder
|
|
281
|
+
so tests work. cmd_serve / cmd_stop gate on macOS upstream, so this
|
|
282
|
+
fallback only matters for unit-test execution paths.
|
|
283
|
+
"""
|
|
284
|
+
uid = os.getuid() if hasattr(os, "getuid") else 1000
|
|
285
|
+
return f"gui/{uid}/{LAUNCHD_LABEL}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --------------------------------------------------------------------------- #
|
|
289
|
+
# Sub-commands
|
|
290
|
+
# --------------------------------------------------------------------------- #
|
|
291
|
+
|
|
292
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
293
|
+
"""First-time setup: ensure Postgres reachable, write .env, bootstrap schema."""
|
|
294
|
+
print(f"[init] aether {VERSION}")
|
|
295
|
+
print(f"[init] data dir: {data_dir()}")
|
|
296
|
+
data_dir().mkdir(parents=True, exist_ok=True)
|
|
297
|
+
runtime_dir().mkdir(parents=True, exist_ok=True)
|
|
298
|
+
|
|
299
|
+
_check_postgres()
|
|
300
|
+
print("[init] postgres OK")
|
|
301
|
+
|
|
302
|
+
env_file = env_file_path()
|
|
303
|
+
if env_file.exists() and not args.force:
|
|
304
|
+
print(f"[init] .env already exists at {env_file} (use --force to overwrite)")
|
|
305
|
+
else:
|
|
306
|
+
env_file.write_text(
|
|
307
|
+
ENV_TEMPLATE.format(
|
|
308
|
+
default_db=DEFAULT_DATABASE_URL,
|
|
309
|
+
default_llm_base=DEFAULT_LLM_BASE_URL,
|
|
310
|
+
default_llm_model=DEFAULT_LLM_MODEL,
|
|
311
|
+
),
|
|
312
|
+
encoding="utf-8",
|
|
313
|
+
)
|
|
314
|
+
print(f"[init] wrote {env_file}")
|
|
315
|
+
|
|
316
|
+
# Schema bootstrap — idempotent.
|
|
317
|
+
if _schema_exists():
|
|
318
|
+
print("[init] schema already present, skipping bootstrap")
|
|
319
|
+
else:
|
|
320
|
+
_bootstrap_schema()
|
|
321
|
+
|
|
322
|
+
print()
|
|
323
|
+
print("=" * 60)
|
|
324
|
+
print("NEXT: edit .env and fill LLM_API_KEY (required), then run:")
|
|
325
|
+
print(" aether serve")
|
|
326
|
+
print("=" * 60)
|
|
327
|
+
if not args.no_prompt:
|
|
328
|
+
try:
|
|
329
|
+
input("Press Enter to open .env in your default editor (Ctrl-D to skip)...")
|
|
330
|
+
except EOFError:
|
|
331
|
+
print()
|
|
332
|
+
_open_in_editor(env_file)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
336
|
+
"""Install launchd plist + launchctl load (background, auto-restart)."""
|
|
337
|
+
_check_postgres()
|
|
338
|
+
if not env_file_path().exists():
|
|
339
|
+
sys.stderr.write(f"ERROR: {env_file_path()} not found. Run `aether init` first.\n")
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
if not _schema_exists():
|
|
342
|
+
sys.stderr.write("ERROR: schema not bootstrapped. Run `aether init` first.\n")
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
|
|
345
|
+
plist = _install_launchd_plist()
|
|
346
|
+
print(f"[serve] wrote {plist}")
|
|
347
|
+
|
|
348
|
+
uid_target = _launchd_uid_target()
|
|
349
|
+
# If already loaded, bootout first so a fresh load picks up new plist content.
|
|
350
|
+
list_result = subprocess.run(
|
|
351
|
+
["launchctl", "list", LAUNCHD_LABEL],
|
|
352
|
+
capture_output=True, text=True,
|
|
353
|
+
)
|
|
354
|
+
if list_result.returncode == 0:
|
|
355
|
+
print(f"[serve] unloading previous instance...")
|
|
356
|
+
with contextlib.suppress(subprocess.CalledProcessError):
|
|
357
|
+
_launchctl("bootout", uid_target)
|
|
358
|
+
|
|
359
|
+
print(f"[serve] launchctl load -w {plist.name} ...")
|
|
360
|
+
_launchctl("load", "-w", str(plist))
|
|
361
|
+
print(f"[serve] launchctl kickstart -k {uid_target} ...")
|
|
362
|
+
_launchctl("kickstart", "-k", uid_target)
|
|
363
|
+
print()
|
|
364
|
+
print("[serve] aether is running.")
|
|
365
|
+
print(" web: http://localhost:8000/articles")
|
|
366
|
+
print(" logs: aether logs")
|
|
367
|
+
print(" stop: aether stop")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_stop(args: argparse.Namespace) -> None:
|
|
371
|
+
"""launchctl bootout (graceful stop; plist kept on disk)."""
|
|
372
|
+
uid_target = _launchd_uid_target()
|
|
373
|
+
try:
|
|
374
|
+
_launchctl("bootout", uid_target)
|
|
375
|
+
print("[stop] aether stopped")
|
|
376
|
+
except subprocess.CalledProcessError:
|
|
377
|
+
print("[stop] aether was not running")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def cmd_update(args: argparse.Namespace) -> None:
|
|
381
|
+
"""pip install --upgrade + restart."""
|
|
382
|
+
print("[update] pip install --upgrade aether-mkt ...")
|
|
383
|
+
_run([sys.executable, "-m", "pip", "install", "--upgrade", "aether-mkt"])
|
|
384
|
+
cmd_stop(argparse.Namespace())
|
|
385
|
+
cmd_serve(argparse.Namespace())
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
389
|
+
"""Show launchd status + DB connectivity + data dir."""
|
|
390
|
+
print(f"aether {VERSION}")
|
|
391
|
+
print(f"data dir: {data_dir()}")
|
|
392
|
+
print()
|
|
393
|
+
# launchd status
|
|
394
|
+
try:
|
|
395
|
+
_run(["launchctl", "print", _launchd_uid_target()])
|
|
396
|
+
except subprocess.CalledProcessError:
|
|
397
|
+
print("not running. run `aether serve`.")
|
|
398
|
+
# DB ping
|
|
399
|
+
print()
|
|
400
|
+
try:
|
|
401
|
+
from src.db import get_conn
|
|
402
|
+
with get_conn(connect_timeout=3) as conn, conn.cursor() as cur:
|
|
403
|
+
cur.execute("SELECT version()")
|
|
404
|
+
v = cur.fetchone()[0]
|
|
405
|
+
print(f"postgres: OK ({v[:60]})")
|
|
406
|
+
except Exception as e:
|
|
407
|
+
print(f"postgres: DOWN ({e.__class__.__name__}: {e})")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def cmd_logs(args: argparse.Namespace) -> None:
|
|
411
|
+
"""tail the web log (out + err interleaved)."""
|
|
412
|
+
log_dir = runtime_dir()
|
|
413
|
+
if not log_dir.exists():
|
|
414
|
+
print("no log dir. run `aether serve` first.")
|
|
415
|
+
return
|
|
416
|
+
out_log = log_dir / "web.out.log"
|
|
417
|
+
err_log = log_dir / "web.err.log"
|
|
418
|
+
files = [str(p) for p in (out_log, err_log) if p.exists()]
|
|
419
|
+
if not files:
|
|
420
|
+
print(f"no log files in {log_dir}. run `aether serve` first.")
|
|
421
|
+
return
|
|
422
|
+
try:
|
|
423
|
+
_run(["tail", "-f"] + files)
|
|
424
|
+
except KeyboardInterrupt:
|
|
425
|
+
print()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def cmd_uninstall(args: argparse.Namespace) -> None:
|
|
429
|
+
"""Stop service + remove launchd plist + (optional) remove data dir."""
|
|
430
|
+
print("[uninstall] this will:")
|
|
431
|
+
print(" - stop aether (launchctl bootout)")
|
|
432
|
+
print(" - remove ~/Library/LaunchAgents/com.aether.web.plist")
|
|
433
|
+
if args.keep_data:
|
|
434
|
+
print(" - KEEP data dir (--keep-data)")
|
|
435
|
+
else:
|
|
436
|
+
print(" - remove data dir")
|
|
437
|
+
if not args.yes:
|
|
438
|
+
try:
|
|
439
|
+
confirm = input("continue? [y/N] ")
|
|
440
|
+
except EOFError:
|
|
441
|
+
confirm = "n"
|
|
442
|
+
if confirm.lower() != "y":
|
|
443
|
+
print("cancelled")
|
|
444
|
+
return
|
|
445
|
+
# Stop + remove plist
|
|
446
|
+
with contextlib.suppress(subprocess.CalledProcessError):
|
|
447
|
+
_launchctl("bootout", _launchd_uid_target())
|
|
448
|
+
plist = launchd_plist_path()
|
|
449
|
+
if plist.exists():
|
|
450
|
+
plist.unlink()
|
|
451
|
+
print(f"[uninstall] removed {plist}")
|
|
452
|
+
if not args.keep_data and data_dir().exists():
|
|
453
|
+
shutil.rmtree(data_dir())
|
|
454
|
+
print(f"[uninstall] removed {data_dir()}")
|
|
455
|
+
print("[uninstall] done.")
|
|
456
|
+
print(" to remove the package itself: pip uninstall aether-mkt")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# --------------------------------------------------------------------------- #
|
|
460
|
+
# Entry point
|
|
461
|
+
# --------------------------------------------------------------------------- #
|
|
462
|
+
|
|
463
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
464
|
+
parser = argparse.ArgumentParser(
|
|
465
|
+
prog="aether",
|
|
466
|
+
description="aether 内容分发 v0.1 · CLI",
|
|
467
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
468
|
+
epilog="""\
|
|
469
|
+
examples:
|
|
470
|
+
aether init first-time setup (check postgres, write .env, bootstrap schema)
|
|
471
|
+
aether serve start web (background, auto-restart via launchd)
|
|
472
|
+
aether status show launchd status + DB connectivity
|
|
473
|
+
aether logs tail web logs (Ctrl-C to exit)
|
|
474
|
+
aether stop stop web
|
|
475
|
+
aether update upgrade package + restart
|
|
476
|
+
aether uninstall remove plist + (optionally) data dir
|
|
477
|
+
""",
|
|
478
|
+
)
|
|
479
|
+
parser.add_argument("--version", action="version", version=f"aether {VERSION}")
|
|
480
|
+
|
|
481
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
482
|
+
|
|
483
|
+
p = sub.add_parser("init", help="first-time setup")
|
|
484
|
+
p.add_argument("--force", action="store_true", help="overwrite existing .env")
|
|
485
|
+
p.add_argument("--no-prompt", action="store_true", help="don't open editor")
|
|
486
|
+
p.set_defaults(func=cmd_init)
|
|
487
|
+
|
|
488
|
+
p = sub.add_parser("serve", help="start web (background via launchd)")
|
|
489
|
+
p.set_defaults(func=cmd_serve)
|
|
490
|
+
|
|
491
|
+
p = sub.add_parser("stop", help="stop web (launchctl bootout)")
|
|
492
|
+
p.set_defaults(func=cmd_stop)
|
|
493
|
+
|
|
494
|
+
p = sub.add_parser("update", help="upgrade package + restart")
|
|
495
|
+
p.set_defaults(func=cmd_update)
|
|
496
|
+
|
|
497
|
+
p = sub.add_parser("status", help="show launchd status + DB ping")
|
|
498
|
+
p.set_defaults(func=cmd_status)
|
|
499
|
+
|
|
500
|
+
p = sub.add_parser("logs", help="tail web logs (Ctrl-C to exit)")
|
|
501
|
+
p.set_defaults(func=cmd_logs)
|
|
502
|
+
|
|
503
|
+
p = sub.add_parser("uninstall", help="stop + remove plist + (optionally) data dir")
|
|
504
|
+
p.add_argument("--keep-data", action="store_true", help="preserve data dir")
|
|
505
|
+
p.add_argument("--yes", "-y", action="store_true", help="skip confirmation")
|
|
506
|
+
p.set_defaults(func=cmd_uninstall)
|
|
507
|
+
|
|
508
|
+
return parser
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
512
|
+
parser = _build_parser()
|
|
513
|
+
args = parser.parse_args(argv)
|
|
514
|
+
try:
|
|
515
|
+
args.func(args)
|
|
516
|
+
except KeyboardInterrupt:
|
|
517
|
+
print("\ninterrupted", file=sys.stderr)
|
|
518
|
+
return 130
|
|
519
|
+
except subprocess.CalledProcessError as e:
|
|
520
|
+
return e.returncode or 1
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
if __name__ == "__main__":
|
|
525
|
+
sys.exit(main())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<!-- ===== Identity ===== -->
|
|
6
|
+
<key>Label</key>
|
|
7
|
+
<string>com.aether.web</string>
|
|
8
|
+
<key>ProcessType</key>
|
|
9
|
+
<string>Background</string>
|
|
10
|
+
|
|
11
|
+
<!-- ===== What to run =====
|
|
12
|
+
Placeholders are substituted by aether serve at install time:
|
|
13
|
+
__PYTHON__ absolute path to the Python interpreter
|
|
14
|
+
running the CLI (sys.executable)
|
|
15
|
+
__WORKDIR__ aether data dir (XDG-style)
|
|
16
|
+
The CLI passes --app-dir so web.app can read settings.json
|
|
17
|
+
from the right location regardless of cwd. -->
|
|
18
|
+
<key>ProgramArguments</key>
|
|
19
|
+
<array>
|
|
20
|
+
<string>__PYTHON__</string>
|
|
21
|
+
<string>-m</string>
|
|
22
|
+
<string>uvicorn</string>
|
|
23
|
+
<string>web.app:app</string>
|
|
24
|
+
<string>--host</string>
|
|
25
|
+
<string>127.0.0.1</string>
|
|
26
|
+
<string>--port</string>
|
|
27
|
+
<string>8000</string>
|
|
28
|
+
</array>
|
|
29
|
+
|
|
30
|
+
<key>WorkingDirectory</key>
|
|
31
|
+
<string>__WORKDIR__</string>
|
|
32
|
+
|
|
33
|
+
<!-- ===== Environment =====
|
|
34
|
+
Database URL + LLM keys + runtime config, all sourced from the
|
|
35
|
+
user's ~/Library/Application Support/aether/.env at install time.
|
|
36
|
+
PATH includes the venv site-packages bin so uvicorn is found. -->
|
|
37
|
+
<key>EnvironmentVariables</key>
|
|
38
|
+
<dict>
|
|
39
|
+
<key>PATH</key>
|
|
40
|
+
<string>__PATH__</string>
|
|
41
|
+
<key>AETHER_DATA_DIR</key>
|
|
42
|
+
<string>__WORKDIR__</string>
|
|
43
|
+
<!-- The actual DATABASE_URL / LLM_* / USER_AGENT / REQUEST_TIMEOUT /
|
|
44
|
+
DEFAULT_TOP_N entries are injected by `aether serve` from .env. -->
|
|
45
|
+
__INJECTED_ENV__
|
|
46
|
+
</dict>
|
|
47
|
+
|
|
48
|
+
<!-- ===== Lifecycle ===== -->
|
|
49
|
+
<key>RunAtLoad</key>
|
|
50
|
+
<true/>
|
|
51
|
+
<key>KeepAlive</key>
|
|
52
|
+
<dict>
|
|
53
|
+
<!-- Restart on crash, but not on clean exit (so `aether stop` works). -->
|
|
54
|
+
<key>SuccessfulExit</key>
|
|
55
|
+
<false/>
|
|
56
|
+
<key>Crashed</key>
|
|
57
|
+
<true/>
|
|
58
|
+
</dict>
|
|
59
|
+
<key>ThrottleInterval</key>
|
|
60
|
+
<integer>10</integer>
|
|
61
|
+
|
|
62
|
+
<!-- ===== Logs =====
|
|
63
|
+
Captured to runtime/ so `aether logs` can tail them. -->
|
|
64
|
+
<key>StandardOutPath</key>
|
|
65
|
+
<string>__LOG_DIR__/web.out.log</string>
|
|
66
|
+
<key>StandardErrorPath</key>
|
|
67
|
+
<string>__LOG_DIR__/web.err.log</string>
|
|
68
|
+
|
|
69
|
+
<!-- ===== Resource limits ===== -->
|
|
70
|
+
<key>SoftResourceLimits</key>
|
|
71
|
+
<dict>
|
|
72
|
+
<key>NumberOfFiles</key>
|
|
73
|
+
<integer>65536</integer>
|
|
74
|
+
</dict>
|
|
75
|
+
<key>HardResourceLimits</key>
|
|
76
|
+
<dict>
|
|
77
|
+
<key>NumberOfFiles</key>
|
|
78
|
+
<integer>65536</integer>
|
|
79
|
+
</dict>
|
|
80
|
+
</dict>
|
|
81
|
+
</plist>
|