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.
Files changed (89) hide show
  1. aether/__init__.py +12 -0
  2. aether/cli.py +525 -0
  3. aether/deploy/com.aether.web.plist +81 -0
  4. aether/deploy/db/schema.sql +155 -0
  5. aether_mkt-0.1.0.dist-info/METADATA +79 -0
  6. aether_mkt-0.1.0.dist-info/RECORD +89 -0
  7. aether_mkt-0.1.0.dist-info/WHEEL +4 -0
  8. aether_mkt-0.1.0.dist-info/entry_points.txt +2 -0
  9. blocks/README.md +103 -0
  10. blocks/__init__.py +2 -0
  11. blocks/_templates/README.md +14 -0
  12. blocks/publish/README.md +28 -0
  13. blocks/publish/__init__.py +2 -0
  14. blocks/rewrite/README.md +25 -0
  15. blocks/rewrite/__init__.py +3 -0
  16. blocks/rewrite/platform_creator.py +307 -0
  17. blocks/rewrite/state.py +30 -0
  18. blocks/rewrite/tests/__init__.py +0 -0
  19. blocks/rewrite/tests/test_rewrite.py +300 -0
  20. blocks/rewrite/translation_launcher.py +397 -0
  21. blocks/rewrite/wechat/README.md +137 -0
  22. blocks/rewrite/wechat/__init__.py +3 -0
  23. blocks/rewrite/wechat/expected/README.md +35 -0
  24. blocks/rewrite/wechat/prompt.v1.md +137 -0
  25. blocks/rewrite/wechat/samples/README.md +31 -0
  26. blocks/rewrite/xiaohongshu/__init__.py +2 -0
  27. blocks/rewrite/xiaohongshu/prompt.v1.md +142 -0
  28. blocks/sources/README.md +22 -0
  29. blocks/sources/__init__.py +2 -0
  30. blocks/sources/basic/README.md +98 -0
  31. blocks/sources/basic/__init__.py +1 -0
  32. blocks/sources/basic/parser.py +125 -0
  33. blocks/sources/basic/sources.yaml +103 -0
  34. blocks/sources/basic/state.py +33 -0
  35. blocks/sources/basic/subgraph.py +162 -0
  36. blocks/sources/basic/tests/__init__.py +1 -0
  37. blocks/sources/basic/tests/test_subgraph.py +67 -0
  38. blocks/sources/multi/__init__.py +7 -0
  39. blocks/sources/multi/__main__.py +7 -0
  40. blocks/sources/multi/content_fetch.py +408 -0
  41. blocks/sources/multi/strategies/__init__.py +41 -0
  42. blocks/sources/multi/strategies/contentmarketinginstitute.py +103 -0
  43. blocks/sources/multi/strategies/searchengineland.py +25 -0
  44. blocks/sources/multi/strategies/socialmediaexaminer.py +25 -0
  45. blocks/sources/multi/strategies/wordstream.py +20 -0
  46. blocks/sources/multi/subgraph.py +363 -0
  47. blocks/sources/multi/tests/__init__.py +0 -0
  48. blocks/sources/multi/tests/test_content_fetch.py +398 -0
  49. blocks/sources/multi/tests/test_subgraph_filter.py +167 -0
  50. blocks/sources/multi/tests/walkthrough.py +359 -0
  51. blocks/sources/scheduler/__init__.py +1 -0
  52. src/__init__.py +20 -0
  53. src/base_subgraph.py +109 -0
  54. src/browser.py +84 -0
  55. src/config.py +258 -0
  56. src/db.py +394 -0
  57. src/extractor.py +473 -0
  58. src/fetcher.py +36 -0
  59. src/image_handler.py +147 -0
  60. src/llm.py +299 -0
  61. src/parsers.py +275 -0
  62. src/registry.py +204 -0
  63. src/single_getter.py +61 -0
  64. src/strategies.py +109 -0
  65. web/README.md +175 -0
  66. web/__init__.py +2 -0
  67. web/app.py +88 -0
  68. web/queries.py +277 -0
  69. web/routes/__init__.py +1 -0
  70. web/routes/actions.py +164 -0
  71. web/routes/articles.py +87 -0
  72. web/routes/drafts.py +52 -0
  73. web/routes/placeholders.py +48 -0
  74. web/routes/settings.py +157 -0
  75. web/routes/sources.py +57 -0
  76. web/settings_store.py +90 -0
  77. web/static/style.css +418 -0
  78. web/templates/articles/list.html +96 -0
  79. web/templates/articles/translate.html +91 -0
  80. web/templates/base.html +43 -0
  81. web/templates/drafts/detail.html +76 -0
  82. web/templates/drafts/list.html +90 -0
  83. web/templates/placeholders/coming_soon.html +16 -0
  84. web/templates/settings/edit.html +118 -0
  85. web/templates/sources/fetch.html +114 -0
  86. web/templates/sources/list.html +77 -0
  87. web/tests/__init__.py +1 -0
  88. web/tests/test_app.py +341 -0
  89. 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>