mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge master into feat/custom-name-i18n; keep windowTitle stable on solo renders
Resolves the CLAUDE.md paragraph conflict with #162, skips the windowTitle recompute for solo-session renders so a detached window cannot reset the push-notification hostTitle prefix to the default, and prettier-formats test/mobile/devices.ts (came in unformatted via the #162 merge; CI format:check only covers src/**). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
# Written by install.sh into end-user clones when setup finishes
|
||||
.install-complete
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Installer (install.sh) reliability and safety overhaul, prompted by a review of the Linux flow:
|
||||
- Install-completion marker (`.install-complete`): a bare re-run only takes the quiet update path when a previous install actually finished. Previously, a first install that failed during npm install/build (or was interrupted) left `.git` behind, so the retry silently became an "update" and the user never got the launch menu, the `codeman`/`tmux-chooser` symlinks, the PATH entry, or the `sc` alias. The marker is refreshed by updates and cleared by uninstall when the app dir is kept; added to .gitignore for end-user clones.
|
||||
- `update` no longer runs an unconditional `git reset --hard` over local changes: interactive runs are asked to stash (declining keeps everything and skips the update), headless runs auto-stash with a dated message (same policy as scripts/self-update.sh).
|
||||
- Service setup is verified instead of asserted: after starting codeman-web, the installer polls `systemctl --user is-active` (up to 6s) and only then prints "Codeman is running now!"; failures print an honest warning plus status/journalctl hints. Uses `restart` instead of `start` so re-running the installer over an already-running service actually loads the new build. A missing user D-Bus session (e.g. bare `ssh host 'curl | bash'`) is detected up front with copy-paste recovery commands instead of dying mid-setup via `set -e`. macOS gets the equivalent `launchctl list` verification, and the update path verifies its service restart too. The Cloudflare tunnel-service offer is skipped when service setup failed.
|
||||
- Headless consent guard: with no interactive terminal AND no explicit `CODEMAN_NONINTERACTIVE=1`, the installer now refuses (with instructions) to run sudo package installs (git/node/tmux) or third-party `curl | bash` AI CLI installers, instead of silently taking the default-yes prompts. Explicit `CODEMAN_NONINTERACTIVE=1` keeps the previous full-auto behavior for CI/automation.
|
||||
- AI CLI gate now recognizes Codex and Gemini (search paths mirrored from the CLI resolvers), so a box with only Codex or Gemini installed is no longer forced to install Claude Code/OpenCode. The install menu gains a "Skip" option (with npm install hints for Codex/Gemini), and the final reminder lists all four CLIs.
|
||||
|
||||
Docs: CLAUDE.md documents `src/remote-reconnect.ts` (pure COD-108 auto-reconnect backoff/eligibility logic) in the Infra table and the remote-sessions pattern.
|
||||
|
||||
## 1.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -32,9 +32,13 @@
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
```
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). After install:
|
||||
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
|
||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
|
||||
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). The installer detects whichever of the four is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -53,6 +57,8 @@ Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
|
||||
<details>
|
||||
<summary><strong>Run as a background service</strong></summary>
|
||||
|
||||
The installer's final menu sets this up for you (option 2) and verifies the service actually comes up before claiming success. To configure it manually instead:
|
||||
|
||||
**Linux (systemd):**
|
||||
|
||||
```bash
|
||||
|
||||
+8
-2
@@ -34,9 +34,13 @@
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装完成后:
|
||||
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。
|
||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
|
||||
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装器会自动检测这四个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -55,6 +59,8 @@ codeman web --multiuser # 命名登录 + 按用户隔离的案例
|
||||
<details>
|
||||
<summary><strong>作为后台服务运行</strong></summary>
|
||||
|
||||
安装器结尾的菜单(选项 2)可以帮你完成这一步,并在宣告成功前校验服务确实已启动。如需手动配置:
|
||||
|
||||
**Linux(systemd):**
|
||||
|
||||
```bash
|
||||
|
||||
+218
-22
@@ -5,7 +5,11 @@
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
#
|
||||
# Environment variables:
|
||||
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
||||
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts and accept their defaults
|
||||
# (CI/automation). Required for headless runs
|
||||
# that need system changes (sudo package
|
||||
# installs, AI CLI download); without it those
|
||||
# steps abort instead of running silently.
|
||||
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
|
||||
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
||||
@@ -53,6 +57,26 @@ OPENCODE_SEARCH_PATHS=(
|
||||
"$HOME/bin/opencode"
|
||||
)
|
||||
|
||||
# Codex CLI search paths (from src/utils/codex-cli-resolver.ts)
|
||||
CODEX_SEARCH_PATHS=(
|
||||
"$HOME/.codex/bin/codex"
|
||||
"$HOME/.local/bin/codex"
|
||||
"/usr/local/bin/codex"
|
||||
"$HOME/.bun/bin/codex"
|
||||
"$HOME/.npm-global/bin/codex"
|
||||
"$HOME/bin/codex"
|
||||
)
|
||||
|
||||
# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts)
|
||||
GEMINI_SEARCH_PATHS=(
|
||||
"$HOME/.gemini/bin/gemini"
|
||||
"$HOME/.local/bin/gemini"
|
||||
"/usr/local/bin/gemini"
|
||||
"$HOME/.bun/bin/gemini"
|
||||
"$HOME/.npm-global/bin/gemini"
|
||||
"$HOME/bin/gemini"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Color Output
|
||||
# ============================================================================
|
||||
@@ -336,6 +360,62 @@ get_opencode_path() {
|
||||
done
|
||||
}
|
||||
|
||||
check_codex() {
|
||||
if command -v codex &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for path in "${CODEX_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
get_codex_path() {
|
||||
if command -v codex &>/dev/null; then
|
||||
command -v codex
|
||||
return
|
||||
fi
|
||||
|
||||
for path in "${CODEX_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
echo "$path"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_gemini() {
|
||||
if command -v gemini &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
get_gemini_path() {
|
||||
if command -v gemini &>/dev/null; then
|
||||
command -v gemini
|
||||
return
|
||||
fi
|
||||
|
||||
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
|
||||
if [[ -x "$path" ]]; then
|
||||
echo "$path"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_cloudflared() {
|
||||
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
|
||||
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
|
||||
@@ -701,6 +781,21 @@ read_reply() {
|
||||
fi
|
||||
}
|
||||
|
||||
# headless_guard <action>: refuse consequential system changes (sudo package
|
||||
# installs, third-party curl | bash installers) when nobody can consent, i.e.
|
||||
# no terminal AND no explicit CODEMAN_NONINTERACTIVE=1 opt-in. Interactive
|
||||
# runs fall through to their normal prompt; opted-in automation proceeds with
|
||||
# the prompt defaults as before.
|
||||
headless_guard() {
|
||||
local action="$1"
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || has_tty; then
|
||||
return 0
|
||||
fi
|
||||
error "No interactive terminal, but the installer would need to: $action."
|
||||
error "Re-run from a terminal to be prompted, or set CODEMAN_NONINTERACTIVE=1 to approve such steps in automation."
|
||||
exit 1
|
||||
}
|
||||
|
||||
prompt_yes_no() {
|
||||
local prompt="$1"
|
||||
local default="${2:-y}"
|
||||
@@ -835,6 +930,20 @@ setup_sc_alias() {
|
||||
# Service Setup (Linux systemd / macOS launchd)
|
||||
# ============================================================================
|
||||
|
||||
# Wait briefly for codeman-web.service to report active. A bad node path or a
|
||||
# busy port makes the unit crash within the first seconds (then sit in
|
||||
# activating/auto-restart), so a blind "started!" message would be a lie.
|
||||
verify_systemd_active() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
sleep 2
|
||||
if systemctl --user is-active --quiet codeman-web.service 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_launchd_service() {
|
||||
local plist_label="com.codeman.web"
|
||||
local agent_dir="$HOME/Library/LaunchAgents"
|
||||
@@ -907,7 +1016,15 @@ EOF
|
||||
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
|
||||
# launchctl load is silent about many failures: confirm the agent is loaded
|
||||
sleep 2
|
||||
if launchctl list "$plist_label" &>/dev/null; then
|
||||
success "LaunchAgent installed and started"
|
||||
return 0
|
||||
fi
|
||||
warn "LaunchAgent did not load."
|
||||
warn "Inspect: launchctl list | grep codeman ; tail -20 /tmp/codeman.log"
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_systemd_service() {
|
||||
@@ -941,8 +1058,15 @@ Environment=PATH=$PATH
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
# Reload systemd
|
||||
systemctl --user daemon-reload
|
||||
# Reload systemd. A user D-Bus session is required for systemctl --user
|
||||
# (missing under bare `ssh host 'curl | bash'` provisioning), so detect
|
||||
# that up front instead of dying mid-setup with a cryptic trap message.
|
||||
if ! systemctl --user daemon-reload 2>/dev/null; then
|
||||
warn "systemctl --user is unavailable (no user D-Bus session?); cannot manage user services here."
|
||||
warn "Unit written to $service_file. From a normal login shell, enable it with:"
|
||||
warn " systemctl --user daemon-reload && systemctl --user enable --now codeman-web"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Enable service
|
||||
systemctl --user enable codeman-web.service 2>/dev/null || true
|
||||
@@ -952,10 +1076,17 @@ EOF
|
||||
loginctl enable-linger "$USER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Start the service immediately
|
||||
systemctl --user start codeman-web.service 2>/dev/null || true
|
||||
# (Re)start the service. restart, not start: on a re-run over an existing
|
||||
# running service, start would be a no-op and leave the OLD build running.
|
||||
systemctl --user restart codeman-web.service 2>/dev/null || true
|
||||
|
||||
if verify_systemd_active; then
|
||||
success "Systemd service installed and started"
|
||||
return 0
|
||||
fi
|
||||
warn "codeman-web.service did not become active."
|
||||
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_tunnel_service() {
|
||||
@@ -1039,6 +1170,7 @@ main() {
|
||||
# Git
|
||||
info "Checking Git..."
|
||||
if ! check_git; then
|
||||
headless_guard "install Git (system package via sudo)"
|
||||
if prompt_yes_no "Git is not installed. Install it now?"; then
|
||||
install_dependency "git" "$os" "$distro"
|
||||
else
|
||||
@@ -1057,6 +1189,7 @@ main() {
|
||||
warn "Node.js $node_version is installed but version $MIN_NODE_VERSION+ is required."
|
||||
fi
|
||||
|
||||
headless_guard "install Node.js v$TARGET_NODE_VERSION (system package via sudo)"
|
||||
if prompt_yes_no "Install Node.js v$TARGET_NODE_VERSION?"; then
|
||||
install_dependency "node" "$os" "$distro"
|
||||
|
||||
@@ -1081,6 +1214,7 @@ main() {
|
||||
if check_tmux; then
|
||||
success "tmux is installed"
|
||||
else
|
||||
headless_guard "install tmux (system package via sudo)"
|
||||
if prompt_yes_no "tmux is not installed. Install it now?"; then
|
||||
install_dependency "tmux" "$os" "$distro"
|
||||
else
|
||||
@@ -1088,9 +1222,11 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# AI CLI (at least one required: Claude Code or OpenCode)
|
||||
# AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini)
|
||||
local has_claude=false
|
||||
local has_opencode=false
|
||||
local has_codex=false
|
||||
local has_gemini=false
|
||||
|
||||
info "Checking AI CLI tools..."
|
||||
if check_claude; then
|
||||
@@ -1101,29 +1237,39 @@ main() {
|
||||
has_opencode=true
|
||||
success "OpenCode found at $(get_opencode_path)"
|
||||
fi
|
||||
if check_codex; then
|
||||
has_codex=true
|
||||
success "Codex found at $(get_codex_path)"
|
||||
fi
|
||||
if check_gemini; then
|
||||
has_gemini=true
|
||||
success "Gemini CLI found at $(get_gemini_path)"
|
||||
fi
|
||||
|
||||
if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
|
||||
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" ]]; then
|
||||
echo ""
|
||||
warn "No AI CLI found. Codeman requires at least one: Claude Code or OpenCode."
|
||||
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, or Gemini."
|
||||
headless_guard "install an AI CLI (curl | bash from its vendor)"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
|
||||
echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)"
|
||||
echo -e " ${CYAN}2)${NC} OpenCode (open-source)"
|
||||
echo -e " ${CYAN}3)${NC} Both"
|
||||
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex or Gemini)"
|
||||
echo ""
|
||||
|
||||
local cli_choice=""
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
|
||||
# Non-interactive: default to Claude Code
|
||||
# Explicit automation opt-in: default to Claude Code
|
||||
cli_choice="1"
|
||||
info "No interactive terminal detected: defaulting to Claude Code"
|
||||
info "CODEMAN_NONINTERACTIVE=1: defaulting to Claude Code"
|
||||
else
|
||||
while true; do
|
||||
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
|
||||
echo -en "${CYAN}Choose [1/2/3/4]:${NC} " >&2
|
||||
read_reply cli_choice || { cli_choice="1"; break; }
|
||||
case "$cli_choice" in
|
||||
1|2|3) break ;;
|
||||
*) echo "Please enter 1, 2, or 3." >&2 ;;
|
||||
1|2|3|4) break ;;
|
||||
*) echo "Please enter 1, 2, 3, or 4." >&2 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
@@ -1152,8 +1298,12 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
|
||||
die "At least one AI CLI is required. Install manually and re-run the installer."
|
||||
if [[ "$cli_choice" == "4" ]]; then
|
||||
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
|
||||
info "Install one later, e.g.: npm install -g @openai/codex (Codex)"
|
||||
info " or: npm install -g @google/gemini-cli (Gemini)"
|
||||
elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
|
||||
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1249,6 +1399,16 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# ========================================================================
|
||||
# Mark install complete
|
||||
# ========================================================================
|
||||
|
||||
# The dispatcher at the bottom only routes a bare re-run to the quiet
|
||||
# update path when this marker exists, so an aborted first install
|
||||
# (failed npm install/build, Ctrl+C) re-runs the full setup flow
|
||||
# (symlinks, PATH, launch menu) instead of silently "updating".
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
|
||||
|
||||
# ========================================================================
|
||||
# Launch Options
|
||||
# ========================================================================
|
||||
@@ -1325,14 +1485,16 @@ main() {
|
||||
|
||||
# Handle service setup
|
||||
if [[ "$launch_choice" == "2" ]]; then
|
||||
local service_ok=true
|
||||
if [[ "$service_type" == "launchd" ]]; then
|
||||
setup_launchd_service
|
||||
setup_launchd_service || service_ok=false
|
||||
else
|
||||
setup_systemd_service
|
||||
setup_systemd_service || service_ok=false
|
||||
fi
|
||||
|
||||
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
|
||||
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
# Offer tunnel service if cloudflared is available (Linux only: systemd tunnel service).
|
||||
# Skipped when service setup failed: it needs the same systemctl --user access.
|
||||
if [[ "$service_ok" == "true" ]] && [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
echo ""
|
||||
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
||||
setup_tunnel_service
|
||||
@@ -1340,10 +1502,15 @@ main() {
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [[ "$service_ok" == "true" ]]; then
|
||||
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}# Open in browser${NC}"
|
||||
echo -e " http://localhost:3000"
|
||||
else
|
||||
echo -e " ${YELLOW}${BOLD}The service was set up but is not running yet${NC} (see warnings above)."
|
||||
echo -e " ${DIM}You can always run it directly:${NC} ${CYAN}codeman web${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " ${BOLD}Manage the service:${NC}"
|
||||
echo ""
|
||||
@@ -1392,10 +1559,12 @@ main() {
|
||||
echo -e " https://github.com/Ark0N/Codeman"
|
||||
echo ""
|
||||
|
||||
if ! check_claude && ! check_opencode; then
|
||||
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini; then
|
||||
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
|
||||
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
|
||||
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
|
||||
echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex"
|
||||
echo -e " ${CYAN}npm install -g @google/gemini-cli${NC} # Gemini"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
@@ -1427,10 +1596,26 @@ update() {
|
||||
info "Updating Codeman..."
|
||||
cd "$INSTALL_DIR"
|
||||
git remote set-url origin "$REPO_URL" 2>/dev/null || true
|
||||
|
||||
# Never blow away local changes silently (this used to be an unconditional
|
||||
# reset --hard). Interactive users get a choice; headless runs auto-stash
|
||||
# so the changes stay recoverable, the same policy as scripts/self-update.sh.
|
||||
if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then
|
||||
warn "Local changes detected in $INSTALL_DIR"
|
||||
if prompt_yes_no "Stash local changes and update? (recover with: git stash pop)"; then
|
||||
git stash push --quiet -m "codeman-installer auto-stash $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
info "Local changes stashed (see 'git stash list' in $INSTALL_DIR)"
|
||||
else
|
||||
info "Keeping local changes; update skipped."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
git fetch --quiet origin
|
||||
git reset --hard "origin/$BRANCH" --quiet
|
||||
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
|
||||
npm run build --quiet 2>/dev/null || npm run build
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
|
||||
@@ -1438,8 +1623,13 @@ update() {
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service
|
||||
systemctl --user restart codeman-web.service 2>/dev/null || true
|
||||
if verify_systemd_active; then
|
||||
success "codeman-web service restarted"
|
||||
else
|
||||
warn "codeman-web.service did not come back up."
|
||||
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
|
||||
fi
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
@@ -1508,6 +1698,9 @@ uninstall() {
|
||||
rm -rf "$INSTALL_DIR"
|
||||
success "Removed $INSTALL_DIR"
|
||||
else
|
||||
# Clear the marker so a future installer run does full setup again
|
||||
# (the symlinks and services being removed here need recreating).
|
||||
rm -f "$INSTALL_DIR/.install-complete"
|
||||
info "Kept $INSTALL_DIR"
|
||||
fi
|
||||
fi
|
||||
@@ -1537,7 +1730,10 @@ case "${1:-}" in
|
||||
update) update ;;
|
||||
uninstall) uninstall ;;
|
||||
*)
|
||||
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
|
||||
# Only a COMPLETED install re-runs as a quiet update. A partial one
|
||||
# (clone succeeded but build/menu never finished) lacks the marker and
|
||||
# re-runs the full flow, so a failed first attempt can actually finish.
|
||||
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then
|
||||
print_banner
|
||||
update
|
||||
else
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.2",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -60,7 +60,7 @@ export function stripAnsi(text: string): string {
|
||||
*/
|
||||
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
||||
|
||||
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
|
||||
export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u;
|
||||
|
||||
/**
|
||||
* Execute a global regex pattern against data, calling the callback for each match.
|
||||
|
||||
@@ -43,6 +43,35 @@ const MobileDetection = {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether this browser belongs to a handheld device.
|
||||
*
|
||||
* Unlike getDeviceType(), this classification must remain stable when a
|
||||
* foldable changes posture. An unfolded phone can expose a desktop-width
|
||||
* viewport, but it still needs the same per-device settings that were saved
|
||||
* while folded. User-Agent Client Hints are preferred where available; the
|
||||
* legacy token fallback covers Android WebView and iPhone browsers.
|
||||
*/
|
||||
isHandheldDevice() {
|
||||
if (!this.isTouchDevice()) return false;
|
||||
|
||||
const userAgent = navigator.userAgent || '';
|
||||
|
||||
// Prefer explicit UA form-factor signals. Besides matching real browsers,
|
||||
// this avoids Chromium emulation reporting userAgentData.mobile=true for
|
||||
// an iPad/tablet context created with isMobile=true.
|
||||
if (/iPad|Tablet|Silk|PlayBook|Kindle|Windows NT|CrOS|Macintosh/i.test(userAgent)) {
|
||||
return false;
|
||||
}
|
||||
if (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)) return false;
|
||||
if (/Mobi|iPhone|iPod/i.test(userAgent)) return true;
|
||||
|
||||
const uaDataMobile = navigator.userAgentData?.mobile;
|
||||
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/** Check if device is iOS (iPhone, iPad, iPod) */
|
||||
isIOS() {
|
||||
return (
|
||||
|
||||
@@ -350,19 +350,60 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.run();
|
||||
},
|
||||
|
||||
/** Ensure a newly-created session is visible without waiting for the SSE event.
|
||||
* The POST response and session:created can arrive in either order, so the
|
||||
* normal idempotent SSE handler remains the single state-upsert path. */
|
||||
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
|
||||
if (!sessionId) return;
|
||||
|
||||
let session = sessionSnapshot;
|
||||
if (!session && !this.sessions?.has(sessionId)) {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to load the new session');
|
||||
session = data.data?.session || data.data;
|
||||
}
|
||||
|
||||
if (session?.id) this._onSessionCreated(session);
|
||||
// session:created normally uses the debounced renderer. The direct POST path
|
||||
// needs the tab in the DOM before selectSession() marks it active.
|
||||
this._renderSessionTabsImmediate?.();
|
||||
},
|
||||
|
||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
|
||||
async run() {
|
||||
if (this._runInFlight) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const minLockMs = Number.isFinite(this._runMinLockMs) ? this._runMinLockMs : 500;
|
||||
const runBtn = document.getElementById('runBtn');
|
||||
this._runInFlight = true;
|
||||
if (runBtn) {
|
||||
runBtn.disabled = true;
|
||||
runBtn.setAttribute('aria-busy', 'true');
|
||||
}
|
||||
|
||||
try {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'opencode') {
|
||||
return this.runOpenCode();
|
||||
return await this.runOpenCode();
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
return this.runCodex();
|
||||
return await this.runCodex();
|
||||
}
|
||||
if (mode === 'gemini') {
|
||||
return this.runGemini();
|
||||
return await this.runGemini();
|
||||
}
|
||||
return await this.runClaude();
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
this._runInFlight = false;
|
||||
if (runBtn) {
|
||||
runBtn.disabled = false;
|
||||
runBtn.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
return this.runClaude();
|
||||
},
|
||||
|
||||
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
|
||||
@@ -596,6 +637,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||
@@ -659,6 +701,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
firstSessionId = sessionIds[0];
|
||||
@@ -774,6 +817,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
if (remoteIds[0]) {
|
||||
@@ -807,6 +851,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
|
||||
@@ -884,6 +929,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
@@ -940,6 +986,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
@@ -992,6 +1039,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
|
||||
@@ -1768,17 +1768,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
return settings.ralphTrackerEnabled ?? false;
|
||||
},
|
||||
|
||||
// Get the settings storage key based on device type (mobile vs desktop)
|
||||
// Keep the settings namespace stable across foldable posture changes. Layout
|
||||
// still follows viewport width, but an unfolded phone remains the same
|
||||
// handheld device and must not silently switch to desktop preferences.
|
||||
getSettingsStorageKey() {
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
|
||||
const isHandheld =
|
||||
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
|
||||
return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
|
||||
},
|
||||
|
||||
// Get default settings based on device type
|
||||
// Note: Notification prefs are handled separately by NotificationManager
|
||||
getDefaultSettings() {
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
if (isMobile) {
|
||||
const isHandheld =
|
||||
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
|
||||
if (isHandheld) {
|
||||
// Mobile defaults: minimal UI for small screens
|
||||
return {
|
||||
// Header visibility - hide everything on mobile
|
||||
@@ -2203,7 +2207,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
|
||||
try {
|
||||
if (
|
||||
MobileDetection.getDeviceType() === 'mobile' &&
|
||||
(MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') &&
|
||||
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
|
||||
) {
|
||||
const s = this.loadAppSettingsFromStorage();
|
||||
|
||||
@@ -1226,7 +1226,11 @@ export class WebServer extends EventEmitter {
|
||||
const configuredDisplayName =
|
||||
typeof persistedSettings.displayName === 'string' ? persistedSettings.displayName.trim() : '';
|
||||
const displayName = configuredDisplayName || 'Codeman';
|
||||
// Solo renders read no settings; recomputing here would reset the shared
|
||||
// push-notification prefix (hostTitle) to the default name.
|
||||
if (!soloSessionId) {
|
||||
this.windowTitle = `${displayName === 'Codeman' ? 'codeman' : displayName}:${this.titleHostname}`;
|
||||
}
|
||||
let html = this.indexHtmlTemplate.replace(
|
||||
'<title>Codeman</title>',
|
||||
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit).
|
||||
|
||||
**325 tests across 135 devices — all passing.**
|
||||
**326 tests across 136 devices — all passing.**
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates Codeman's mobile UI across 135 devices, covering:
|
||||
Validates Codeman's mobile UI across 136 devices, covering:
|
||||
|
||||
- **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
|
||||
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit)
|
||||
@@ -26,7 +26,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.t
|
||||
# Quick mode — 6 representative devices, skip full matrix
|
||||
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
|
||||
|
||||
# Full device matrix only (135 devices)
|
||||
# Full device matrix only (136 devices)
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
|
||||
|
||||
# Update visual baselines (delete old baselines, re-run)
|
||||
@@ -43,7 +43,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
|
||||
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
|
||||
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
|
||||
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
|
||||
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (135 devices) |
|
||||
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) |
|
||||
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
|
||||
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
|
||||
|
||||
@@ -58,7 +58,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
|
||||
| standard-tablet | 768–834px | ~8 | iPad Mini |
|
||||
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
|
||||
|
||||
135 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 67 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, iPad Air M2, Surface Pro, etc.).
|
||||
136 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.).
|
||||
|
||||
### How Devices Are Differentiated
|
||||
|
||||
@@ -98,7 +98,7 @@ Test File
|
||||
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
|
||||
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
|
||||
├─ helpers/visual.ts → pixelmatch screenshot comparison
|
||||
└─ devices.ts → 135-device registry
|
||||
└─ devices.ts → 136-device registry
|
||||
```
|
||||
|
||||
### Keyboard Simulation — 3-Layer Approach
|
||||
|
||||
+45
-29
@@ -1,6 +1,12 @@
|
||||
import { devices as playwrightDevices } from 'playwright';
|
||||
|
||||
export type DeviceCategory = 'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet';
|
||||
export type DeviceCategory =
|
||||
| 'small-phone'
|
||||
| 'standard-phone'
|
||||
| 'large-phone'
|
||||
| 'small-tablet'
|
||||
| 'standard-tablet'
|
||||
| 'large-tablet';
|
||||
|
||||
export interface DeviceEntry {
|
||||
name: string;
|
||||
@@ -55,14 +61,7 @@ function fromPlaywright(name: string): DeviceEntry | null {
|
||||
}
|
||||
|
||||
/** Create a custom DeviceEntry for devices not in Playwright. */
|
||||
function custom(
|
||||
name: string,
|
||||
width: number,
|
||||
height: number,
|
||||
dpr: number,
|
||||
ua: string,
|
||||
isIOS: boolean,
|
||||
): DeviceEntry {
|
||||
function custom(name: string, width: number, height: number, dpr: number, ua: string, isIOS: boolean): DeviceEntry {
|
||||
return {
|
||||
name,
|
||||
category: categoryFor(width),
|
||||
@@ -163,9 +162,9 @@ const PLAYWRIGHT_DEVICE_NAMES = [
|
||||
'iPad Pro 11', // 834x1194
|
||||
];
|
||||
|
||||
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES
|
||||
.map(n => fromPlaywright(n))
|
||||
.filter((d): d is DeviceEntry => d !== null);
|
||||
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES.map((n) => fromPlaywright(n)).filter(
|
||||
(d): d is DeviceEntry => d !== null
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom devices — newer models and those missing from Playwright
|
||||
@@ -240,7 +239,14 @@ const customEntries: DeviceEntry[] = [
|
||||
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
|
||||
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
|
||||
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
|
||||
custom('Kindle Fire HD 8', 600, 1024, 1.5, 'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36', false),
|
||||
custom(
|
||||
'Kindle Fire HD 8',
|
||||
600,
|
||||
1024,
|
||||
1.5,
|
||||
'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36',
|
||||
false
|
||||
),
|
||||
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
|
||||
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
|
||||
|
||||
@@ -256,13 +262,23 @@ const customEntries: DeviceEntry[] = [
|
||||
custom('iPad Pro 12.9 (6th gen)', 1024, 1366, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
|
||||
custom('Surface Pro 7', 912, 1368, 2,
|
||||
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0', false),
|
||||
custom(
|
||||
'Surface Pro 7',
|
||||
912,
|
||||
1368,
|
||||
2,
|
||||
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0',
|
||||
false
|
||||
),
|
||||
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
|
||||
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
|
||||
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
|
||||
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
|
||||
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
|
||||
// Find N5 inner display is 2248x2480 physical pixels. At DPR 2 its full-
|
||||
// resolution CSS viewport crosses Codeman's desktop breakpoint while the
|
||||
// browser remains a mobile/touch device.
|
||||
custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -276,29 +292,29 @@ export const DEVICE_REGISTRY: DeviceEntry[] = [...playwrightEntries, ...customEn
|
||||
// Per-category exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-phone');
|
||||
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-phone');
|
||||
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-phone');
|
||||
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-tablet');
|
||||
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-tablet');
|
||||
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-tablet');
|
||||
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-phone');
|
||||
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-phone');
|
||||
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-phone');
|
||||
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-tablet');
|
||||
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-tablet');
|
||||
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-tablet');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.isIOS);
|
||||
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => !d.isIOS);
|
||||
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.isIOS);
|
||||
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => !d.isIOS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Representative devices — one per category for quick smoke tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const REPRESENTATIVE_DEVICES: Record<DeviceCategory, DeviceEntry> = {
|
||||
'small-phone': SMALL_PHONES.find(d => d.name === 'iPhone SE')!,
|
||||
'standard-phone': STANDARD_PHONES.find(d => d.name === 'iPhone 14 Pro')!,
|
||||
'large-phone': LARGE_PHONES.find(d => d.name === 'iPhone 15 Pro Max')!,
|
||||
'small-tablet': SMALL_TABLETS.find(d => d.name === 'Nexus 7')!,
|
||||
'standard-tablet': STANDARD_TABLETS.find(d => d.name === 'iPad Mini')!,
|
||||
'large-tablet': LARGE_TABLETS.find(d => d.name === 'iPad Pro 11')!,
|
||||
'small-phone': SMALL_PHONES.find((d) => d.name === 'iPhone SE')!,
|
||||
'standard-phone': STANDARD_PHONES.find((d) => d.name === 'iPhone 14 Pro')!,
|
||||
'large-phone': LARGE_PHONES.find((d) => d.name === 'iPhone 15 Pro Max')!,
|
||||
'small-tablet': SMALL_TABLETS.find((d) => d.name === 'Nexus 7')!,
|
||||
'standard-tablet': STANDARD_TABLETS.find((d) => d.name === 'iPad Mini')!,
|
||||
'large-tablet': LARGE_TABLETS.find((d) => d.name === 'iPad Pro 11')!,
|
||||
};
|
||||
|
||||
@@ -5,10 +5,8 @@ import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './
|
||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
||||
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
|
||||
import {
|
||||
assertVisible, assertHidden, getCSSProperty, getCSSNumericValue,
|
||||
} from './helpers/assertions.js';
|
||||
import { REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
|
||||
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
import type { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = PORTS.SETTINGS;
|
||||
@@ -94,9 +92,7 @@ describe('Settings Modal', () => {
|
||||
if (gearBox && toolbarBox) {
|
||||
// Gear button should be within toolbar's vertical range
|
||||
expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5);
|
||||
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(
|
||||
toolbarBox.y + toolbarBox.height + 5,
|
||||
);
|
||||
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(toolbarBox.y + toolbarBox.height + 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -304,11 +300,14 @@ describe('Settings Modal', () => {
|
||||
try {
|
||||
// Store a test setting
|
||||
await page.evaluate((key) => {
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
showFontControls: true,
|
||||
showMonitor: true,
|
||||
subagentTrackingEnabled: true,
|
||||
}));
|
||||
})
|
||||
);
|
||||
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
|
||||
// Reload page
|
||||
@@ -335,20 +334,32 @@ describe('Settings Modal', () => {
|
||||
|
||||
try {
|
||||
// Store both mobile and desktop settings
|
||||
await page.evaluate(({ mobileKey, desktopKey, notifKey }) => {
|
||||
await page.evaluate(
|
||||
({ mobileKey, desktopKey, notifKey }) => {
|
||||
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
|
||||
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
|
||||
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
|
||||
}, {
|
||||
},
|
||||
{
|
||||
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
|
||||
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
|
||||
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Verify they are independent
|
||||
const mobile = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
const desktop = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_DESKTOP);
|
||||
const notif = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE);
|
||||
const mobile = await page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
||||
STORAGE_KEYS.SETTINGS_MOBILE
|
||||
);
|
||||
const desktop = await page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
||||
STORAGE_KEYS.SETTINGS_DESKTOP
|
||||
);
|
||||
const notif = await page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
||||
STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE
|
||||
);
|
||||
|
||||
expect(mobile.showFontControls).toBe(false);
|
||||
expect(desktop.showFontControls).toBe(true);
|
||||
@@ -390,5 +401,53 @@ describe('Settings Modal', () => {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => {
|
||||
const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
|
||||
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
|
||||
try {
|
||||
// Seed the preferences while folded, exactly as a phone user does.
|
||||
await page.setViewportSize({ width: 412, height: 915 });
|
||||
await page.evaluate((key) => {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
showResponseViewer: true,
|
||||
extendedKeyboardBar: true,
|
||||
})
|
||||
);
|
||||
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
|
||||
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
||||
|
||||
// Unfolding can reload Android WebView. The viewport now uses desktop
|
||||
// layout, but the physical device and its preferences have not changed.
|
||||
await page.setViewportSize(device.viewport);
|
||||
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
|
||||
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
deviceType: (window as any).MobileDetection.getDeviceType(),
|
||||
handheld: (window as any).MobileDetection.isHandheldDevice(),
|
||||
storageKey: (window as any).app.getSettingsStorageKey(),
|
||||
responseViewerVisible: !document
|
||||
.querySelector('.btn-response-viewer-header')
|
||||
?.classList.contains('btn-response-viewer-header--hidden'),
|
||||
keyboardExtended: Boolean(document.querySelector('.keyboard-accessory-bar [data-action="arrow-left"]')),
|
||||
}));
|
||||
|
||||
expect(state.deviceType).toBe('desktop');
|
||||
expect(state.handheld).toBe(true);
|
||||
expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
expect(state.responseViewerVisible).toBe(true);
|
||||
expect(state.keyboardExtended).toBe(true);
|
||||
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await assertVisible(page, '.keyboard-accessory-bar');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,101 @@ describe('run mode UI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Run launch synchronization', () => {
|
||||
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
|
||||
const runBtn = {
|
||||
disabled: false,
|
||||
setAttribute: vi.fn(),
|
||||
removeAttribute: vi.fn(),
|
||||
};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => (id === 'runBtn' ? runBtn : null) },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app._runMinLockMs = 0;
|
||||
let finishRun!: () => void;
|
||||
app.runClaude = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolveRun) => {
|
||||
finishRun = resolveRun;
|
||||
})
|
||||
);
|
||||
|
||||
const first = app.run();
|
||||
const duplicate = app.run();
|
||||
|
||||
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||
expect(runBtn.disabled).toBe(true);
|
||||
expect(runBtn.setAttribute).toHaveBeenCalledWith('aria-busy', 'true');
|
||||
|
||||
finishRun();
|
||||
await Promise.all([first, duplicate]);
|
||||
|
||||
expect(runBtn.disabled).toBe(false);
|
||||
expect(runBtn.removeAttribute).toHaveBeenCalledWith('aria-busy');
|
||||
});
|
||||
|
||||
it('renders a POST response session immediately without waiting for SSE', async () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
fetch: vi.fn(),
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.sessions = new Map();
|
||||
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
|
||||
app._renderSessionTabsImmediate = vi.fn();
|
||||
const snapshot = { id: 'sess-new', name: 'w1-case', workingDir: '/tmp/case' };
|
||||
|
||||
await app._ensureCreatedSessionVisible(snapshot.id, snapshot);
|
||||
|
||||
expect(context.fetch).not.toHaveBeenCalled();
|
||||
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
|
||||
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loads the new session when a quick-start response wins the race with SSE', async () => {
|
||||
const snapshot = { id: 'sess-race', name: 'w1-remote', workingDir: '/remote/work' };
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
json: async () => ({ success: true, data: snapshot }),
|
||||
}));
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
fetch: fetchMock,
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.sessions = new Map();
|
||||
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
|
||||
app._renderSessionTabsImmediate = vi.fn();
|
||||
|
||||
await app._ensureCreatedSessionVisible(snapshot.id);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/sess-race');
|
||||
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
|
||||
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex quick start settings', () => {
|
||||
it('renders Codex CLI settings in a dedicated app settings tab', () => {
|
||||
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||
@@ -113,6 +208,8 @@ describe('Codex quick start settings', () => {
|
||||
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
||||
if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
||||
if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
|
||||
if (url === '/api/sessions/sess-1')
|
||||
return { json: async () => ({ success: true, data: { id: 'sess-1', name: 'w1-codex-case' } }) };
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
console,
|
||||
@@ -128,6 +225,9 @@ describe('Codex quick start settings', () => {
|
||||
});
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
app.sessions = new Map();
|
||||
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
||||
app._renderSessionTabsImmediate = vi.fn();
|
||||
const selected: string[] = [];
|
||||
app.selectSession = async (id: string) => {
|
||||
selected.push(id);
|
||||
@@ -424,6 +524,8 @@ describe('Gemini quick start', () => {
|
||||
if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
||||
if (url === '/api/quick-start')
|
||||
return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) };
|
||||
if (url === '/api/sessions/sess-gm')
|
||||
return { json: async () => ({ success: true, data: { id: 'sess-gm', name: 'w1-gemini-case' } }) };
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
console,
|
||||
@@ -437,6 +539,9 @@ describe('Gemini quick start', () => {
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
app.sessions = new Map();
|
||||
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
||||
app._renderSessionTabsImmediate = vi.fn();
|
||||
const selected: string[] = [];
|
||||
app.selectSession = async (id: string) => {
|
||||
selected.push(id);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateSessionSchema, QuickRunSchema, ScheduledRunSchema } from '../src/web/schemas.js';
|
||||
|
||||
describe('working directory schemas', () => {
|
||||
const unicodeWorkingDir = '/mnt/d/AI/中文项目';
|
||||
|
||||
it('accepts Unicode paths for session creation and run requests', () => {
|
||||
expect(CreateSessionSchema.safeParse({ workingDir: unicodeWorkingDir, mode: 'codex' }).success).toBe(true);
|
||||
expect(QuickRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test' }).success).toBe(true);
|
||||
expect(
|
||||
ScheduledRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test', durationMinutes: 10 }).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('continues to reject shell metacharacters in Unicode paths', () => {
|
||||
const unsafeWorkingDir = `${unicodeWorkingDir};rm -rf /`;
|
||||
|
||||
expect(CreateSessionSchema.safeParse({ workingDir: unsafeWorkingDir, mode: 'codex' }).success).toBe(false);
|
||||
expect(QuickRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test' }).success).toBe(false);
|
||||
expect(
|
||||
ScheduledRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test', durationMinutes: 10 }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user