chatup 0.2.9__tar.gz → 0.2.11__tar.gz

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 (88) hide show
  1. {chatup-0.2.9/src/chatup.egg-info → chatup-0.2.11}/PKG-INFO +1 -1
  2. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/__init__.py +1 -1
  3. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/elements.py +103 -0
  4. chatup-0.2.11/src/chatup/setup/twikoo.py +403 -0
  5. {chatup-0.2.9 → chatup-0.2.11/src/chatup.egg-info}/PKG-INFO +1 -1
  6. {chatup-0.2.9 → chatup-0.2.11}/src/chatup.egg-info/SOURCES.txt +2 -0
  7. {chatup-0.2.9 → chatup-0.2.11}/tests/test_cli_tree_docs.py +9 -0
  8. {chatup-0.2.9 → chatup-0.2.11}/tests/test_publish_workflow.py +3 -0
  9. chatup-0.2.11/tests/test_twikoo_setup.py +136 -0
  10. {chatup-0.2.9 → chatup-0.2.11}/tests/test_version.py +2 -2
  11. {chatup-0.2.9 → chatup-0.2.11}/LICENSE +0 -0
  12. {chatup-0.2.9 → chatup-0.2.11}/README.md +0 -0
  13. {chatup-0.2.9 → chatup-0.2.11}/pyproject.toml +0 -0
  14. {chatup-0.2.9 → chatup-0.2.11}/setup.cfg +0 -0
  15. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/chrome_for_testing.py +0 -0
  16. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/chromedriver.py +0 -0
  17. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/cli.py +0 -0
  18. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/commands/__init__.py +0 -0
  19. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/commands/chrome_for_testing.py +0 -0
  20. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/commands/chromedriver.py +0 -0
  21. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/commands/playwright.py +0 -0
  22. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/config.py +0 -0
  23. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/const.py +0 -0
  24. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/interaction/__init__.py +0 -0
  25. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/interaction/command_schema.py +0 -0
  26. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/interaction/policy.py +0 -0
  27. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/interaction/warnings.py +0 -0
  28. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/playwright.py +0 -0
  29. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/runtime/__init__.py +0 -0
  30. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/runtime/_managed_artifact.py +0 -0
  31. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/runtime/chrome_for_testing.py +0 -0
  32. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/runtime/chromedriver.py +0 -0
  33. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/runtime/playwright.py +0 -0
  34. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/__init__.py +0 -0
  35. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/assets/hermes/install.sh +0 -0
  36. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/assets/nvm.sh +0 -0
  37. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/cc_connect.py +0 -0
  38. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/claude.py +0 -0
  39. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/cli.py +0 -0
  40. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/codex.py +0 -0
  41. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/crs.py +0 -0
  42. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/cursor_agent.py +0 -0
  43. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/discourse.py +0 -0
  44. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/docker.py +0 -0
  45. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/frp.py +0 -0
  46. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/gitea.py +0 -0
  47. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/hermes.py +0 -0
  48. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/lark_cli.py +0 -0
  49. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/mode_prompt.py +0 -0
  50. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/mysql.py +0 -0
  51. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/nginx.py +0 -0
  52. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/nodejs.py +0 -0
  53. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/opencode.py +0 -0
  54. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/service_credentials.py +0 -0
  55. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/shell_rc.py +0 -0
  56. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/uv.py +0 -0
  57. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/__init__.py +0 -0
  58. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/cli.py +0 -0
  59. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/core.py +0 -0
  60. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/options.py +0 -0
  61. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/render.py +0 -0
  62. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/templates/default/en/AGENTS.md +0 -0
  63. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/templates/default/en/discussion/README.md +0 -0
  64. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/templates/default/en/projects/README.md +0 -0
  65. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/templates/default/zh/AGENTS.md +0 -0
  66. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md +0 -0
  67. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace/templates/default/zh/projects/README.md +0 -0
  68. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/workspace.py +0 -0
  69. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/zsh.py +0 -0
  70. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/setup/zulip.py +0 -0
  71. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/utils/__init__.py +0 -0
  72. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/utils/custom_logger.py +0 -0
  73. {chatup-0.2.9 → chatup-0.2.11}/src/chatup/utils/pathing.py +0 -0
  74. {chatup-0.2.9 → chatup-0.2.11}/src/chatup.egg-info/dependency_links.txt +0 -0
  75. {chatup-0.2.9 → chatup-0.2.11}/src/chatup.egg-info/entry_points.txt +0 -0
  76. {chatup-0.2.9 → chatup-0.2.11}/src/chatup.egg-info/requires.txt +0 -0
  77. {chatup-0.2.9 → chatup-0.2.11}/src/chatup.egg-info/top_level.txt +0 -0
  78. {chatup-0.2.9 → chatup-0.2.11}/tests/test_browser_backend_cli.py +0 -0
  79. {chatup-0.2.9 → chatup-0.2.11}/tests/test_chrome_for_testing.py +0 -0
  80. {chatup-0.2.9 → chatup-0.2.11}/tests/test_chromedriver.py +0 -0
  81. {chatup-0.2.9 → chatup-0.2.11}/tests/test_crs_setup.py +0 -0
  82. {chatup-0.2.9 → chatup-0.2.11}/tests/test_cursor_agent_setup.py +0 -0
  83. {chatup-0.2.9 → chatup-0.2.11}/tests/test_playwright.py +0 -0
  84. {chatup-0.2.9 → chatup-0.2.11}/tests/test_playwright_cli.py +0 -0
  85. {chatup-0.2.9 → chatup-0.2.11}/tests/test_pyproject_metadata.py +0 -0
  86. {chatup-0.2.9 → chatup-0.2.11}/tests/test_service_installers.py +0 -0
  87. {chatup-0.2.9 → chatup-0.2.11}/tests/test_setup_cli.py +0 -0
  88. {chatup-0.2.9 → chatup-0.2.11}/tests/test_workspace_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatup
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: ChatArch setup CLI extracted from ChatTool
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.9"
5
+ __version__ = "0.2.11"
@@ -54,6 +54,14 @@ from chatup.setup.nginx import (
54
54
  )
55
55
  from chatup.setup.opencode import setup_opencode
56
56
  from chatup.setup.nodejs import setup_nodejs
57
+ from chatup.setup.twikoo import (
58
+ DEFAULT_TWIKOO_BIND_ADDRESS,
59
+ DEFAULT_TWIKOO_INSTANCE,
60
+ DEFAULT_TWIKOO_PORT,
61
+ DEFAULT_TWIKOO_REPO,
62
+ DEFAULT_TWIKOO_VERSION,
63
+ setup_twikoo,
64
+ )
57
65
  from chatup.setup.uv import DEFAULT_PYTHON_VERSION, DEFAULT_VENV_PATH, setup_uv
58
66
  from chatup.setup.workspace import setup_workspace
59
67
  from chatup.setup.zsh import setup_zsh
@@ -304,6 +312,41 @@ def mysql_setup(
304
312
  )
305
313
 
306
314
 
315
+ def twikoo_setup(
316
+ version,
317
+ repo,
318
+ home,
319
+ name,
320
+ port,
321
+ bind_address,
322
+ install,
323
+ init,
324
+ service,
325
+ start,
326
+ smoke,
327
+ force,
328
+ log_level,
329
+ ):
330
+ result = setup_twikoo(
331
+ name=name,
332
+ version=version,
333
+ repo=repo,
334
+ home=home,
335
+ port=port,
336
+ bind_address=bind_address,
337
+ install=install,
338
+ init=init,
339
+ service=service,
340
+ start=start,
341
+ smoke=smoke,
342
+ force=force,
343
+ log_level=log_level,
344
+ )
345
+ service_info = result.get("service") if isinstance(result, dict) else None
346
+ if isinstance(service_info, dict) and service_info.get("unit"):
347
+ click.echo(service_info["unit"])
348
+
349
+
307
350
  def nginx_setup(
308
351
  template,
309
352
  output_file,
@@ -870,6 +913,66 @@ SETUP_COMMAND_ELEMENTS = (
870
913
  ),
871
914
  ),
872
915
  ),
916
+ SetupCommandElement(
917
+ name="twikoo",
918
+ help="Install and prepare a multi-instance Twikoo comment service runtime.",
919
+ callback=twikoo_setup,
920
+ options=(
921
+ LOG_LEVEL_OPTION,
922
+ SetupOptionElement(
923
+ param_decls=("--version",),
924
+ kwargs={"default": DEFAULT_TWIKOO_VERSION, "show_default": True},
925
+ ),
926
+ SetupOptionElement(
927
+ param_decls=("--repo",),
928
+ kwargs={"default": DEFAULT_TWIKOO_REPO, "show_default": True},
929
+ ),
930
+ SetupOptionElement(
931
+ param_decls=("--home",),
932
+ kwargs={
933
+ "default": None,
934
+ "type": click.Path(path_type=Path),
935
+ "help": "Twikoo home. Defaults to ~/.chatarch/twikoo.",
936
+ },
937
+ ),
938
+ SetupOptionElement(
939
+ param_decls=("--name",),
940
+ kwargs={"default": DEFAULT_TWIKOO_INSTANCE, "show_default": True},
941
+ ),
942
+ SetupOptionElement(
943
+ param_decls=("--port",),
944
+ kwargs={"default": DEFAULT_TWIKOO_PORT, "show_default": True, "type": int},
945
+ ),
946
+ SetupOptionElement(
947
+ param_decls=("--bind-address",),
948
+ kwargs={"default": DEFAULT_TWIKOO_BIND_ADDRESS, "show_default": True},
949
+ ),
950
+ SetupOptionElement(
951
+ param_decls=("--install/--no-install",),
952
+ kwargs={"default": True, "show_default": True, "help": "Download the Twikoo release binary."},
953
+ ),
954
+ SetupOptionElement(
955
+ param_decls=("--init/--no-init",),
956
+ kwargs={"default": True, "show_default": True, "help": "Create instance directories, env file, and instance-local binary link."},
957
+ ),
958
+ SetupOptionElement(
959
+ param_decls=("--service/--no-service",),
960
+ kwargs={"default": True, "show_default": True, "help": "Install a user-level systemd service."},
961
+ ),
962
+ SetupOptionElement(
963
+ param_decls=("--start/--no-start",),
964
+ kwargs={"default": False, "show_default": True, "help": "Start the user-level Twikoo service."},
965
+ ),
966
+ SetupOptionElement(
967
+ param_decls=("--smoke/--no-smoke",),
968
+ kwargs={"default": False, "show_default": True, "help": "Run a local HTTP smoke test after starting."},
969
+ ),
970
+ SetupOptionElement(
971
+ param_decls=("--force", "-f"),
972
+ kwargs={"is_flag": True, "help": "Replace existing runtime/config/service where supported."},
973
+ ),
974
+ ),
975
+ ),
873
976
  SetupCommandElement(
874
977
  name="nginx",
875
978
  help="Prepare user-level NGINX under ChatArch home and render config templates.",
@@ -0,0 +1,403 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import urllib.request
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+ from chatenv import get_paths
16
+
17
+ from chatup.utils.custom_logger import setup_logger
18
+
19
+ logger = setup_logger("setup_twikoo")
20
+
21
+ DEFAULT_TWIKOO_REPO = "twikoojs/twikoo"
22
+ DEFAULT_TWIKOO_VERSION = "1.7.15"
23
+ DEFAULT_TWIKOO_PORT = 8892
24
+ DEFAULT_TWIKOO_BIND_ADDRESS = "127.0.0.1"
25
+ DEFAULT_TWIKOO_INSTANCE = "chatblog"
26
+ TWIKOO_RUNTIME_SUPPORT_FILES = ("ip2region.db", "xhr-sync-worker.js")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class TwikooService:
31
+ name: str
32
+ path: Path
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class TwikooLayout:
37
+ home: Path
38
+ downloads: Path
39
+ runtime: Path
40
+ runtime_binary: Path
41
+ instance: Path
42
+ instance_bin: Path
43
+ instance_env_link: Path
44
+ binary: Path
45
+ env: Path
46
+ data: Path
47
+ logs: Path
48
+ run: Path
49
+ tmp: Path
50
+ service: TwikooService
51
+
52
+
53
+ def _configure_logger(log_level="INFO"):
54
+ global logger
55
+ logger = setup_logger("setup_twikoo", log_level=str(log_level).upper())
56
+ return logger
57
+
58
+
59
+ def validate_safe_name(value: str, *, field: str) -> str:
60
+ if not value or any(ch in value for ch in ("/", "\\", "\x00")) or value in {".", ".."}:
61
+ raise click.ClickException(
62
+ f"Invalid {field}: {value!r}. Use an instance-safe name without path separators."
63
+ )
64
+ allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-")
65
+ if any(ch not in allowed for ch in value):
66
+ raise click.ClickException(
67
+ f"Invalid {field}: {value!r}. Use letters, numbers, dot, underscore, or dash."
68
+ )
69
+ return value
70
+
71
+
72
+ def default_twikoo_home() -> Path:
73
+ return get_paths().home_dir / "twikoo"
74
+
75
+
76
+ def service_name(name: str = DEFAULT_TWIKOO_INSTANCE) -> str:
77
+ validate_safe_name(name, field="instance name")
78
+ return f"chatarch-twikoo-{name}.service"
79
+
80
+
81
+ def service_file_path(name: str = DEFAULT_TWIKOO_INSTANCE) -> Path:
82
+ return Path("~/.config/systemd/user").expanduser() / service_name(name)
83
+
84
+
85
+ def twikoo_layout(
86
+ name: str = DEFAULT_TWIKOO_INSTANCE,
87
+ version: str = DEFAULT_TWIKOO_VERSION,
88
+ home: Path | None = None,
89
+ ) -> TwikooLayout:
90
+ validate_safe_name(name, field="instance name")
91
+ validate_safe_name(version, field="version")
92
+ root = (home or default_twikoo_home()).expanduser()
93
+ runtime = root / "runtimes" / version
94
+ instance = root / "instances" / name
95
+ instance_bin = instance / "bin" / "twikoo"
96
+ return TwikooLayout(
97
+ home=root,
98
+ downloads=root / "downloads",
99
+ runtime=runtime,
100
+ runtime_binary=runtime / "twikoo",
101
+ instance=instance,
102
+ instance_bin=instance_bin,
103
+ instance_env_link=instance / "bin" / ".env",
104
+ binary=instance_bin,
105
+ env=instance / "env" / "twikoo.env",
106
+ data=instance / "data",
107
+ logs=instance / "logs",
108
+ run=instance / "run",
109
+ tmp=instance / "tmp",
110
+ service=TwikooService(name=service_name(name), path=service_file_path(name)),
111
+ )
112
+
113
+
114
+ def _machine_arch() -> str:
115
+ machine = platform.machine().lower()
116
+ if machine in {"x86_64", "amd64"}:
117
+ return "x64"
118
+ if machine in {"aarch64", "arm64"}:
119
+ return "arm64"
120
+ raise click.ClickException(f"Unsupported Twikoo release architecture: {machine}")
121
+
122
+
123
+ def select_twikoo_asset_name() -> str:
124
+ system = platform.system().lower()
125
+ arch = _machine_arch()
126
+ if system == "linux" and arch == "x64":
127
+ return "twikoo-linux-x64"
128
+ if system == "darwin" and arch in {"x64", "arm64"}:
129
+ return f"twikoo-darwin-{arch}"
130
+ if system == "windows" and arch == "x64":
131
+ return "twikoo-win-x64.exe"
132
+ raise click.ClickException(f"Unsupported Twikoo release platform: {platform.system()} {platform.machine()}")
133
+
134
+
135
+ def twikoo_release_asset_url(repo: str, version: str, asset_name: str) -> str:
136
+ version = version.removeprefix("v")
137
+ return f"https://github.com/{repo}/releases/download/{version}/{asset_name}"
138
+
139
+
140
+ def _download_url(url: str, target: Path) -> None:
141
+ target.parent.mkdir(parents=True, exist_ok=True)
142
+ logger.info("Downloading %s", url)
143
+ with urllib.request.urlopen(url, timeout=180) as response, target.open("wb") as handle:
144
+ if response.status != 200:
145
+ raise click.ClickException(f"Failed to download Twikoo release asset: HTTP {response.status}")
146
+ shutil.copyfileobj(response, handle)
147
+
148
+
149
+ def _sha256(path: Path) -> str:
150
+ digest = hashlib.sha256()
151
+ with path.open("rb") as handle:
152
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
153
+ digest.update(chunk)
154
+ return digest.hexdigest()
155
+
156
+
157
+ def install_twikoo_binary(
158
+ *,
159
+ repo: str = DEFAULT_TWIKOO_REPO,
160
+ version: str = DEFAULT_TWIKOO_VERSION,
161
+ home: Path | None = None,
162
+ force: bool = False,
163
+ asset_name: str | None = None,
164
+ ) -> dict[str, Any]:
165
+ layout = twikoo_layout(version=version, home=home)
166
+ if layout.runtime_binary.exists() and not force:
167
+ return {
168
+ "version": version,
169
+ "binary": str(layout.runtime_binary),
170
+ "sha256": _sha256(layout.runtime_binary),
171
+ "reused": True,
172
+ }
173
+
174
+ selected_asset = asset_name or select_twikoo_asset_name()
175
+ url = twikoo_release_asset_url(repo, version, selected_asset)
176
+ layout.runtime.mkdir(parents=True, exist_ok=True)
177
+ with tempfile.TemporaryDirectory(prefix=".chatup-twikoo-", dir=layout.runtime) as tmp:
178
+ tmp_path = Path(tmp) / selected_asset
179
+ _download_url(url, tmp_path)
180
+ tmp_path.chmod(0o755)
181
+ tmp_path.replace(layout.runtime_binary)
182
+ layout.runtime_binary.chmod(0o755)
183
+ return {
184
+ "version": version,
185
+ "binary": str(layout.runtime_binary),
186
+ "asset": selected_asset,
187
+ "sha256": _sha256(layout.runtime_binary),
188
+ "reused": False,
189
+ }
190
+
191
+
192
+ def render_env(
193
+ layout: TwikooLayout,
194
+ *,
195
+ port: int = DEFAULT_TWIKOO_PORT,
196
+ bind_address: str = DEFAULT_TWIKOO_BIND_ADDRESS,
197
+ ) -> str:
198
+ return f"""# Managed by ChatUp. Do not store secrets in this generated default env file.
199
+ TWIKOO_HOST={bind_address}
200
+ TWIKOO_PORT={port}
201
+ TWIKOO_DATA={layout.data}
202
+ TWIKOO_THROTTLE=250
203
+ TWIKOO_LOG_LEVEL=info
204
+ TWIKOO_LOCALHOST_ONLY=true
205
+ TWIKOO_SHUTDOWN_TIMEOUT=5000
206
+ """
207
+
208
+
209
+ def render_service(layout: TwikooLayout, *, name: str = DEFAULT_TWIKOO_INSTANCE) -> str:
210
+ return f"""[Unit]
211
+ Description=ChatUp Twikoo comment service ({name})
212
+ After=network-online.target
213
+ Wants=network-online.target
214
+
215
+ [Service]
216
+ Type=simple
217
+ WorkingDirectory={layout.instance}
218
+ EnvironmentFile={layout.env}
219
+ ExecStart={layout.binary}
220
+ Restart=always
221
+ RestartSec=5
222
+ StandardOutput=append:{layout.logs}/twikoo.stdout.log
223
+ StandardError=append:{layout.logs}/twikoo.stderr.log
224
+
225
+ [Install]
226
+ WantedBy=default.target
227
+ """
228
+
229
+
230
+ def _link_or_copy_binary(source: Path, target: Path, *, force: bool = False) -> None:
231
+ target.parent.mkdir(parents=True, exist_ok=True)
232
+ if target.exists() or target.is_symlink():
233
+ if not force:
234
+ return
235
+ target.unlink()
236
+ try:
237
+ os.link(source, target)
238
+ except OSError:
239
+ shutil.copy2(source, target)
240
+ target.chmod(0o755)
241
+
242
+
243
+ def ensure_runtime_binary(layout: TwikooLayout) -> Path:
244
+ """Return the canonical runtime binary, migrating an existing asset-named file if present."""
245
+
246
+ if layout.runtime_binary.exists():
247
+ return layout.runtime_binary
248
+ try:
249
+ asset_name = select_twikoo_asset_name()
250
+ except click.ClickException:
251
+ asset_name = ""
252
+ if asset_name:
253
+ candidate = layout.runtime / asset_name
254
+ if candidate.exists():
255
+ _link_or_copy_binary(candidate, layout.runtime_binary, force=False)
256
+ return layout.runtime_binary
257
+ raise click.ClickException(
258
+ f"Twikoo runtime binary is missing at {layout.runtime_binary}; run without --no-install first."
259
+ )
260
+
261
+
262
+ def _copy_support_files(runtime: Path, target_dir: Path, *, force: bool = False) -> list[str]:
263
+ copied: list[str] = []
264
+ target_dir.mkdir(parents=True, exist_ok=True)
265
+ for filename in TWIKOO_RUNTIME_SUPPORT_FILES:
266
+ source = runtime / filename
267
+ if not source.exists():
268
+ continue
269
+ target = target_dir / filename
270
+ if target.exists() and not force:
271
+ copied.append(filename)
272
+ continue
273
+ shutil.copy2(source, target)
274
+ copied.append(filename)
275
+ return copied
276
+
277
+
278
+ def init_instance(
279
+ *,
280
+ name: str = DEFAULT_TWIKOO_INSTANCE,
281
+ version: str = DEFAULT_TWIKOO_VERSION,
282
+ home: Path | None = None,
283
+ port: int = DEFAULT_TWIKOO_PORT,
284
+ bind_address: str = DEFAULT_TWIKOO_BIND_ADDRESS,
285
+ force: bool = False,
286
+ ) -> dict[str, Any]:
287
+ layout = twikoo_layout(name=name, version=version, home=home)
288
+ for directory in [layout.data, layout.logs, layout.run, layout.tmp, layout.env.parent, layout.instance_bin.parent]:
289
+ directory.mkdir(parents=True, exist_ok=True)
290
+ runtime_binary = ensure_runtime_binary(layout)
291
+ _link_or_copy_binary(runtime_binary, layout.instance_bin, force=force)
292
+ support_files = _copy_support_files(layout.runtime, layout.instance_bin.parent, force=force)
293
+ if not layout.env.exists() or force:
294
+ layout.env.write_text(render_env(layout, port=port, bind_address=bind_address), encoding="utf-8")
295
+ layout.env.chmod(0o600)
296
+ if layout.instance_env_link.exists() or layout.instance_env_link.is_symlink():
297
+ if force:
298
+ layout.instance_env_link.unlink()
299
+ if not layout.instance_env_link.exists() and not layout.instance_env_link.is_symlink():
300
+ os.symlink(Path("../env/twikoo.env"), layout.instance_env_link)
301
+ return {
302
+ "instance": str(layout.instance),
303
+ "binary": str(layout.instance_bin),
304
+ "env": str(layout.env),
305
+ "data": str(layout.data),
306
+ "support_files": support_files,
307
+ }
308
+
309
+
310
+ def install_service(
311
+ *,
312
+ name: str = DEFAULT_TWIKOO_INSTANCE,
313
+ version: str = DEFAULT_TWIKOO_VERSION,
314
+ home: Path | None = None,
315
+ force: bool = False,
316
+ ) -> dict[str, Any]:
317
+ layout = twikoo_layout(name=name, version=version, home=home)
318
+ layout.service.path.parent.mkdir(parents=True, exist_ok=True)
319
+ if not layout.service.path.exists() or force:
320
+ layout.service.path.write_text(render_service(layout, name=name), encoding="utf-8")
321
+ return {"unit": layout.service.name, "path": str(layout.service.path)}
322
+
323
+
324
+ def export_layout(
325
+ *,
326
+ name: str = DEFAULT_TWIKOO_INSTANCE,
327
+ version: str = DEFAULT_TWIKOO_VERSION,
328
+ home: Path | None = None,
329
+ ) -> dict[str, Any]:
330
+ layout = twikoo_layout(name=name, version=version, home=home)
331
+ data = asdict(layout)
332
+ data["service"] = {"name": layout.service.name, "path": str(layout.service.path)}
333
+ for key, value in list(data.items()):
334
+ if isinstance(value, Path):
335
+ data[key] = str(value)
336
+ return data
337
+
338
+
339
+ def _systemctl_user(*args: str) -> None:
340
+ subprocess.run(["systemctl", "--user", *args], check=True)
341
+
342
+
343
+ def start_service(name: str = DEFAULT_TWIKOO_INSTANCE) -> None:
344
+ unit = service_name(name)
345
+ _systemctl_user("daemon-reload")
346
+ _systemctl_user("enable", "--now", unit)
347
+
348
+
349
+ def smoke_twikoo(*, port: int = DEFAULT_TWIKOO_PORT, bind_address: str = DEFAULT_TWIKOO_BIND_ADDRESS) -> str:
350
+ import json
351
+
352
+ url = f"http://{bind_address}:{port}/"
353
+ with urllib.request.urlopen(url, timeout=15) as response:
354
+ body = response.read().decode("utf-8")
355
+ try:
356
+ payload = json.loads(body)
357
+ except json.JSONDecodeError as exc:
358
+ raise click.ClickException(f"Twikoo smoke did not return JSON: {body[:200]}") from exc
359
+ if payload.get("code") != 100:
360
+ raise click.ClickException(f"Twikoo smoke failed: {payload}")
361
+ return body
362
+
363
+
364
+ def setup_twikoo(
365
+ *,
366
+ name: str = DEFAULT_TWIKOO_INSTANCE,
367
+ version: str = DEFAULT_TWIKOO_VERSION,
368
+ repo: str = DEFAULT_TWIKOO_REPO,
369
+ home: Path | None = None,
370
+ port: int = DEFAULT_TWIKOO_PORT,
371
+ bind_address: str = DEFAULT_TWIKOO_BIND_ADDRESS,
372
+ install: bool = True,
373
+ init: bool = True,
374
+ service: bool = True,
375
+ start: bool = False,
376
+ smoke: bool = False,
377
+ force: bool = False,
378
+ log_level: str = "INFO",
379
+ ) -> dict[str, Any]:
380
+ _configure_logger(log_level)
381
+ validate_safe_name(name, field="instance name")
382
+ if smoke and not start:
383
+ raise click.ClickException("--smoke requires --start")
384
+ result: dict[str, Any] = {"layout": export_layout(name=name, version=version, home=home)}
385
+ if install:
386
+ result["install"] = install_twikoo_binary(repo=repo, version=version, home=home, force=force)
387
+ if init:
388
+ result["init"] = init_instance(
389
+ name=name,
390
+ version=version,
391
+ home=home,
392
+ port=port,
393
+ bind_address=bind_address,
394
+ force=force,
395
+ )
396
+ if service:
397
+ result["service"] = install_service(name=name, version=version, home=home, force=force)
398
+ if start:
399
+ start_service(name)
400
+ result["started"] = service_name(name)
401
+ if smoke:
402
+ result["smoke"] = smoke_twikoo(port=port, bind_address=bind_address)
403
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatup
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: ChatArch setup CLI extracted from ChatTool
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -48,6 +48,7 @@ src/chatup/setup/nodejs.py
48
48
  src/chatup/setup/opencode.py
49
49
  src/chatup/setup/service_credentials.py
50
50
  src/chatup/setup/shell_rc.py
51
+ src/chatup/setup/twikoo.py
51
52
  src/chatup/setup/uv.py
52
53
  src/chatup/setup/workspace.py
53
54
  src/chatup/setup/zsh.py
@@ -80,5 +81,6 @@ tests/test_publish_workflow.py
80
81
  tests/test_pyproject_metadata.py
81
82
  tests/test_service_installers.py
82
83
  tests/test_setup_cli.py
84
+ tests/test_twikoo_setup.py
83
85
  tests/test_version.py
84
86
  tests/test_workspace_setup.py
@@ -33,6 +33,15 @@ def test_bilingual_cli_tree_matches_registered_click_commands():
33
33
  assert _documented_reference_commands(ROOT / "docs" / "commands.en.md") == actual
34
34
 
35
35
 
36
+ def test_mkdocs_material_theme_has_material_icon_renderer():
37
+ mkdocs = (ROOT / "mkdocs.yml").read_text(encoding="utf-8")
38
+
39
+ assert "name: material" in mkdocs
40
+ assert "pymdownx.emoji" in mkdocs
41
+ assert "material.extensions.emoji.twemoji" in mkdocs
42
+ assert "material.extensions.emoji.to_svg" in mkdocs
43
+
44
+
36
45
  def test_cli_tree_is_in_mkdocs_nav_and_backend_contracts_are_complete():
37
46
  mkdocs = (ROOT / "mkdocs.yml").read_text(encoding="utf-8")
38
47
  assert "CLI 树: cli-tree.md" in mkdocs
@@ -11,6 +11,9 @@ def test_publish_workflow_matches_active_any_environment_publisher():
11
11
  assert "id-token: write" in workflow
12
12
  assert "pypa/gh-action-pypi-publish@release/v1" in workflow
13
13
  assert "environment: pypi" not in workflow
14
+ assert "Release workflow must run from a tag ref" in workflow
15
+ assert "GITHUB_REF_TYPE" in workflow
16
+ assert "GITHUB_REF_NAME" in workflow
14
17
  for secret_name in (
15
18
  "PYPI_API_TOKEN",
16
19
  "PYPI_TOKEN",
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from click.testing import CliRunner
4
+
5
+ from chatup.cli import main
6
+
7
+
8
+ def test_twikoo_layout_uses_chatarch_home_and_instance_scoped_paths(tmp_path):
9
+ from chatup.setup.twikoo import twikoo_layout
10
+
11
+ layout = twikoo_layout(name="chatblog", version="1.7.15", home=tmp_path / "twikoo")
12
+
13
+ assert layout.home == tmp_path / "twikoo"
14
+ assert layout.runtime == tmp_path / "twikoo" / "runtimes" / "1.7.15"
15
+ assert layout.runtime_binary == tmp_path / "twikoo" / "runtimes" / "1.7.15" / "twikoo"
16
+ assert layout.instance == tmp_path / "twikoo" / "instances" / "chatblog"
17
+ assert layout.instance_bin == tmp_path / "twikoo" / "instances" / "chatblog" / "bin" / "twikoo"
18
+ assert layout.instance_env_link == tmp_path / "twikoo" / "instances" / "chatblog" / "bin" / ".env"
19
+ assert layout.binary == layout.instance_bin
20
+ assert layout.env == tmp_path / "twikoo" / "instances" / "chatblog" / "env" / "twikoo.env"
21
+ assert layout.data == tmp_path / "twikoo" / "instances" / "chatblog" / "data"
22
+ assert layout.logs == tmp_path / "twikoo" / "instances" / "chatblog" / "logs"
23
+ assert layout.service.name == "chatarch-twikoo-chatblog.service"
24
+
25
+
26
+ def test_twikoo_render_env_and_service_are_instance_scoped(tmp_path):
27
+ from chatup.setup.twikoo import render_env, render_service, service_name, twikoo_layout
28
+
29
+ layout = twikoo_layout(name="chatblog", version="1.7.15", home=tmp_path / "twikoo")
30
+
31
+ env_text = render_env(layout, port=8892, bind_address="127.0.0.1")
32
+ assert "TWIKOO_HOST=127.0.0.1" in env_text
33
+ assert "TWIKOO_PORT=8892" in env_text
34
+ assert f"TWIKOO_DATA={layout.data}" in env_text
35
+ assert "MONGODB" not in env_text
36
+
37
+ service_text = render_service(layout, name="chatblog")
38
+ assert service_name("chatblog") == "chatarch-twikoo-chatblog.service"
39
+ assert f"WorkingDirectory={layout.instance}" in service_text
40
+ assert f"EnvironmentFile={layout.env}" in service_text
41
+ assert f"ExecStart={layout.binary}" in service_text
42
+ assert "WantedBy=default.target" in service_text
43
+
44
+
45
+ def test_twikoo_help_exposes_multi_instance_binary_installer_options():
46
+ result = CliRunner().invoke(main, ["twikoo", "--help"])
47
+
48
+ assert result.exit_code == 0, result.output
49
+ assert "--version" in result.output
50
+ assert "1.7.15" in result.output
51
+ assert "--repo" in result.output
52
+ assert "twikoojs/twikoo" in result.output
53
+ assert "--home" in result.output
54
+ assert "--name" in result.output
55
+ assert "--port" in result.output
56
+ assert "--bind-address" in result.output
57
+ assert "--start / --no-start" in result.output
58
+ assert "--smoke / --no-smoke" in result.output
59
+
60
+
61
+ def test_twikoo_cli_prepares_named_instance_without_starting(monkeypatch, tmp_path):
62
+ import chatup.setup.twikoo as twikoo_setup
63
+
64
+ calls = []
65
+ monkeypatch.setattr(
66
+ twikoo_setup,
67
+ "export_layout",
68
+ lambda **kwargs: {
69
+ "instance": str(tmp_path / "twikoo" / "instances" / kwargs["name"]),
70
+ "service": str(tmp_path / f"chatarch-twikoo-{kwargs['name']}.service"),
71
+ },
72
+ )
73
+ monkeypatch.setattr(
74
+ twikoo_setup,
75
+ "install_twikoo_binary",
76
+ lambda **kwargs: calls.append(("install", kwargs))
77
+ or {"binary": str(tmp_path / "twikoo" / "runtimes" / kwargs["version"] / "twikoo"), "reused": False},
78
+ )
79
+ monkeypatch.setattr(
80
+ twikoo_setup,
81
+ "init_instance",
82
+ lambda **kwargs: calls.append(("init", kwargs))
83
+ or {"env": str(tmp_path / "twikoo" / "instances" / kwargs["name"] / "env" / "twikoo.env")},
84
+ )
85
+ monkeypatch.setattr(
86
+ twikoo_setup,
87
+ "install_service",
88
+ lambda **kwargs: calls.append(("service", kwargs))
89
+ or {"unit": f"chatarch-twikoo-{kwargs['name']}.service"},
90
+ )
91
+
92
+ result = CliRunner().invoke(
93
+ main,
94
+ [
95
+ "twikoo",
96
+ "--home",
97
+ str(tmp_path / "twikoo"),
98
+ "--name",
99
+ "chatblog",
100
+ "--port",
101
+ "8892",
102
+ "--no-start",
103
+ "--no-smoke",
104
+ ],
105
+ )
106
+
107
+ assert result.exit_code == 0, result.output
108
+ assert [name for name, _ in calls] == ["install", "init", "service"]
109
+ assert calls[0][1]["repo"] == "twikoojs/twikoo"
110
+ assert calls[1][1]["name"] == "chatblog"
111
+ assert calls[1][1]["port"] == 8892
112
+ assert calls[1][1]["bind_address"] == "127.0.0.1"
113
+ assert "chatarch-twikoo-chatblog.service" in result.output
114
+
115
+
116
+ def test_twikoo_smoke_requires_start():
117
+ result = CliRunner().invoke(main, ["twikoo", "--no-install", "--no-init", "--no-service", "--smoke"])
118
+
119
+ assert result.exit_code != 0
120
+ assert "--smoke requires --start" in result.output
121
+
122
+
123
+ def test_twikoo_no_install_can_adopt_asset_named_runtime(monkeypatch, tmp_path):
124
+ from chatup.setup.twikoo import ensure_runtime_binary, twikoo_layout
125
+
126
+ monkeypatch.setattr("chatup.setup.twikoo.select_twikoo_asset_name", lambda: "twikoo-linux-x64")
127
+ layout = twikoo_layout(name="chatblog", version="1.7.15", home=tmp_path / "twikoo")
128
+ layout.runtime.mkdir(parents=True)
129
+ asset = layout.runtime / "twikoo-linux-x64"
130
+ asset.write_bytes(b"fake")
131
+ asset.chmod(0o755)
132
+
133
+ adopted = ensure_runtime_binary(layout)
134
+
135
+ assert adopted == layout.runtime_binary
136
+ assert layout.runtime_binary.read_bytes() == b"fake"
@@ -5,7 +5,7 @@ from chatup.cli import main
5
5
 
6
6
 
7
7
  def test_version_present():
8
- assert __version__ == "0.2.9"
8
+ assert __version__ == "0.2.11"
9
9
 
10
10
 
11
11
  def test_cli_help():
@@ -42,4 +42,4 @@ def test_cli_doctor():
42
42
  result = CliRunner().invoke(main, ["doctor"])
43
43
 
44
44
  assert result.exit_code == 0
45
- assert "chatup 0.2.9 ok" in result.output
45
+ assert "chatup 0.2.11 ok" in result.output
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes