chatup 0.2.20__tar.gz → 0.2.21__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.
- {chatup-0.2.20/src/chatup.egg-info → chatup-0.2.21}/PKG-INFO +6 -5
- {chatup-0.2.20 → chatup-0.2.21}/README.md +5 -4
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/__init__.py +1 -1
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/cli.py +2 -1
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/desktop.py +9 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/_managed_artifact.py +1 -1
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/desktop.py +83 -12
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/options.py +125 -6
- {chatup-0.2.20 → chatup-0.2.21/src/chatup.egg-info}/PKG-INFO +6 -5
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_browser_backend_cli.py +3 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_chrome_for_testing.py +18 -3
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_chromedriver.py +2 -2
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_cli_tree_docs.py +1 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_playwright.py +1 -1
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_version.py +1 -1
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_workspace_setup.py +126 -10
- {chatup-0.2.20 → chatup-0.2.21}/LICENSE +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/pyproject.toml +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/setup.cfg +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/chrome_for_testing.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/chromedriver.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/__init__.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/chrome_for_testing.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/chromedriver.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/macos.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/playwright.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/remotion.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/config.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/const.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/__init__.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/command_schema.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/policy.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/warnings.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/playwright.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/__init__.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/chrome_for_testing.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/chromedriver.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/playwright.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/__init__.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/hermes/install.sh +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/nvm.sh +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/.gitignore +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/package-lock.json +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/package.json +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/src/index.jsx +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/cc_connect.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/chatgpt.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/claude.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/cli.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/codex.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/crs.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/cursor_agent.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/discourse.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/docker.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/elements.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/frp.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/gitea.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/hermes.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/lark_cli.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/mode_prompt.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/mysql.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/nginx.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/nodejs.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/opencode.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/remotion.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/service_credentials.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/shell_rc.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/twikoo.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/uv.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/__init__.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/cli.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/core.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/render.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/AGENTS.md +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/discussion/README.md +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/projects/README.md +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/AGENTS.md +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/projects/README.md +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/zsh.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/zulip.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/__init__.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/custom_logger.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/pathing.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/platforming.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/SOURCES.txt +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/dependency_links.txt +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/entry_points.txt +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/requires.txt +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/top_level.txt +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_chatgpt_setup.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_codex_setup.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_crs_setup.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_cursor_agent_setup.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_hermes_model_defaults.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_playwright_cli.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_publish_workflow.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_pyproject_metadata.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_service_installers.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_setup_cli.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_twikoo_setup.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_uv_activation.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_windows_compat.py +0 -0
- {chatup-0.2.20 → chatup-0.2.21}/tests/test_zsh_alias_names.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.21
|
|
4
4
|
Summary: ChatArch setup CLI extracted from ChatTool
|
|
5
5
|
Author-email: rexwzh <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,7 +59,7 @@ chatup doctor
|
|
|
59
59
|
chatup uv
|
|
60
60
|
chatup chrome-for-testing install --channel stable -I
|
|
61
61
|
chatup playwright install 1.61.1 -I
|
|
62
|
-
chatup workspace
|
|
62
|
+
chatup workspace ~/Playground
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
`chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
|
|
@@ -99,16 +99,17 @@ npm run studio
|
|
|
99
99
|
|
|
100
100
|
需要已有 Node.js >=18.12 和 npm >=9。创建带锁文件的 Remotion 4.0.530 项目,检测本机 Chrome 并输出渲染命令;不覆盖已有无关目录,也不替换 Node 或下载浏览器。项目放在指定目录,已有 Blender/Chrome 可通过 `chatup macos --app blender --app chrome` 验证或安装。详见[Remotion 安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#remotion)。
|
|
101
101
|
|
|
102
|
-
## Chrome 与 iTerm2
|
|
102
|
+
## Chrome、Snipaste 与 iTerm2
|
|
103
103
|
|
|
104
104
|
```bash
|
|
105
105
|
chatup chrome --dry-run
|
|
106
106
|
chatup chrome # 按系统安装普通 Google Chrome
|
|
107
|
+
chatup snipaste # 在 macOS 或 Windows 安装 Snipaste
|
|
107
108
|
chatup iterm # 安装 iTerm2,仅限 macOS
|
|
108
109
|
chatup chrome --sudo --yes # Linux:允许提权并确认包安装
|
|
109
110
|
```
|
|
110
111
|
|
|
111
|
-
macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet `Google.Chrome
|
|
112
|
+
macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet 精确包 `Google.Chrome.EXE`(当前用户安装)和 `liule.Snipaste`,需以 `--yes` 接受首次协议,Chrome 还会验证已安装程序的 Google 签名、产品身份和版本;Linux x86_64 支持 Chrome 的官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
|
|
112
113
|
|
|
113
114
|
## ChatGPT / Codex 桌面应用
|
|
114
115
|
|
|
@@ -128,7 +129,7 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
|
|
|
128
129
|
| --- | --- |
|
|
129
130
|
| 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
|
|
130
131
|
| 工作区脚手架 | `workspace` |
|
|
131
|
-
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
132
|
+
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
132
133
|
| 视频项目 | `remotion` |
|
|
133
134
|
| 本地服务安装 | `gitea`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
|
|
134
135
|
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
@@ -28,7 +28,7 @@ chatup doctor
|
|
|
28
28
|
chatup uv
|
|
29
29
|
chatup chrome-for-testing install --channel stable -I
|
|
30
30
|
chatup playwright install 1.61.1 -I
|
|
31
|
-
chatup workspace
|
|
31
|
+
chatup workspace ~/Playground
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
`chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
|
|
@@ -68,16 +68,17 @@ npm run studio
|
|
|
68
68
|
|
|
69
69
|
需要已有 Node.js >=18.12 和 npm >=9。创建带锁文件的 Remotion 4.0.530 项目,检测本机 Chrome 并输出渲染命令;不覆盖已有无关目录,也不替换 Node 或下载浏览器。项目放在指定目录,已有 Blender/Chrome 可通过 `chatup macos --app blender --app chrome` 验证或安装。详见[Remotion 安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#remotion)。
|
|
70
70
|
|
|
71
|
-
## Chrome 与 iTerm2
|
|
71
|
+
## Chrome、Snipaste 与 iTerm2
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
chatup chrome --dry-run
|
|
75
75
|
chatup chrome # 按系统安装普通 Google Chrome
|
|
76
|
+
chatup snipaste # 在 macOS 或 Windows 安装 Snipaste
|
|
76
77
|
chatup iterm # 安装 iTerm2,仅限 macOS
|
|
77
78
|
chatup chrome --sudo --yes # Linux:允许提权并确认包安装
|
|
78
79
|
```
|
|
79
80
|
|
|
80
|
-
macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet `Google.Chrome
|
|
81
|
+
macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet 精确包 `Google.Chrome.EXE`(当前用户安装)和 `liule.Snipaste`,需以 `--yes` 接受首次协议,Chrome 还会验证已安装程序的 Google 签名、产品身份和版本;Linux x86_64 支持 Chrome 的官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
|
|
81
82
|
|
|
82
83
|
## ChatGPT / Codex 桌面应用
|
|
83
84
|
|
|
@@ -97,7 +98,7 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
|
|
|
97
98
|
| --- | --- |
|
|
98
99
|
| 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
|
|
99
100
|
| 工作区脚手架 | `workspace` |
|
|
100
|
-
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
101
|
+
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
101
102
|
| 视频项目 | `remotion` |
|
|
102
103
|
| 本地服务安装 | `gitea`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
|
|
103
104
|
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
@@ -6,7 +6,7 @@ from chatstyle import add_tree_option
|
|
|
6
6
|
from chatup import __version__
|
|
7
7
|
from chatup.commands.chrome_for_testing import cli as chrome_for_testing_cli
|
|
8
8
|
from chatup.commands.chromedriver import cli as chromedriver_cli
|
|
9
|
-
from chatup.commands.desktop import chrome_cli, iterm_cli
|
|
9
|
+
from chatup.commands.desktop import chrome_cli, iterm_cli, snipaste_cli
|
|
10
10
|
from chatup.commands.macos import macos_cli
|
|
11
11
|
from chatup.commands.playwright import cli as playwright_cli
|
|
12
12
|
from chatup.commands.remotion import remotion_cli
|
|
@@ -36,6 +36,7 @@ main.add_command(chrome_for_testing_cli)
|
|
|
36
36
|
main.add_command(chromedriver_cli)
|
|
37
37
|
main.add_command(playwright_cli)
|
|
38
38
|
main.add_command(chrome_cli)
|
|
39
|
+
main.add_command(snipaste_cli)
|
|
39
40
|
main.add_command(iterm_cli)
|
|
40
41
|
main.add_command(macos_cli)
|
|
41
42
|
main.add_command(remotion_cli)
|
|
@@ -44,6 +44,15 @@ def chrome_cli(**kwargs) -> None:
|
|
|
44
44
|
_install("chrome", **kwargs)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
@click.command(name="snipaste")
|
|
48
|
+
@click.option("--dry-run", is_flag=True, help="Show the installation plan without downloading or installing.")
|
|
49
|
+
@click.option("--yes", "-y", is_flag=True, help="Accept Windows agreements.")
|
|
50
|
+
@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False), default="INFO")
|
|
51
|
+
def snipaste_cli(**kwargs) -> None:
|
|
52
|
+
"""Install Snipaste for macOS or Windows."""
|
|
53
|
+
_install("snipaste", **kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
47
56
|
@click.command(name="iterm")
|
|
48
57
|
@click.option("--dry-run", is_flag=True, help="Show the installation plan without downloading or installing.")
|
|
49
58
|
@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False), default="INFO")
|
|
@@ -79,7 +79,7 @@ def safe_extract_zip(
|
|
|
79
79
|
f"Symlink escapes {label} archive root: {member.filename}"
|
|
80
80
|
)
|
|
81
81
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
-
target.symlink_to(link_value)
|
|
82
|
+
target.symlink_to(link_value, target_is_directory=resolved_link.is_dir())
|
|
83
83
|
continue
|
|
84
84
|
if member.is_dir() or stat.S_ISDIR(mode):
|
|
85
85
|
target.mkdir(parents=True, exist_ok=True)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
"""Native installers for
|
|
1
|
+
"""Native installers for Chrome, Snipaste and supported macOS desktop apps."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import logging
|
|
5
5
|
import hashlib
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
6
8
|
import os
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
import platform
|
|
@@ -22,6 +24,8 @@ logger = logging.getLogger(__name__)
|
|
|
22
24
|
MAC_APPS = {
|
|
23
25
|
"chrome": {
|
|
24
26
|
"app": "Google Chrome",
|
|
27
|
+
"winget_id": "Google.Chrome.EXE",
|
|
28
|
+
"winget_scope": "user",
|
|
25
29
|
"bundle": "Google Chrome.app",
|
|
26
30
|
"bundle_id": "com.google.Chrome",
|
|
27
31
|
"team_id": "EQHXZ8M8AV",
|
|
@@ -38,6 +42,7 @@ MAC_APPS = {
|
|
|
38
42
|
},
|
|
39
43
|
"snipaste": {
|
|
40
44
|
"app": "Snipaste",
|
|
45
|
+
"winget_id": "liule.Snipaste",
|
|
41
46
|
"bundle": "Snipaste.app",
|
|
42
47
|
"bundle_id": "com.Snipaste",
|
|
43
48
|
"team_id": "NGTL73P583",
|
|
@@ -73,15 +78,15 @@ def _is_root() -> bool:
|
|
|
73
78
|
return hasattr(os, "geteuid") and os.geteuid() == 0
|
|
74
79
|
|
|
75
80
|
|
|
76
|
-
def plan_desktop_install(
|
|
77
|
-
app: str, *, sudo: bool = False, yes: bool = False,
|
|
78
|
-
) -> dict[str, Any]:
|
|
81
|
+
def plan_desktop_install(app: str, *, sudo: bool = False, yes: bool = False) -> dict[str, Any]:
|
|
79
82
|
"""Describe the current platform's installation without writes or processes."""
|
|
80
83
|
spec = MAC_APPS[app]
|
|
81
84
|
system = platform.system()
|
|
82
85
|
plan = {"app": spec["app"], "platform": system}
|
|
83
|
-
if
|
|
86
|
+
if system != "Darwin" and app in {"iterm", "blender"}:
|
|
84
87
|
raise RuntimeError(f"{spec['app']} is supported on macOS only; current platform: {system}.")
|
|
88
|
+
if system not in {"Darwin", "Windows"} and app == "snipaste":
|
|
89
|
+
raise RuntimeError(f"{spec['app']} is supported on macOS and Windows only; current platform: {system}.")
|
|
85
90
|
if system == "Darwin":
|
|
86
91
|
if platform.machine().lower() not in {"arm64", "aarch64", "x86_64", "amd64"}:
|
|
87
92
|
raise RuntimeError(f"Unsupported macOS architecture: {platform.machine()}.")
|
|
@@ -92,13 +97,24 @@ def plan_desktop_install(
|
|
|
92
97
|
directory = directories[0] if os.access(directories[0], os.W_OK) else directories[1]
|
|
93
98
|
return {**plan, **spec, "method": "macos", "path": str(existing or directory / spec["bundle"])}
|
|
94
99
|
if system == "Windows":
|
|
95
|
-
|
|
100
|
+
package_id = spec.get("winget_id")
|
|
101
|
+
if not package_id:
|
|
102
|
+
raise RuntimeError(f"{spec['app']} is supported on macOS only; current platform: {system}.")
|
|
103
|
+
selector = ["--id", package_id, "--exact", "--source", "winget", "--disable-interactivity"]
|
|
96
104
|
command = ["winget", "install", *selector, "--silent", "--no-upgrade"]
|
|
105
|
+
if spec.get("winget_scope"):
|
|
106
|
+
command += ["--scope", spec["winget_scope"]]
|
|
97
107
|
verify = ["winget", "list", *selector]
|
|
98
108
|
if yes:
|
|
99
109
|
command += ["--accept-package-agreements", "--accept-source-agreements"]
|
|
100
110
|
verify += ["--accept-source-agreements"]
|
|
101
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
**plan,
|
|
113
|
+
"method": "winget",
|
|
114
|
+
"winget_id": package_id,
|
|
115
|
+
"command": command,
|
|
116
|
+
"verify_command": verify,
|
|
117
|
+
}
|
|
102
118
|
if system == "Linux":
|
|
103
119
|
if platform.machine().lower() not in {"x86_64", "amd64"}:
|
|
104
120
|
raise RuntimeError("Google Chrome for Linux requires x86_64; Google does not provide a Linux ARM package.")
|
|
@@ -274,22 +290,77 @@ def _install_linux(plan: dict[str, Any], *, sudo: bool) -> dict[str, Any]:
|
|
|
274
290
|
return {**plan, **installed, "status": "installed", "verified": True}
|
|
275
291
|
|
|
276
292
|
|
|
293
|
+
def _google_signed_file(path: Path) -> dict[str, str]:
|
|
294
|
+
powershell = shutil.which("powershell.exe") or shutil.which("pwsh.exe")
|
|
295
|
+
if not powershell:
|
|
296
|
+
raise RuntimeError("PowerShell is required to verify Google's Authenticode signature.")
|
|
297
|
+
# Encode the script and quote its sole path literal, including apostrophes.
|
|
298
|
+
literal = str(path).replace("'", "''")
|
|
299
|
+
script = (
|
|
300
|
+
"$ErrorActionPreference = 'Stop'; "
|
|
301
|
+
"$ProgressPreference = 'SilentlyContinue'; "
|
|
302
|
+
"$env:PSModulePath = Join-Path $PSHOME 'Modules'; "
|
|
303
|
+
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); "
|
|
304
|
+
f"$file = Get-Item -LiteralPath '{literal}'; "
|
|
305
|
+
"$sig = Get-AuthenticodeSignature -LiteralPath $file.FullName; "
|
|
306
|
+
"$publisher = if ($sig.SignerCertificate) { "
|
|
307
|
+
"$sig.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) "
|
|
308
|
+
"} else { '' }; "
|
|
309
|
+
"@{status=[string]$sig.Status; publisher=$publisher; "
|
|
310
|
+
"product=$file.VersionInfo.ProductName; version=$file.VersionInfo.ProductVersion; "
|
|
311
|
+
"original=$file.VersionInfo.OriginalFilename} | ConvertTo-Json -Compress"
|
|
312
|
+
)
|
|
313
|
+
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
|
314
|
+
result = _run([powershell, "-NoProfile", "-NonInteractive", "-OutputFormat", "Text",
|
|
315
|
+
"-EncodedCommand", encoded], timeout=120)
|
|
316
|
+
details = json.loads(result.stdout.lstrip("\ufeff"))
|
|
317
|
+
if not isinstance(details, dict) or details.get("status") != "Valid" or details.get("publisher") != "Google LLC":
|
|
318
|
+
raise RuntimeError(f"Google Authenticode signature verification failed: {path}.")
|
|
319
|
+
version = details.get("version", "")
|
|
320
|
+
if (details.get("product") != "Google Chrome"
|
|
321
|
+
or str(details.get("original", "")).lower() != "chrome.exe"
|
|
322
|
+
or not isinstance(version, str) or not re.fullmatch(r"\d+(?:\.\d+){3}", version)):
|
|
323
|
+
raise RuntimeError(f"Cannot verify installed Google Chrome identity/version: {path}.")
|
|
324
|
+
return {"binary": str(path), "path": str(path), "version": version}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _windows_chrome() -> dict[str, str] | None:
|
|
328
|
+
for variable in ("LOCALAPPDATA", "PROGRAMW6432", "PROGRAMFILES", "PROGRAMFILES(X86)"):
|
|
329
|
+
root = os.environ.get(variable)
|
|
330
|
+
if root:
|
|
331
|
+
binary = Path(root) / "Google/Chrome/Application/chrome.exe"
|
|
332
|
+
if binary.exists():
|
|
333
|
+
return _google_signed_file(binary)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
277
337
|
def _install_windows(plan: dict[str, Any]) -> dict[str, Any]:
|
|
338
|
+
is_chrome = plan["winget_id"] == "Google.Chrome.EXE"
|
|
339
|
+
existing = _windows_chrome() if is_chrome else None
|
|
340
|
+
if existing:
|
|
341
|
+
return {**plan, **existing, "status": "already_installed", "verified": True}
|
|
278
342
|
executable = shutil.which("winget")
|
|
279
343
|
if not executable:
|
|
280
|
-
raise RuntimeError("WinGet is required to install
|
|
344
|
+
raise RuntimeError(f"WinGet is required to install {plan['app']}: https://aka.ms/getwinget")
|
|
281
345
|
|
|
282
346
|
def installed():
|
|
283
347
|
result = _run([executable, *plan["verify_command"][1:]], timeout=120, check=False)
|
|
284
|
-
return result.returncode == 0 and re.search(
|
|
348
|
+
return result.returncode == 0 and re.search(
|
|
349
|
+
rf"(?<![\w.-]){re.escape(plan['winget_id'])}(?![\w.-])", result.stdout,
|
|
350
|
+
) is not None
|
|
285
351
|
|
|
286
352
|
if installed():
|
|
353
|
+
if is_chrome:
|
|
354
|
+
raise RuntimeError("Google Chrome package record exists, but signed chrome.exe was not found.")
|
|
287
355
|
return {**plan, "status": "already_installed", "verified": True}
|
|
288
|
-
logger.info("Installing
|
|
356
|
+
logger.info("Installing %s with WinGet; use --yes to accept source/package agreements", plan["app"])
|
|
289
357
|
_run([executable, *plan["command"][1:]], capture=False)
|
|
290
358
|
if not installed():
|
|
291
|
-
raise RuntimeError("
|
|
292
|
-
|
|
359
|
+
raise RuntimeError(f"{plan['app']} installation verification failed: no matching WinGet package record.")
|
|
360
|
+
details = _windows_chrome() if is_chrome else {}
|
|
361
|
+
if is_chrome and not details:
|
|
362
|
+
raise RuntimeError("Google Chrome installation verification failed: signed chrome.exe not found.")
|
|
363
|
+
return {**plan, **details, "status": "installed", "verified": True}
|
|
293
364
|
|
|
294
365
|
|
|
295
366
|
def setup_desktop_app(
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
4
7
|
import subprocess
|
|
5
8
|
|
|
6
9
|
import click
|
|
@@ -27,14 +30,60 @@ Topic directories such as `chatarch/package-development` and `chatarch/package-r
|
|
|
27
30
|
"""
|
|
28
31
|
|
|
29
32
|
|
|
33
|
+
def _resolve_git_command() -> str | None:
|
|
34
|
+
git = shutil.which("git")
|
|
35
|
+
if git:
|
|
36
|
+
return git
|
|
37
|
+
if os.name != "nt":
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
candidates: list[Path] = []
|
|
41
|
+
local_app_data = os.environ.get("LOCALAPPDATA")
|
|
42
|
+
if local_app_data:
|
|
43
|
+
candidates.extend(
|
|
44
|
+
[
|
|
45
|
+
Path(local_app_data) / "Programs" / "Git" / "cmd" / "git.exe",
|
|
46
|
+
Path(local_app_data) / "Programs" / "Git" / "bin" / "git.exe",
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
|
|
50
|
+
root = os.environ.get(env_name)
|
|
51
|
+
if root:
|
|
52
|
+
candidates.extend(
|
|
53
|
+
[
|
|
54
|
+
Path(root) / "Git" / "cmd" / "git.exe",
|
|
55
|
+
Path(root) / "Git" / "bin" / "git.exe",
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for candidate in candidates:
|
|
60
|
+
if candidate.is_file():
|
|
61
|
+
return str(candidate)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_GIT_NOT_FOUND_MESSAGE = (
|
|
66
|
+
"Git executable not found. Install Git and make sure `git` is available on PATH. "
|
|
67
|
+
"On Windows, standard Git for Windows locations are also supported. "
|
|
68
|
+
"Then rerun this command."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
30
72
|
def _run_git(
|
|
31
73
|
args: list[str], workdir: Path | None = None
|
|
32
74
|
) -> subprocess.CompletedProcess[str]:
|
|
33
|
-
|
|
75
|
+
git = _resolve_git_command()
|
|
76
|
+
if git is None:
|
|
77
|
+
raise click.ClickException(_GIT_NOT_FOUND_MESSAGE)
|
|
78
|
+
|
|
79
|
+
command = [git]
|
|
34
80
|
if workdir is not None:
|
|
35
81
|
command.extend(["-C", str(workdir)])
|
|
36
82
|
command.extend(args)
|
|
37
|
-
|
|
83
|
+
try:
|
|
84
|
+
return subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
85
|
+
except FileNotFoundError as exc:
|
|
86
|
+
raise click.ClickException(_GIT_NOT_FOUND_MESSAGE) from exc
|
|
38
87
|
|
|
39
88
|
|
|
40
89
|
def _clone_or_update_repo(
|
|
@@ -92,15 +141,85 @@ def apply_chattool_option(
|
|
|
92
141
|
}
|
|
93
142
|
|
|
94
143
|
|
|
144
|
+
def _paths_point_to_same_entry(source: Path, target: Path) -> bool:
|
|
145
|
+
try:
|
|
146
|
+
return source.exists() and target.exists() and source.samefile(target)
|
|
147
|
+
except OSError:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_windows_reparse_point(path: Path) -> bool:
|
|
152
|
+
if os.name != "nt":
|
|
153
|
+
return False
|
|
154
|
+
try:
|
|
155
|
+
info = path.stat(follow_symlinks=False)
|
|
156
|
+
except (AttributeError, OSError, ValueError):
|
|
157
|
+
return False
|
|
158
|
+
return info.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _remove_link_path(path: Path) -> None:
|
|
162
|
+
if os.name == "nt" and path.is_dir():
|
|
163
|
+
path.rmdir()
|
|
164
|
+
else:
|
|
165
|
+
path.unlink()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _create_windows_junction(source: Path, target: Path) -> None:
|
|
169
|
+
comspec = os.environ.get("ComSpec", "cmd")
|
|
170
|
+
# Pass paths through quoted environment expansion, not cmd command syntax.
|
|
171
|
+
env = {**os.environ, "CHATUP_LINK_SOURCE": str(source), "CHATUP_LINK_TARGET": str(target)}
|
|
172
|
+
command = (
|
|
173
|
+
f'{subprocess.list2cmdline([comspec])} /d /v:off /c '
|
|
174
|
+
'mklink /J "%CHATUP_LINK_TARGET%" "%CHATUP_LINK_SOURCE%"'
|
|
175
|
+
)
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
command,
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
errors="replace",
|
|
181
|
+
env=env,
|
|
182
|
+
)
|
|
183
|
+
if result.returncode != 0:
|
|
184
|
+
message = result.stderr.strip() or result.stdout.strip()
|
|
185
|
+
raise click.ClickException(message or f"Failed to create junction: {target}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _create_windows_link_fallback(source: Path, target: Path, original: OSError) -> None:
|
|
189
|
+
if os.name != "nt":
|
|
190
|
+
raise click.ClickException(
|
|
191
|
+
f"Failed to create symlink {target} -> {source}: {original}"
|
|
192
|
+
) from original
|
|
193
|
+
try:
|
|
194
|
+
if source.is_dir():
|
|
195
|
+
_create_windows_junction(source.resolve(), target)
|
|
196
|
+
else:
|
|
197
|
+
os.link(source, target)
|
|
198
|
+
except (OSError, click.ClickException) as exc:
|
|
199
|
+
raise click.ClickException(
|
|
200
|
+
"Failed to create a workspace link on Windows. Enable Developer Mode "
|
|
201
|
+
"or run with symlink privileges; directory junction/file hard-link "
|
|
202
|
+
f"fallback also failed for {target} -> {source}: {exc}"
|
|
203
|
+
) from exc
|
|
204
|
+
|
|
205
|
+
|
|
95
206
|
def _ensure_symlink(source: Path, target: Path) -> None:
|
|
96
207
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
-
if target.is_symlink():
|
|
98
|
-
target
|
|
208
|
+
if target.is_symlink() or _is_windows_reparse_point(target):
|
|
209
|
+
if _paths_point_to_same_entry(source, target):
|
|
210
|
+
return
|
|
211
|
+
_remove_link_path(target)
|
|
99
212
|
elif target.exists():
|
|
213
|
+
if _paths_point_to_same_entry(source, target):
|
|
214
|
+
return
|
|
100
215
|
raise click.ClickException(
|
|
101
|
-
f"Refusing to replace existing non-
|
|
216
|
+
f"Refusing to replace existing non-link path: {target}"
|
|
102
217
|
)
|
|
103
|
-
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
target.symlink_to(source, target_is_directory=source.is_dir())
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
_create_windows_link_fallback(source, target, exc)
|
|
104
223
|
|
|
105
224
|
|
|
106
225
|
def _ensure_local_skill_group(workspace_dir: Path) -> Path:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.21
|
|
4
4
|
Summary: ChatArch setup CLI extracted from ChatTool
|
|
5
5
|
Author-email: rexwzh <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,7 +59,7 @@ chatup doctor
|
|
|
59
59
|
chatup uv
|
|
60
60
|
chatup chrome-for-testing install --channel stable -I
|
|
61
61
|
chatup playwright install 1.61.1 -I
|
|
62
|
-
chatup workspace
|
|
62
|
+
chatup workspace ~/Playground
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
`chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
|
|
@@ -99,16 +99,17 @@ npm run studio
|
|
|
99
99
|
|
|
100
100
|
需要已有 Node.js >=18.12 和 npm >=9。创建带锁文件的 Remotion 4.0.530 项目,检测本机 Chrome 并输出渲染命令;不覆盖已有无关目录,也不替换 Node 或下载浏览器。项目放在指定目录,已有 Blender/Chrome 可通过 `chatup macos --app blender --app chrome` 验证或安装。详见[Remotion 安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#remotion)。
|
|
101
101
|
|
|
102
|
-
## Chrome 与 iTerm2
|
|
102
|
+
## Chrome、Snipaste 与 iTerm2
|
|
103
103
|
|
|
104
104
|
```bash
|
|
105
105
|
chatup chrome --dry-run
|
|
106
106
|
chatup chrome # 按系统安装普通 Google Chrome
|
|
107
|
+
chatup snipaste # 在 macOS 或 Windows 安装 Snipaste
|
|
107
108
|
chatup iterm # 安装 iTerm2,仅限 macOS
|
|
108
109
|
chatup chrome --sudo --yes # Linux:允许提权并确认包安装
|
|
109
110
|
```
|
|
110
111
|
|
|
111
|
-
macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet `Google.Chrome
|
|
112
|
+
macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet 精确包 `Google.Chrome.EXE`(当前用户安装)和 `liule.Snipaste`,需以 `--yes` 接受首次协议,Chrome 还会验证已安装程序的 Google 签名、产品身份和版本;Linux x86_64 支持 Chrome 的官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
|
|
112
113
|
|
|
113
114
|
## ChatGPT / Codex 桌面应用
|
|
114
115
|
|
|
@@ -128,7 +129,7 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
|
|
|
128
129
|
| --- | --- |
|
|
129
130
|
| 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
|
|
130
131
|
| 工作区脚手架 | `workspace` |
|
|
131
|
-
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
132
|
+
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
132
133
|
| 视频项目 | `remotion` |
|
|
133
134
|
| 本地服务安装 | `gitea`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
|
|
134
135
|
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
@@ -15,8 +15,11 @@ def test_browser_artifacts_have_independent_top_level_backends():
|
|
|
15
15
|
assert "chromedriver" in commands
|
|
16
16
|
assert "playwright" in commands
|
|
17
17
|
assert "chrome" in commands
|
|
18
|
+
assert "snipaste" in commands
|
|
18
19
|
assert not isinstance(main.commands["chrome"], click.Group)
|
|
20
|
+
assert not isinstance(main.commands["snipaste"], click.Group)
|
|
19
21
|
assert "regular Google Chrome" in main.commands["chrome"].help
|
|
22
|
+
assert "Snipaste" in main.commands["snipaste"].help
|
|
20
23
|
assert "browser" not in commands
|
|
21
24
|
assert "chromium" not in commands
|
|
22
25
|
|
|
@@ -263,7 +263,7 @@ def test_resolve_rejects_non_object_metadata(tmp_path):
|
|
|
263
263
|
)
|
|
264
264
|
|
|
265
265
|
|
|
266
|
-
def test_force_install_rejects_symlink_root(tmp_path):
|
|
266
|
+
def test_force_install_rejects_symlink_root(tmp_path, require_symlink_privilege):
|
|
267
267
|
home = tmp_path / "chrome-for-testing"
|
|
268
268
|
outside = tmp_path / "outside"
|
|
269
269
|
outside.mkdir()
|
|
@@ -281,7 +281,7 @@ def test_force_install_rejects_symlink_root(tmp_path):
|
|
|
281
281
|
)
|
|
282
282
|
|
|
283
283
|
|
|
284
|
-
def test_install_rejects_untrusted_resolver_identity_and_parent_symlink(tmp_path):
|
|
284
|
+
def test_install_rejects_untrusted_resolver_identity_and_parent_symlink(tmp_path, require_symlink_privilege):
|
|
285
285
|
home = tmp_path / "chrome-for-testing"
|
|
286
286
|
downloaded = False
|
|
287
287
|
|
|
@@ -402,7 +402,22 @@ def test_safe_extract_rejects_path_traversal_and_special_files(tmp_path):
|
|
|
402
402
|
safe_extract_chrome_for_testing_zip(special, tmp_path / "special-out")
|
|
403
403
|
|
|
404
404
|
|
|
405
|
-
def
|
|
405
|
+
def test_safe_extract_marks_directory_symlinks(tmp_path, monkeypatch):
|
|
406
|
+
internal = tmp_path / "internal.zip"
|
|
407
|
+
with zipfile.ZipFile(internal, "w") as bundle:
|
|
408
|
+
bundle.writestr("app/Versions/1/chrome", "binary")
|
|
409
|
+
link = zipfile.ZipInfo("app/Versions/Current")
|
|
410
|
+
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
|
411
|
+
bundle.writestr(link, "1")
|
|
412
|
+
|
|
413
|
+
calls = []
|
|
414
|
+
monkeypatch.setattr(Path, "symlink_to", lambda self, target, target_is_directory=False:
|
|
415
|
+
calls.append((target, target_is_directory)))
|
|
416
|
+
safe_extract_chrome_for_testing_zip(internal, tmp_path / "output")
|
|
417
|
+
assert calls == [("1", True)]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_safe_extract_allows_internal_symlink_and_rejects_escape(tmp_path, require_symlink_privilege):
|
|
406
421
|
internal = tmp_path / "internal.zip"
|
|
407
422
|
with zipfile.ZipFile(internal, "w") as bundle:
|
|
408
423
|
bundle.writestr("app/Versions/1/chrome", "binary")
|
|
@@ -400,7 +400,7 @@ def test_chromedriver_rejects_non_object_metadata(tmp_path):
|
|
|
400
400
|
)
|
|
401
401
|
|
|
402
402
|
|
|
403
|
-
def test_chromedriver_force_install_rejects_symlink_root(tmp_path):
|
|
403
|
+
def test_chromedriver_force_install_rejects_symlink_root(tmp_path, require_symlink_privilege):
|
|
404
404
|
home = tmp_path / "chromedriver"
|
|
405
405
|
outside = tmp_path / "outside"
|
|
406
406
|
outside.mkdir()
|
|
@@ -419,7 +419,7 @@ def test_chromedriver_force_install_rejects_symlink_root(tmp_path):
|
|
|
419
419
|
|
|
420
420
|
|
|
421
421
|
def test_driver_install_rejects_untrusted_resolver_identity_and_parent_symlink(
|
|
422
|
-
tmp_path,
|
|
422
|
+
tmp_path, require_symlink_privilege,
|
|
423
423
|
):
|
|
424
424
|
home = tmp_path / "chromedriver"
|
|
425
425
|
downloaded = False
|
|
@@ -81,5 +81,6 @@ def test_cli_tree_is_in_mkdocs_nav_and_backend_contracts_are_complete():
|
|
|
81
81
|
missing = sorted(item for item in required if item not in text)
|
|
82
82
|
assert not missing, f"{name} is missing backend contract terms: {missing}"
|
|
83
83
|
assert "chatup chrome" in text
|
|
84
|
+
assert "chatup snipaste" in text
|
|
84
85
|
assert "chatup iterm" in text
|
|
85
86
|
assert "from chatup.chrome import" not in text
|
|
@@ -88,7 +88,7 @@ def test_install_requires_exact_version_supported_browser_and_node(tmp_path):
|
|
|
88
88
|
)
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
def test_install_rejects_parent_symlink_escape(tmp_path):
|
|
91
|
+
def test_install_rejects_parent_symlink_escape(tmp_path, require_symlink_privilege):
|
|
92
92
|
home = tmp_path / "playwright"
|
|
93
93
|
outside = tmp_path / "outside"
|
|
94
94
|
outside.mkdir()
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
from types import SimpleNamespace
|
|
2
4
|
|
|
5
|
+
import click
|
|
6
|
+
import pytest
|
|
3
7
|
from click.testing import CliRunner
|
|
4
8
|
|
|
5
9
|
from chatup.cli import main
|
|
6
10
|
|
|
7
11
|
|
|
12
|
+
def _assert_link_points_to(link: Path, target: Path) -> None:
|
|
13
|
+
assert link.exists()
|
|
14
|
+
try:
|
|
15
|
+
assert link.resolve() == target.resolve() or link.samefile(target)
|
|
16
|
+
except OSError:
|
|
17
|
+
assert False, f"{link} does not point to {target}"
|
|
18
|
+
|
|
19
|
+
|
|
8
20
|
def test_workspace_template_includes_chatarch_lifecycle_dirs(tmp_path):
|
|
9
21
|
workspace_dir = tmp_path / "workspace"
|
|
10
22
|
|
|
@@ -49,8 +61,7 @@ def test_workspace_chatblog_missing_docs_creates_placeholder_and_public_link(tmp
|
|
|
49
61
|
assert result.exit_code == 0, result.output
|
|
50
62
|
public_link = workspace_dir / "public" / "chatblog"
|
|
51
63
|
docs_dir = workspace_dir / "core" / "ChatBlog" / "docs"
|
|
52
|
-
|
|
53
|
-
assert public_link.resolve() == docs_dir
|
|
64
|
+
_assert_link_points_to(public_link, docs_dir)
|
|
54
65
|
assert (docs_dir / "README.md").exists()
|
|
55
66
|
assert "Public link source: docs" in result.output
|
|
56
67
|
|
|
@@ -75,8 +86,7 @@ def test_workspace_chatblog_uses_docs_even_when_source_posts_exists(tmp_path, mo
|
|
|
75
86
|
|
|
76
87
|
assert result.exit_code == 0, result.output
|
|
77
88
|
public_link = workspace_dir / "public" / "chatblog"
|
|
78
|
-
|
|
79
|
-
assert public_link.resolve() == workspace_dir / "core" / "ChatBlog" / "docs"
|
|
89
|
+
_assert_link_points_to(public_link, workspace_dir / "core" / "ChatBlog" / "docs")
|
|
80
90
|
assert "Public link source: docs" in result.output
|
|
81
91
|
|
|
82
92
|
|
|
@@ -123,13 +133,20 @@ def test_workspace_all_extras_clone_chattool_chatblog_chatmemory(tmp_path, monke
|
|
|
123
133
|
assert Path("core/ChatTool") in cloned
|
|
124
134
|
assert Path("core/ChatBlog") in cloned
|
|
125
135
|
assert Path("core/ChatMemory") in cloned
|
|
126
|
-
|
|
136
|
+
_assert_link_points_to(
|
|
137
|
+
workspace_dir / "public" / "chatblog",
|
|
138
|
+
workspace_dir / "core" / "ChatBlog" / "docs",
|
|
139
|
+
)
|
|
127
140
|
assert not (workspace_dir / "skills" / "trae").exists()
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
for group in ["chatarch", "common", "agents"]:
|
|
142
|
+
_assert_link_points_to(
|
|
143
|
+
workspace_dir / "skills" / group,
|
|
144
|
+
workspace_dir / "core" / "ChatMemory" / "Skills" / group,
|
|
145
|
+
)
|
|
146
|
+
_assert_link_points_to(
|
|
147
|
+
workspace_dir / "skills" / "README.md",
|
|
148
|
+
workspace_dir / "core" / "ChatMemory" / "Skills" / "README.md",
|
|
149
|
+
)
|
|
133
150
|
assert not (workspace_dir / "skills" / "package-development").exists()
|
|
134
151
|
assert not (workspace_dir / "skills" / "package-review").exists()
|
|
135
152
|
local_readme = (workspace_dir / "skills" / "local" / "README.md").read_text(encoding="utf-8")
|
|
@@ -138,3 +155,102 @@ def test_workspace_all_extras_clone_chattool_chatblog_chatmemory(tmp_path, monke
|
|
|
138
155
|
assert "available under `skills/chatarch/`" in local_readme
|
|
139
156
|
assert "chatarch/package-review are available under skills/chatarch/" in result.output
|
|
140
157
|
assert "Skills README:" in result.output
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_workspace_extra_repo_reports_missing_git(tmp_path, monkeypatch):
|
|
161
|
+
from chatup.setup.workspace import options
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(options, "_resolve_git_command", lambda: None)
|
|
164
|
+
|
|
165
|
+
with pytest.raises(click.ClickException) as excinfo:
|
|
166
|
+
options._clone_or_update_repo(
|
|
167
|
+
"https://example.invalid/repo.git",
|
|
168
|
+
tmp_path / "workspace" / "core" / "Repo",
|
|
169
|
+
interactive=False,
|
|
170
|
+
can_prompt=False,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
assert "Git executable not found" in str(excinfo.value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_workspace_resolves_windows_git_standard_location(tmp_path, monkeypatch):
|
|
177
|
+
from chatup.setup.workspace import options
|
|
178
|
+
|
|
179
|
+
git = tmp_path / "Programs" / "Git" / "cmd" / "git.exe"
|
|
180
|
+
git.parent.mkdir(parents=True)
|
|
181
|
+
git.write_text("fake git", encoding="utf-8")
|
|
182
|
+
|
|
183
|
+
monkeypatch.setattr(options.shutil, "which", lambda name: None)
|
|
184
|
+
monkeypatch.setattr(options, "os", SimpleNamespace(name="nt", environ=os.environ))
|
|
185
|
+
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
|
186
|
+
monkeypatch.delenv("ProgramFiles", raising=False)
|
|
187
|
+
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
|
|
188
|
+
|
|
189
|
+
assert type(Path()) is type(tmp_path)
|
|
190
|
+
assert options._resolve_git_command() == str(git)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_workspace_directory_link_uses_directory_symlink_flag(tmp_path, monkeypatch):
|
|
194
|
+
from chatup.setup.workspace import options
|
|
195
|
+
|
|
196
|
+
source = tmp_path / "source"
|
|
197
|
+
target = tmp_path / "target"
|
|
198
|
+
source.mkdir()
|
|
199
|
+
captured = {}
|
|
200
|
+
|
|
201
|
+
def fake_symlink_to(self, source_path, target_is_directory=False):
|
|
202
|
+
captured["source"] = source_path
|
|
203
|
+
captured["target_is_directory"] = target_is_directory
|
|
204
|
+
|
|
205
|
+
monkeypatch.setattr(Path, "symlink_to", fake_symlink_to)
|
|
206
|
+
|
|
207
|
+
options._ensure_symlink(source, target)
|
|
208
|
+
|
|
209
|
+
assert captured == {"source": source, "target_is_directory": True}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_workspace_directory_link_falls_back_to_windows_junction(tmp_path, monkeypatch):
|
|
213
|
+
from chatup.setup.workspace import options
|
|
214
|
+
|
|
215
|
+
source = tmp_path / "source"
|
|
216
|
+
target = tmp_path / "target"
|
|
217
|
+
source.mkdir()
|
|
218
|
+
created = []
|
|
219
|
+
|
|
220
|
+
def fail_symlink_to(self, source_path, target_is_directory=False):
|
|
221
|
+
raise OSError("symlink privilege missing")
|
|
222
|
+
|
|
223
|
+
def fake_junction(source_path, target_path):
|
|
224
|
+
created.append((source_path, target_path))
|
|
225
|
+
target_path.mkdir()
|
|
226
|
+
|
|
227
|
+
monkeypatch.setattr(Path, "symlink_to", fail_symlink_to)
|
|
228
|
+
monkeypatch.setattr(options, "os", SimpleNamespace(name="nt", environ=os.environ))
|
|
229
|
+
monkeypatch.setattr(options, "_create_windows_junction", fake_junction)
|
|
230
|
+
|
|
231
|
+
assert type(Path()) is type(tmp_path)
|
|
232
|
+
options._ensure_symlink(source, target)
|
|
233
|
+
|
|
234
|
+
assert created == [(source.resolve(), target)]
|
|
235
|
+
assert target.is_dir()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.mark.skipif(os.name != "nt", reason="native Windows junctions")
|
|
239
|
+
@pytest.mark.parametrize("name", ["space dir", "a&b", "literal%PATH%!name"])
|
|
240
|
+
def test_windows_junction_preserves_literal_paths_and_target_data(tmp_path, name):
|
|
241
|
+
from chatup.setup.workspace import options
|
|
242
|
+
|
|
243
|
+
source = tmp_path / name
|
|
244
|
+
source.mkdir()
|
|
245
|
+
sentinel = source / "keep.txt"
|
|
246
|
+
sentinel.write_text("keep", encoding="utf-8")
|
|
247
|
+
target = tmp_path / (name + "-link")
|
|
248
|
+
options._create_windows_junction(source, target)
|
|
249
|
+
try:
|
|
250
|
+
assert options._is_windows_reparse_point(target)
|
|
251
|
+
assert target.samefile(source)
|
|
252
|
+
options._ensure_symlink(source, target)
|
|
253
|
+
assert (target / "keep.txt").read_text(encoding="utf-8") == "keep"
|
|
254
|
+
finally:
|
|
255
|
+
options._remove_link_path(target)
|
|
256
|
+
assert sentinel.read_text(encoding="utf-8") == "keep"
|
|
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
|
|
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
|
|
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
|
|
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
|
{chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/discussion/README.md
RENAMED
|
File without changes
|
{chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/projects/README.md
RENAMED
|
File without changes
|
|
File without changes
|
{chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md
RENAMED
|
File without changes
|
{chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/projects/README.md
RENAMED
|
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
|
|
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
|