mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-10 17:29:33 +01:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d96b3dcf6 | ||
|
|
f62c1f7e2f | ||
|
|
86502b6904 | ||
|
|
9a1bed449d | ||
|
|
43a86eee39 | ||
|
|
404930d55e | ||
|
|
4bde072355 | ||
|
|
ad5455044a | ||
|
|
db6fb53f5e | ||
|
|
f3087a12c0 | ||
|
|
0c3a70676e | ||
|
|
ccb76c3325 | ||
|
|
01b4150672 |
No files matched your search
@@ -8,6 +8,12 @@
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • Terminal - One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/Download_for_Mac-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="Download Codeman for Mac (Codeman.dmg)" width="354"></a>
|
||||
<br>
|
||||
<sub>The native Mac app: signed and notarized, Apple Silicon and Intel, macOS 11.3 or later. <a href="#on-a-mac-the-codeman-app">What it does</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
||||
@@ -17,7 +23,6 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -103,7 +108,7 @@ The first run asks three questions (data folder, port, password; Enter takes the
|
||||
|
||||
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
|
||||
|
||||
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
||||
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while they work, blinks when one waits for you and flashes when one needs permission, and each state can take a style of your own. It stays off until you turn it on. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
||||
|
||||
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
|
||||
|
||||
|
||||
+7
-2
@@ -8,6 +8,12 @@
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD_Mac_%E7%89%88-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="下载 Mac 版 Codeman(Codeman.dmg)" width="281"></a>
|
||||
<br>
|
||||
<sub>原生 Mac 应用:已签名并经过 Apple 公证,支持 Apple 芯片和 Intel,macOS 11.3 或更高版本。<a href="#在-mac-上codeman-应用">它能做什么</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> • <strong>简体中文</strong>
|
||||
</p>
|
||||
@@ -21,7 +27,6 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -105,7 +110,7 @@ bash docker/Start-Codeman.sh
|
||||
|
||||
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
|
||||
|
||||
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
||||
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志显示智能体在做的事:工作时缓慢呼吸,有智能体等你回复时闪烁,需要权限时快闪三下;每种状态也可以换成你自己选的样式。在你打开之前,它保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
||||
|
||||
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
|
||||
|
||||
|
||||
@@ -248,9 +248,10 @@ A small native app that opens Codeman in its own window. Its first screen has tw
|
||||
runs the installer on that machine over SSH.
|
||||
|
||||
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
|
||||
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
|
||||
your own for working, for waiting for you and for needing permission. All three are off
|
||||
until you choose, and the menu only exists on such a Mac.
|
||||
**View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while
|
||||
they work, blinks when one waits for you and flashes three times when one needs permission.
|
||||
Each state can take another style under **View > Apple Logo Blink Styles**. It is off until
|
||||
you turn it on, and the menu only exists on such a Mac.
|
||||
|
||||
### First launch
|
||||
|
||||
|
||||
Generated
+6
-21
@@ -37,12 +37,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
@@ -161,9 +155,9 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
name = "codeman-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"block2",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"libc",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -353,15 +347,6 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "7.0.0"
|
||||
@@ -2460,7 +2445,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
@@ -2478,7 +2463,7 @@ version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
@@ -2981,11 +2966,11 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"base64",
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs 7.0.0",
|
||||
"dirs",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
|
||||
@@ -22,8 +22,10 @@ muda = "0.21"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
# The versions ureq and wry already pull in, so neither crate is built twice
|
||||
# (ureq builds base64 without its default SIMD engines, and so do we).
|
||||
base64 = { version = "0.23", default-features = false, features = ["std"] }
|
||||
dirs = "7"
|
||||
# The reachability probe. platform-verifier = the OS trust store, the same one
|
||||
# WKWebView uses, so the probe and the window agree on which certificates pass.
|
||||
ureq = { version = "3", features = ["platform-verifier"] }
|
||||
|
||||
+64
-50
@@ -9,7 +9,7 @@ macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
|
||||
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
|
||||
Windows and Linux later.
|
||||
|
||||
- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
|
||||
- **Size:** 5.3 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3.5 MB as a `.dmg` (measured 2026-11-09).
|
||||
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
||||
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
||||
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
||||
@@ -24,7 +24,7 @@ operating system's own web view. An Electron build of the same thing would ship
|
||||
a whole Chromium (around 150 MB). The native side only adds what a browser tab
|
||||
cannot do.
|
||||
|
||||
## What it does (v0.1)
|
||||
## What it does
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
@@ -40,7 +40,7 @@ cannot do.
|
||||
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
|
||||
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
|
||||
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
|
||||
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
|
||||
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks, and while the display sleeps or the lid is closed the blink pauses and writes nothing. |
|
||||
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
|
||||
|
||||
## Build (on a Mac)
|
||||
@@ -109,8 +109,9 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
||||
base path, checked after decoding the path once, and which ones depends on
|
||||
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
||||
rendered HTML views, the raw, preview, attachment and folder file routes, the
|
||||
path picker's preview and `api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
||||
on from any frame without saying which, gets only the file routes and
|
||||
path picker's preview and `api/download`. A `window.open` or `target=_blank`
|
||||
link, which WebKit passes on from any frame without saying which, gets only
|
||||
the file routes and
|
||||
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
||||
HTML file could open copies of itself as top-level windows that pass for the
|
||||
app's own. Such a window does not take keyboard focus, and these windows
|
||||
@@ -218,36 +219,49 @@ Codeman counts a request without valid credentials against its login rate
|
||||
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
||||
never retries in a loop. A look at this Mac asks each Codeman server found
|
||||
here once; the app has a login only for the one at the usual address, so the
|
||||
others are asked without one. Once the server refuses the login in a challenge, the
|
||||
app sends it only once more, in the single probe that checks the refusal. If
|
||||
that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
|
||||
restart, each request that arrives without a cookie gets a 401 before the
|
||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||
at once can push a request past 10, and that request then fails with a 429
|
||||
instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
others are asked without one. Once the server refuses the login in a challenge,
|
||||
the app sends it only once more, in the single probe that checks the refusal.
|
||||
If that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in
|
||||
WebKit has a cost here: after a server restart, each request that arrives
|
||||
without a cookie gets a 401 before the
|
||||
login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.**
|
||||
Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that
|
||||
`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the
|
||||
daemon without an administrator through `BrightnessSystemClient` in the private CoreBrightness
|
||||
framework, and 1.50.0 blinked by switching that setting there. The blink now uses two other
|
||||
properties of the same client, which [LogoLight](https://github.com/Ark0N/LogoLight) (MIT, its
|
||||
`docs/corebrightnessd.md`) measured on the real logo and `src/logo.rs` reimplements:
|
||||
`LEPWMFactorFadeDuration` sets how long the next fade takes, and `DisplayLogicalState` 1
|
||||
(Blank) fades the logo to dark and 2 (Unblank) back to lit, on the LED's own spring curve.
|
||||
Nothing of that is saved, so a blink writes no setting and leaves nothing behind a restart;
|
||||
`LEBacklightMode` is only read.
|
||||
|
||||
**The logo blinks from a child process, through CoreBrightness.** Apple documents
|
||||
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
|
||||
for every blink would need a root helper and an administrator password. The same
|
||||
setting is also a property of `BrightnessSystemClient` in the private
|
||||
CoreBrightness framework, and a process of the logged-in user can set it there,
|
||||
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
|
||||
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
|
||||
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
|
||||
keeps its value while the logo is dark.
|
||||
The daemon decides the rest. Writing the direction the logo already heads in is ignored, and
|
||||
the state cannot be read back, so the child keeps track of it and, when it does not know,
|
||||
first turns the logo round with a fade too slow to see. The keyboard backlight follows
|
||||
`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank
|
||||
(`setProperty:withKey:keyboardID:`), only while the built-in display is awake
|
||||
(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise
|
||||
from exactly dark waits for the display to show a frame, up to half a second with nothing
|
||||
changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round
|
||||
while their fall is still under way and never quite reach dark, and a long one (Calm's,
|
||||
Slow's, the breath's rest) times its light from when the rise starts.
|
||||
|
||||
The setting is system-wide and survives a reboot, so the app must not end with the
|
||||
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
|
||||
app can be relied on. So all of it runs as this same binary with
|
||||
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
|
||||
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
|
||||
back and exits. Switching it off closes the same pipe, so the path a crash
|
||||
takes is the one used every day. A marker file
|
||||
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
|
||||
The app must not end with the logo dark, and the release profile has `panic = "abort"`, so no
|
||||
cleanup code of the app can be relied on. So all of it runs as this same binary with
|
||||
`--logo-blink`, for as long as the switch is on and a server is open, tied to the app by
|
||||
nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back (lit,
|
||||
with the daemon's automatic fade duration) and exits. Switching it off closes the same pipe,
|
||||
so the path a crash takes is the one used every day. A marker file (`logo-fade`, next to
|
||||
`desktop.json`) covers what the child cannot survive itself: at the next launch the app puts
|
||||
back a logo a blink left dark, and with 1.50.0's marker (`logo-blink`) it switches back on a
|
||||
logo that version left off. The framework is loaded at run time and never linked, and
|
||||
`LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS
|
||||
that drops it loses this feature and nothing else.
|
||||
|
||||
e child cannot survive itself:
|
||||
at the next launch the app puts back a logo a blink left dark. The framework is
|
||||
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
|
||||
answers nil) is what hides the option, so a macOS that drops it loses this feature
|
||||
@@ -305,18 +319,18 @@ crosses, so the login is never on a command line.
|
||||
answers at the usual address). A password-protected one counts the
|
||||
look as a failed login, like any look at a server the app has no login for.
|
||||
*Check again* asks again.
|
||||
|
||||
- **The logo blink rests on a private macOS interface.** Apple documents the
|
||||
setting, not the way the app reaches it, so a macOS update can take it away;
|
||||
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
||||
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
||||
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
||||
setting is on or off, so these styles are rhythms of hard changes. After a blink
|
||||
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
|
||||
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
|
||||
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
||||
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
||||
keep-alive turn blinks too.
|
||||
on/off setting, not the fades the app uses, so a macOS update can take them
|
||||
away. The menu disappears if `LEBacklightMode` goes; if only the fade keys
|
||||
change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in
|
||||
*Breathe*) can be up to half a second longer than its style says, while the
|
||||
display waits for a frame before the logo rises. Every Blank dims the
|
||||
keyboard backlight a little before it is told to stay lit. A child killed
|
||||
outright (SIGKILL) leaves the logo where it was, perhaps dark, until the
|
||||
display next sleeps and wakes or the app next starts. Another app that drives
|
||||
the logo the same way and is not LogoLight is not recognised, and the two
|
||||
fight. `api/sessions` does not say which turns are Codeman's own, so with
|
||||
prompt-cache keep-warm on, a keep-alive turn blinks too.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||
@@ -335,16 +349,16 @@ crosses, so the login is never on a command line.
|
||||
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
|
||||
is enough). Microphone and camera permissions are asked again for the same
|
||||
reason.
|
||||
- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you
|
||||
made yourself opens normally; a copy that arrived by download or AirDrop is
|
||||
- **Gatekeeper, for a build of your own.** The `Codeman.dmg` on each GitHub
|
||||
release is signed with a Developer ID and notarized by CI
|
||||
(`.github/workflows/desktop-dmg.yml`), so it opens like any downloaded app.
|
||||
A build without a Developer ID is signed ad hoc: it opens normally on the Mac
|
||||
that built it, while a copy that arrived by download or AirDrop is
|
||||
quarantined. On macOS 15 and later, try to open it once, then click *Open
|
||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||
copy in Applications works too. The `Codeman.dmg` on each GitHub release is
|
||||
signed with a Developer ID and notarized by CI
|
||||
(`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the
|
||||
same by hand:
|
||||
copy in Applications works too. To sign and notarize by hand:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
|
||||
where `scripts/sign-macos.sh` lists what it needs.
|
||||
- **Local Network permission (macOS 15 and later).** The first connection to an
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||
}
|
||||
label.field { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
|
||||
input[type=text], input[type=password], input[type=url] {
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%; height: 38px; padding: 0 12px; border-radius: 9px;
|
||||
border: 1px solid var(--line-strong); background: #0c0d10; color: var(--text);
|
||||
font: inherit; outline: none; -webkit-user-select: text; user-select: text;
|
||||
@@ -61,7 +61,6 @@
|
||||
input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59,130,246,0.25); }
|
||||
.row { display: flex; gap: 10px; }
|
||||
.row > * { flex: 1; }
|
||||
.gap { height: 14px; }
|
||||
#recent { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
#recent:empty { display: none; }
|
||||
.chip {
|
||||
@@ -128,7 +127,7 @@
|
||||
/* The Codeman servers found on this Mac: a row each, in the look of a choice
|
||||
in the install, with the row's own Connect button. */
|
||||
#local-found-note { margin: 8px 0 0; }
|
||||
#local-also { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#local-also, #elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#local-also-head { color: var(--muted); font-size: 12px; margin: 0 0 6px; }
|
||||
.found {
|
||||
display: flex; gap: 10px; align-items: center; padding: 9px 11px; border-radius: 9px;
|
||||
@@ -139,7 +138,6 @@
|
||||
.found b { display: block; font-weight: 600; }
|
||||
.found small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
|
||||
.found button { flex: none; }
|
||||
#elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#elsewhere[hidden] { display: none; }
|
||||
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
||||
#elsewhere summary::-webkit-details-marker { display: none; }
|
||||
@@ -231,7 +229,6 @@
|
||||
border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
|
||||
}
|
||||
#wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
|
||||
footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
footer a { color: #aab1bb; text-decoration: none; cursor: default; }
|
||||
footer a:hover { color: var(--accent); }
|
||||
@@ -416,9 +413,11 @@
|
||||
// suggestion: the address the suggest button connects to.
|
||||
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
|
||||
// the connect under way was asked from, where its answer belongs. local: the
|
||||
// last This Mac status from the app. wait: set while an install runs in
|
||||
// Terminal and this page polls for the server it brings up. gui: the
|
||||
// graphical install while its screens are up (newWizard), null otherwise.
|
||||
// last This Mac status from the app. wait: set while this page polls for the
|
||||
// server an install brings up (one in Terminal, or a graphical one that
|
||||
// finished; newWait). gui: the graphical install or the agent chooser while
|
||||
// its screens are up (newWizard), null otherwise. clock: the timer of the
|
||||
// running phase's clock.
|
||||
// reopen: a connect to this Mac was refused, so open it if it turns out to
|
||||
// be starting. startingLooks: how often the page has looked again at a
|
||||
// starting server. askedFound: the connect under way was asked from the row
|
||||
@@ -427,7 +426,7 @@
|
||||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||||
tab: 'local', askedFrom: 'local', askedFound: false, local: null, localChecking: false, opening: false,
|
||||
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: false,
|
||||
reopen: false, startingLooks: 0, startingTimer: 0
|
||||
reopen: false, startingLooks: 0, startingTimer: 0, clock: 0
|
||||
};
|
||||
// While an install runs: how often to look, how long a finished install may
|
||||
// take to answer before Start is offered, and when to stop looking.
|
||||
@@ -511,14 +510,15 @@
|
||||
els.suggest.hidden = true;
|
||||
}
|
||||
|
||||
function host(url) {
|
||||
function displayAddress(url) {
|
||||
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
|
||||
}
|
||||
|
||||
// The address as the app normalizes it (policy::normalize_server_url):
|
||||
// http:// by default, https:// for a .ts.net name without a port, no query
|
||||
// or fragment, a trailing slash. Only used to tell whether two addresses
|
||||
// are one server.
|
||||
// or fragment, a trailing slash. Used to tell whether two addresses are one
|
||||
// server, and which scheme and host a typed address ends up with
|
||||
// (isLocalUrl, the http:// warning).
|
||||
function normalize(raw) {
|
||||
var s = String(raw || '').trim();
|
||||
if (!s) return '';
|
||||
@@ -553,7 +553,7 @@
|
||||
chip.className = 'chip';
|
||||
var pick = document.createElement('button');
|
||||
pick.type = 'button';
|
||||
pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : '');
|
||||
pick.textContent = displayAddress(s.url) + (s.username ? ' (' + s.username + ')' : '');
|
||||
pick.title = 'Connect to ' + s.url;
|
||||
pick.addEventListener('click', function () {
|
||||
if (state.busy) return;
|
||||
@@ -565,7 +565,7 @@
|
||||
x.className = 'x';
|
||||
x.textContent = '×';
|
||||
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
|
||||
x.setAttribute('aria-label', 'Forget ' + host(s.url));
|
||||
x.setAttribute('aria-label', 'Forget ' + displayAddress(s.url));
|
||||
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
|
||||
chip.appendChild(pick);
|
||||
chip.appendChild(x);
|
||||
@@ -782,8 +782,11 @@
|
||||
return (s.found || []).filter(function (f) { return f && f.url !== s.url; });
|
||||
}
|
||||
|
||||
function canConnect(f) {
|
||||
return f.state === 'running' || f.state === 'needsLogin';
|
||||
// Codeman answers there, with or without a login, so the app can connect
|
||||
// to it. Read the same way for a found server's row and for the This Mac
|
||||
// status.
|
||||
function answers(s) {
|
||||
return s.state === 'running' || s.state === 'needsLogin';
|
||||
}
|
||||
|
||||
// What a found server's row says under its address: its version, or why the
|
||||
@@ -815,7 +818,7 @@
|
||||
text.appendChild(small);
|
||||
}
|
||||
row.appendChild(text);
|
||||
if (canConnect(f)) {
|
||||
if (answers(f)) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = primary ? 'primary' : 'secondary';
|
||||
@@ -848,7 +851,7 @@
|
||||
} else if (!s) {
|
||||
dot = 'busy';
|
||||
title = 'Looking for Codeman on this Mac…';
|
||||
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
|
||||
} else if (s.installRunning && !answers(s)) {
|
||||
dot = 'busy';
|
||||
title = 'Codeman is being installed in Terminal.';
|
||||
detail = 'Finish the installer there, then Check again.';
|
||||
@@ -861,7 +864,7 @@
|
||||
} else if (s.state === 'runningElsewhere') {
|
||||
// Not at the address the app opens by default: each one gets a row.
|
||||
main = (s.found || []).filter(Boolean);
|
||||
dot = main.some(canConnect) ? 'ok' : 'warn';
|
||||
dot = main.some(answers) ? 'ok' : 'warn';
|
||||
title = 'Codeman is already running on this Mac.';
|
||||
// No row: the app found the server's process, but no address to try.
|
||||
if (!main.length) {
|
||||
@@ -869,16 +872,12 @@
|
||||
'Starting another server here would take over its sessions.';
|
||||
}
|
||||
show.check = true;
|
||||
} else if (s.state === 'running') {
|
||||
} else if (answers(s)) {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + (s.version ? ' · version ' + s.version : '');
|
||||
show.open = true;
|
||||
also = otherServers(s);
|
||||
} else if (s.state === 'needsLogin') {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + ' asks for a login: Open asks for the password.';
|
||||
detail = s.state === 'needsLogin'
|
||||
? s.url + ' asks for a login: Open asks for the password.'
|
||||
: s.url + (s.version ? ' · version ' + s.version : '');
|
||||
show.open = true;
|
||||
also = otherServers(s);
|
||||
} else if (s.state === 'stopped') {
|
||||
@@ -915,7 +914,7 @@
|
||||
if (o.kind === 'notCodeman') {
|
||||
title = 'Something else answers at ' + s.url;
|
||||
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to ' +
|
||||
(also.some(canConnect) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.');
|
||||
(also.some(answers) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.');
|
||||
} else {
|
||||
var e = explain[o.kind];
|
||||
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
|
||||
@@ -959,8 +958,8 @@
|
||||
}
|
||||
// An install from this app is still running (this page was shown again
|
||||
// while it ran): wait for it, unless the user stopped waiting.
|
||||
if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
|
||||
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
|
||||
if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && !answers(s)) {
|
||||
state.wait = newWait(s.installPort || 3000);
|
||||
renderLocal();
|
||||
scheduleWait();
|
||||
}
|
||||
@@ -989,7 +988,7 @@
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var up = !!s && (s.state === 'running' || s.state === 'needsLogin');
|
||||
var up = !!s && answers(s);
|
||||
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
|
||||
state.startingLooks = 0;
|
||||
state.reopen = false;
|
||||
@@ -1041,10 +1040,15 @@
|
||||
var network = !!state.gui && state.gui.reach === 'network';
|
||||
closeWizard();
|
||||
selectTab('local');
|
||||
openTerminal({ cmd: 'installLocal', network: network });
|
||||
}
|
||||
|
||||
// Both Terminal installs: no second one while Terminal opens.
|
||||
function openTerminal(msg) {
|
||||
state.opening = true;
|
||||
syncLocalButtons();
|
||||
setStatus('busy', 'Opening Terminal…');
|
||||
post({ cmd: 'installLocal', network: network });
|
||||
post(msg);
|
||||
}
|
||||
|
||||
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
|
||||
@@ -1056,17 +1060,22 @@
|
||||
};
|
||||
}
|
||||
|
||||
// The first screen of the graphical install ('install') or of adding an
|
||||
// agent ('agent'), which asks the installer for its plan.
|
||||
function prepareWizard(mode) {
|
||||
state.gui = newWizard('preparing', mode);
|
||||
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
|
||||
}
|
||||
|
||||
function openWizard() {
|
||||
if (state.busy || tooOld || state.opening) return;
|
||||
state.gui = newWizard('preparing');
|
||||
post({ cmd: 'installPrepare' });
|
||||
prepareWizard('install');
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
function openAgentChooser() {
|
||||
if (state.busy || tooOld || state.opening) return;
|
||||
state.gui = newWizard('preparing', 'agent');
|
||||
post({ cmd: 'agentPrepare' });
|
||||
prepareWizard('agent');
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
@@ -1086,14 +1095,14 @@
|
||||
renderLocal();
|
||||
}
|
||||
|
||||
// Try again keeps what was chosen.
|
||||
function retryWizard() {
|
||||
var reach = state.gui ? state.gui.reach : 'local';
|
||||
var agent = state.gui ? state.gui.agent : '';
|
||||
var mode = state.gui ? state.gui.mode : 'install';
|
||||
state.gui = newWizard('preparing', mode);
|
||||
state.gui.reach = reach;
|
||||
state.gui.agent = agent;
|
||||
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
|
||||
var old = state.gui;
|
||||
prepareWizard(old ? old.mode : 'install');
|
||||
if (old) {
|
||||
state.gui.reach = old.reach;
|
||||
state.gui.agent = old.agent;
|
||||
}
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
@@ -1111,6 +1120,12 @@
|
||||
return offeredAgents().filter(function (c) { return c.id === id; })[0] || null;
|
||||
}
|
||||
|
||||
// This Mac has no coding agent and the installer offers one, so an install
|
||||
// asks which one.
|
||||
function needsAgent() {
|
||||
return !installedAgents().length && offeredAgents().length > 0;
|
||||
}
|
||||
|
||||
// A run this page did not start has no plan, so no name for the agent.
|
||||
function agentName() {
|
||||
var c = agentById(state.gui.agent);
|
||||
@@ -1129,7 +1144,7 @@
|
||||
g.step = 'agent';
|
||||
} else if (plan.blocker) {
|
||||
g.step = 'blocked';
|
||||
} else if (!installedAgents().length && offeredAgents().length) {
|
||||
} else if (needsAgent()) {
|
||||
// Kept across Try again; otherwise the first of the catalogue's order.
|
||||
if (!agentById(g.agent)) g.agent = offeredAgents()[0].id;
|
||||
g.step = 'agent';
|
||||
@@ -1229,7 +1244,7 @@
|
||||
PHASES.forEach(function (id) {
|
||||
var label = {
|
||||
runtime: 'Getting Node.js and tmux',
|
||||
agent: 'Installing ' + (chosen ? chosen.label : 'a coding agent'),
|
||||
agent: 'Installing ' + agentName(),
|
||||
source: 'Downloading Codeman',
|
||||
packages: 'Installing packages',
|
||||
build: 'Building Codeman',
|
||||
@@ -1313,21 +1328,14 @@
|
||||
if (!g) return;
|
||||
appendLog(msg.log);
|
||||
clearInterval(state.clock);
|
||||
if (msg.ok && g.mode === 'agent') {
|
||||
g.ok = true;
|
||||
g.step = 'added';
|
||||
renderWizard();
|
||||
// Whether there is a Codeman to open from here.
|
||||
checkLocal('quick');
|
||||
return;
|
||||
}
|
||||
if (msg.ok) {
|
||||
g.ok = true;
|
||||
g.step = 'finishing';
|
||||
g.step = g.mode === 'agent' ? 'added' : 'finishing';
|
||||
renderWizard();
|
||||
// The same watch as a Terminal install: it opens Codeman at the first
|
||||
// answer, and offers Start when none comes.
|
||||
state.wait = { since: Date.now(), finishedAt: 0, port: g.port };
|
||||
// An install gets the same watch as a Terminal install: it opens Codeman
|
||||
// at the first answer, and offers Start when none comes. An added agent
|
||||
// only asks whether there is a Codeman to open from here.
|
||||
if (g.mode !== 'agent') state.wait = newWait(g.port);
|
||||
checkLocal('quick');
|
||||
return;
|
||||
}
|
||||
@@ -1346,7 +1354,8 @@
|
||||
renderLog();
|
||||
}
|
||||
|
||||
// Each screen of the graphical install, drawn from state.gui alone.
|
||||
// Each screen of the graphical install and of adding an agent, drawn from
|
||||
// state.gui (and state.local for Open Codeman).
|
||||
function renderWizard() {
|
||||
var g = state.gui;
|
||||
els.card.classList.toggle('wizard-on', !!g);
|
||||
@@ -1418,10 +1427,6 @@
|
||||
else els.wizTitle.focus();
|
||||
}
|
||||
|
||||
function localAnswers() {
|
||||
return !!state.local && (state.local.state === 'running' || state.local.state === 'needsLogin');
|
||||
}
|
||||
|
||||
function renderWizardButtons() {
|
||||
var g = state.gui;
|
||||
var step = g.step;
|
||||
@@ -1430,7 +1435,7 @@
|
||||
var next = {
|
||||
agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue',
|
||||
reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal',
|
||||
ready: 'Open Codeman', added: localAnswers() ? 'Open Codeman' : ''
|
||||
ready: 'Open Codeman', added: state.local && answers(state.local) ? 'Open Codeman' : ''
|
||||
}[step];
|
||||
els.wizNext.hidden = !next;
|
||||
els.wizNext.textContent = next || '';
|
||||
@@ -1478,7 +1483,7 @@
|
||||
els.wizPw.value = '';
|
||||
els.wizPw2.value = '';
|
||||
// Skipped: the first thing to say once Codeman answers.
|
||||
g.noAgent = !installedAgents().length && !msg.aiCli && offeredAgents().length > 0;
|
||||
g.noAgent = needsAgent() && !msg.aiCli;
|
||||
beginRun();
|
||||
post(msg);
|
||||
}
|
||||
@@ -1524,7 +1529,7 @@
|
||||
function wizardBack() {
|
||||
var g = state.gui;
|
||||
if (!g) return;
|
||||
if (g.step === 'reach' && !installedAgents().length && offeredAgents().length && !g.plan.blocker) {
|
||||
if (g.step === 'reach' && needsAgent() && !g.plan.blocker) {
|
||||
g.step = 'agent';
|
||||
renderWizard();
|
||||
} else {
|
||||
@@ -1532,6 +1537,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A wait for the server an install brings up, on `port` until the
|
||||
// installer says otherwise.
|
||||
function newWait(port) {
|
||||
return { since: Date.now(), finishedAt: 0, port: port };
|
||||
}
|
||||
|
||||
function scheduleWait() {
|
||||
clearTimeout(state.waitTimer);
|
||||
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
|
||||
@@ -1560,7 +1571,7 @@
|
||||
// Until the installer writes its LaunchAgent, the app looks at port 3000,
|
||||
// where something else may answer when the install took another port.
|
||||
var ours = portOf(s.url) === w.port;
|
||||
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
|
||||
if (ours && answers(s)) {
|
||||
var g = state.gui;
|
||||
if (g && g.step === 'finishing' && g.noAgent) {
|
||||
clearTimeout(state.waitTimer);
|
||||
@@ -1608,10 +1619,7 @@
|
||||
els.ssh.focus();
|
||||
return;
|
||||
}
|
||||
state.opening = true;
|
||||
syncLocalButtons();
|
||||
setStatus('busy', 'Opening Terminal…');
|
||||
post({ cmd: 'installRemote', target: target });
|
||||
openTerminal({ cmd: 'installRemote', target: target });
|
||||
}
|
||||
|
||||
window.__codemanDesktop = {
|
||||
@@ -1690,7 +1698,7 @@
|
||||
case 'installStarted':
|
||||
state.opening = false;
|
||||
setStatus('', '');
|
||||
state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 };
|
||||
state.wait = newWait(Number(msg.port) || 3000);
|
||||
selectTab('local');
|
||||
renderLocal();
|
||||
scheduleWait();
|
||||
|
||||
@@ -115,19 +115,22 @@ fn session_cookie(header: &str) -> Option<&str> {
|
||||
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
|
||||
}
|
||||
|
||||
/// A refused login: Codeman counts each against the login rate limit, so the
|
||||
/// polling ends.
|
||||
fn refused(status: u16) -> bool {
|
||||
matches!(status, 401 | 403 | 429)
|
||||
}
|
||||
|
||||
/// What a status code says about asking again.
|
||||
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
|
||||
fn answer(status: u16, body: Option<&str>) -> Poll {
|
||||
match status {
|
||||
200 => body()
|
||||
.as_deref()
|
||||
.and_then(count_working)
|
||||
.map_or(Poll::Failed, |working| {
|
||||
Poll::Seen(Activity {
|
||||
working: working > 0,
|
||||
..Activity::default()
|
||||
})
|
||||
}),
|
||||
401 | 403 | 429 => Poll::Refused,
|
||||
200 => body.and_then(count_working).map_or(Poll::Failed, |working| {
|
||||
Poll::Seen(Activity {
|
||||
working: working > 0,
|
||||
..Activity::default()
|
||||
})
|
||||
}),
|
||||
s if refused(s) => Poll::Refused,
|
||||
_ => Poll::Failed,
|
||||
}
|
||||
}
|
||||
@@ -192,7 +195,7 @@ impl Poller {
|
||||
let Some((status, body)) = self.ask(&url) else {
|
||||
return Poll::Failed;
|
||||
};
|
||||
let mut activity = match answer(status, || body) {
|
||||
let mut activity = match answer(status, body.as_deref()) {
|
||||
Poll::Seen(activity) => activity,
|
||||
other => return other,
|
||||
};
|
||||
@@ -203,7 +206,7 @@ impl Poller {
|
||||
self.alerts = alerts;
|
||||
}
|
||||
}
|
||||
Some((401 | 403 | 429, _)) => return Poll::Refused,
|
||||
Some((s, _)) if refused(s) => return Poll::Refused,
|
||||
// A server from before the list has no alerts to report.
|
||||
Some((404, _)) => self.alerts = (false, false),
|
||||
_ => {}
|
||||
@@ -264,17 +267,17 @@ mod tests {
|
||||
fn a_body_that_is_not_the_list_is_no_answer() {
|
||||
assert_eq!(count_working("<html>"), None);
|
||||
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
|
||||
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
|
||||
assert_eq!(answer(200, || None), Poll::Failed);
|
||||
assert_eq!(answer(200, Some("<html>")), Poll::Failed);
|
||||
assert_eq!(answer(200, None), Poll::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_login_refusal_ends_the_polling() {
|
||||
for status in [401, 403, 429] {
|
||||
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
|
||||
assert_eq!(answer(status, None), Poll::Refused, "{status}");
|
||||
}
|
||||
for status in [301, 404, 500, 502, 503] {
|
||||
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
|
||||
assert_eq!(answer(status, None), Poll::Failed, "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ const MAX_SERVERS: usize = 8;
|
||||
/// How many recovery copies of an unreadable config can sit side by side:
|
||||
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
|
||||
const ASIDE_SLOTS: usize = 10;
|
||||
const MIN_WIDTH: f64 = 480.0;
|
||||
const MIN_HEIGHT: f64 = 360.0;
|
||||
pub const MIN_WIDTH: f64 = 480.0;
|
||||
pub const MIN_HEIGHT: f64 = 360.0;
|
||||
const MAX_EDGE: f64 = 20_000.0;
|
||||
|
||||
/// Height of a standard title bar, in points.
|
||||
@@ -44,9 +44,10 @@ pub struct Config {
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
/// What the lit Apple logo does for each state of the agents (logo.rs). The
|
||||
/// menu only shows on a Mac that has one. A value this version does not know
|
||||
/// reads as off: a bad style must not make the whole file unreadable.
|
||||
/// Whether the lit Apple logo shows what the agents are doing, and its style
|
||||
/// for each state (logo.rs). The menu only shows on a Mac that has one. A
|
||||
/// value this version does not know reads as off: a bad style must not make
|
||||
/// the whole file unreadable.
|
||||
#[serde(deserialize_with = "lenient_blink")]
|
||||
pub logo_blink: BlinkStyles,
|
||||
/// Anything that is not a whole frame reads as None: a bad frame must not
|
||||
@@ -415,9 +416,12 @@ mod tests {
|
||||
use crate::logo::BlinkStyle;
|
||||
let mut c = Config::default();
|
||||
assert_eq!(c.logo_blink, BlinkStyles::default());
|
||||
c.logo_blink.needs_you = BlinkStyle::Alarm;
|
||||
assert!(!c.logo_blink.enabled, "off until the user turns it on");
|
||||
c.logo_blink.enabled = true;
|
||||
c.logo_blink.needs_you = BlinkStyle::Wink;
|
||||
let text = serde_json::to_string(&c).unwrap();
|
||||
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
|
||||
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
|
||||
assert!(text.contains(r#""enabled":true"#), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
|
||||
for bad in [
|
||||
r#"{"logoBlink":3}"#,
|
||||
@@ -434,6 +438,7 @@ mod tests {
|
||||
use crate::logo::BlinkStyle;
|
||||
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
|
||||
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
|
||||
assert!(c.logo_blink.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -88,9 +88,7 @@ impl Keychain {
|
||||
password: password.to_owned(),
|
||||
reply,
|
||||
};
|
||||
if self.jobs.send(job).is_err() {
|
||||
return Err(WORKER_GONE.into());
|
||||
}
|
||||
self.jobs.send(job).map_err(|_| WORKER_GONE.to_string())?;
|
||||
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! tmux in the home folder, so nothing in it asks for a password, and the two
|
||||
//! questions only a person can answer (which coding agent, who may reach
|
||||
//! Codeman) are asked by the page and passed as flags. The Terminal route in
|
||||
//! `setup.rs` stays for what this one cannot do, a Mac older than macOS 15 with
|
||||
//! no tmux first of all.
|
||||
//! `terminal_install.rs` stays for what this one cannot do, a Mac older than
|
||||
//! macOS 15 with no tmux first of all.
|
||||
//!
|
||||
//! `prepare` downloads the installer once into a private folder and asks it
|
||||
//! what is on this Mac (`install.sh preflight`); `start` runs the same file with
|
||||
@@ -100,8 +100,8 @@ impl Run {
|
||||
/// not one: it has no `--progress`.
|
||||
pub fn matches(self, command: &str) -> bool {
|
||||
let suffix = format!("/{}/{SCRIPT_FILE}", self.folder());
|
||||
let mut words = command.split_whitespace();
|
||||
words.any(|w| w.ends_with(&suffix)) && command.split_whitespace().any(|w| w == "--progress")
|
||||
command.split_whitespace().any(|w| w.ends_with(&suffix))
|
||||
&& command.split_whitespace().any(|w| w == "--progress")
|
||||
}
|
||||
|
||||
/// A process of this run, if it is running.
|
||||
@@ -269,11 +269,12 @@ fn download_installer(script: &Path) -> Result<(), String> {
|
||||
if out.code == Some(0) && script.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
const FAILED: &str = "Could not download the installer. Check the internet connection, then try again.";
|
||||
let said = out.stderr.trim();
|
||||
Err(if said.is_empty() {
|
||||
"Could not download the installer. Check the internet connection, then try again.".to_string()
|
||||
FAILED.to_string()
|
||||
} else {
|
||||
format!("Could not download the installer. Check the internet connection, then try again.\n{said}")
|
||||
format!("{FAILED}\n{said}")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -571,9 +572,8 @@ pub fn follow(run: Run, stale: impl Fn() -> bool, send: impl Fn(Value)) {
|
||||
return;
|
||||
}
|
||||
let running = run.process().is_some();
|
||||
let log = progress.feed(&read_more(&mut file, &path, &mut offset));
|
||||
let mut log = progress.feed(&read_more(&mut file, &path, &mut offset));
|
||||
if !running || progress.done {
|
||||
let mut log = log;
|
||||
log.extend(progress.finish());
|
||||
let cancelled = CANCELLED.load(Ordering::SeqCst);
|
||||
if progress.done {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
|
||||
//! The same login-shell machinery answers whether Codeman is installed
|
||||
//! (`installed`) and hands its environment to the graphical install
|
||||
//! (`login_environment`); `exec_bounded` is the crate's bounded process runner.
|
||||
//!
|
||||
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
|
||||
//! finds neither `codeman` nor `node`, and the server inherits whatever
|
||||
@@ -143,7 +146,8 @@ fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Send
|
||||
});
|
||||
}
|
||||
|
||||
/// `exec_bounded` for a start.
|
||||
/// `exec_bounded` with the start's timeout message. The installed check and
|
||||
/// `login_environment` run through it too, and drop its error.
|
||||
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||
exec_bounded(
|
||||
cmd,
|
||||
@@ -248,13 +252,16 @@ fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A portable variable name: a letter or `_`, then letters, digits or `_`.
|
||||
pub fn is_env_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
fn env_entry(line: &str) -> Option<(&str, &str)> {
|
||||
let (name, value) = line.split_once('=')?;
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next()?;
|
||||
let portable =
|
||||
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
portable.then_some((name, value))
|
||||
is_env_name(name).then_some((name, value))
|
||||
}
|
||||
|
||||
/// Ask `shell` for its environment with `flags`. The outer error ends the
|
||||
@@ -339,16 +346,13 @@ fn run_in_shell(
|
||||
/// 127 means "no codeman here" and moves on to the next way of asking; the
|
||||
/// interactive profile is asked only when `interactive` allows it.
|
||||
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
|
||||
let attempts = match flavor(shell) {
|
||||
Flavor::Posix => {
|
||||
let result = run_in_shell(script, shell, false, args, deadline)?;
|
||||
if result.code != Some(127) || !interactive {
|
||||
return Ok(result);
|
||||
}
|
||||
// Not on the login PATH; try the interactive profile too.
|
||||
return run_in_shell(script, shell, true, args, deadline);
|
||||
let Flavor::Capture(attempts) = flavor(shell) else {
|
||||
let result = run_in_shell(script, shell, false, args, deadline)?;
|
||||
if result.code != Some(127) || !interactive {
|
||||
return Ok(result);
|
||||
}
|
||||
Flavor::Capture(attempts) => attempts,
|
||||
// Not on the login PATH; try the interactive profile too.
|
||||
return run_in_shell(script, shell, true, args, deadline);
|
||||
};
|
||||
let mut last_run = None;
|
||||
let mut capture_error = None;
|
||||
@@ -386,8 +390,8 @@ const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0;
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
|
||||
exit 127"#;
|
||||
|
||||
/// How long the This Mac tab waits for the login shell to answer whether
|
||||
/// Codeman is installed.
|
||||
/// How long one look at the login shell may take: whether Codeman is
|
||||
/// installed (the This Mac tab), or its environment for the graphical install.
|
||||
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// `installed` with the shell given, so tests need not touch `$SHELL`.
|
||||
@@ -453,7 +457,7 @@ mod tests {
|
||||
})
|
||||
.output
|
||||
};
|
||||
// stdout that ended without a newline used to be glued to stderr.
|
||||
// stdout that ends without a newline: stderr still starts a line of its own.
|
||||
assert_eq!(
|
||||
said("Starting Codeman", "Error: port 3000 is in use\n"),
|
||||
"Starting Codeman\nError: port 3000 is in use"
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! The login a connect goes out with, and what the Keychain keeps once the
|
||||
//! server has answered it.
|
||||
|
||||
use crate::credentials::{self, Keychain};
|
||||
use crate::probe::ProbeOutcome;
|
||||
|
||||
/// The login a connect goes out with, and where its password came from. A
|
||||
/// typed password wins, then the one the Keychain holds for this user. Last
|
||||
/// comes the login of the Codeman on this Mac, which keeps it where its own
|
||||
/// clients find it (setup::local_login: its launchd job, then
|
||||
/// ~/.codeman/.env) and only answers it at its own loopback address. That one
|
||||
/// is used only when the config expected no password in the Keychain
|
||||
/// (`expect_saved`: one that should be there and is not is reported, not
|
||||
/// papered over), only for the username in use when there is one, and it is
|
||||
/// never saved anywhere. `local` is asked only then: it runs processes.
|
||||
pub fn choose_login(
|
||||
user: Option<String>,
|
||||
typed_password: Option<String>,
|
||||
saved: Option<String>,
|
||||
expect_saved: bool,
|
||||
local: impl FnOnce() -> Option<(String, String)>,
|
||||
) -> (Option<String>, Option<String>, PasswordSource) {
|
||||
if let Some(password) = typed_password {
|
||||
return (user, Some(password), PasswordSource::Typed);
|
||||
}
|
||||
if let Some(password) = saved {
|
||||
return (user, Some(password), PasswordSource::Keychain);
|
||||
}
|
||||
if !expect_saved {
|
||||
if let Some((local_user, password)) = local() {
|
||||
if user.as_deref().is_none_or(|current| current == local_user) {
|
||||
return (Some(local_user), Some(password), PasswordSource::LocalServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
(user, None, PasswordSource::Absent)
|
||||
}
|
||||
|
||||
/// A login the probe just tried, and what the user asked for it.
|
||||
pub struct ProbedLogin<'a> {
|
||||
/// The server, as the config and the Keychain accounts key it.
|
||||
pub server: &'a str,
|
||||
/// The username the config named for this server before the connect.
|
||||
pub previous_user: Option<&'a str>,
|
||||
pub username: Option<&'a str>,
|
||||
pub password: Option<&'a str>,
|
||||
/// Where the password came from.
|
||||
pub source: PasswordSource,
|
||||
pub remember: bool,
|
||||
}
|
||||
|
||||
/// Where a connect's password came from, which decides what the Keychain
|
||||
/// does with it (update_keychain).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PasswordSource {
|
||||
/// Typed on the connect page this time: saved if Remember is on.
|
||||
Typed,
|
||||
/// From the Keychain: there already.
|
||||
Keychain,
|
||||
/// The local Codeman's own login (setup::local_login): it stays where it
|
||||
/// is, and the Keychain never gets a copy.
|
||||
LocalServer,
|
||||
/// No password.
|
||||
Absent,
|
||||
}
|
||||
|
||||
/// What update_keychain did: whether the Keychain now holds the login's
|
||||
/// password, and whose items it deleted.
|
||||
#[derive(Default)]
|
||||
pub struct KeychainUpdate {
|
||||
pub saved: bool,
|
||||
pub deleted: Vec<String>,
|
||||
}
|
||||
|
||||
/// Bring the Keychain in line with a probe the server answered, on the probe
|
||||
/// worker. Only an accepted login changes anything (Ok, or a password the
|
||||
/// server wants changed), since only then does the config get the new entry.
|
||||
///
|
||||
/// When that entry names another user than the config did (compared exactly,
|
||||
/// as the account string is), the old user's item is deleted FIRST, before any
|
||||
/// save: the Keychain worker runs jobs in order, so a Keychain that treats two
|
||||
/// spellings of a name as one item cannot lose the new password to that
|
||||
/// delete. Then the typed password is saved when Remember is on and deleted
|
||||
/// when it is off, and a password the server wants changed is never kept.
|
||||
pub fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate {
|
||||
let mut update = KeychainUpdate::default();
|
||||
if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) {
|
||||
return update;
|
||||
}
|
||||
let mut delete = |user: &str| {
|
||||
keychain.delete(&credentials::account(login.server, user));
|
||||
update.deleted.push(user.to_string());
|
||||
};
|
||||
if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) {
|
||||
delete(old);
|
||||
}
|
||||
let saved = match (outcome, login.username, login.password) {
|
||||
(ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => {
|
||||
if login.remember {
|
||||
keychain
|
||||
.save_blocking(&credentials::account(login.server, user), password)
|
||||
.is_ok()
|
||||
} else {
|
||||
delete(user);
|
||||
false
|
||||
}
|
||||
}
|
||||
// A password that came from the Keychain is saved there already.
|
||||
(ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain,
|
||||
(ProbeOutcome::PasswordChangeRequired, Some(user), _) => {
|
||||
delete(user);
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
update.saved = saved;
|
||||
update
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::credentials::memory;
|
||||
|
||||
fn none() -> Option<(String, String)> {
|
||||
panic!("the local login must not be looked up here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_login_a_connect_uses() {
|
||||
let s = |v: &str| Some(v.to_string());
|
||||
let local = || Some(("admin".to_string(), "from-this-mac".to_string()));
|
||||
|
||||
// A typed password wins over everything, and nothing else is asked.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), s("typed"), s("kept"), true, none),
|
||||
(s("kay"), s("typed"), PasswordSource::Typed)
|
||||
);
|
||||
// Then the Keychain's.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), None, s("kept"), true, none),
|
||||
(s("kay"), s("kept"), PasswordSource::Keychain)
|
||||
);
|
||||
// The Keychain should hold one and does not: reported as absent, the
|
||||
// local login is not tried in its place.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), None, None, true, none),
|
||||
(s("kay"), None, PasswordSource::Absent)
|
||||
);
|
||||
// Nothing typed, nothing expected: the Codeman on this Mac's own
|
||||
// login, for no username or the same one.
|
||||
assert_eq!(
|
||||
choose_login(None, None, None, false, local),
|
||||
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
|
||||
);
|
||||
assert_eq!(
|
||||
choose_login(s("admin"), None, None, false, local),
|
||||
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
|
||||
);
|
||||
// Never for another user: the names are compared exactly.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), None, None, false, local),
|
||||
(s("kay"), None, PasswordSource::Absent)
|
||||
);
|
||||
assert_eq!(
|
||||
choose_login(s("Admin"), None, None, false, local),
|
||||
(s("Admin"), None, PasswordSource::Absent)
|
||||
);
|
||||
// No local login either.
|
||||
assert_eq!(
|
||||
choose_login(None, None, None, false, || None),
|
||||
(None, None, PasswordSource::Absent)
|
||||
);
|
||||
}
|
||||
|
||||
/// update_keychain for one probe answer; the log is what the Keychain did,
|
||||
/// in order (the last load only waits for the queue to drain).
|
||||
fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec<String>) {
|
||||
let (keychain, store) = memory::keychain();
|
||||
let update = update_keychain(&keychain, &outcome, &login);
|
||||
keychain.load_blocking("done");
|
||||
let mut log = store.log();
|
||||
assert_eq!(log.pop().as_deref(), Some("load done"));
|
||||
(update, log)
|
||||
}
|
||||
|
||||
fn login<'a>(
|
||||
previous_user: Option<&'a str>,
|
||||
username: Option<&'a str>,
|
||||
source: PasswordSource,
|
||||
remember: bool,
|
||||
) -> ProbedLogin<'a> {
|
||||
ProbedLogin {
|
||||
server: "http://a/",
|
||||
previous_user,
|
||||
username,
|
||||
password: (source != PasswordSource::Absent).then_some("secret"),
|
||||
source,
|
||||
remember,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_keychain_keeps_after_a_connect() {
|
||||
let ok = || ProbeOutcome::Ok {
|
||||
version: "1.48.0".into(),
|
||||
};
|
||||
let item = |user: &str| credentials::account("http://a/", user);
|
||||
|
||||
// A typed password is saved when Remember is on, deleted when it is off.
|
||||
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true));
|
||||
assert!(update.saved && update.deleted.is_empty());
|
||||
assert_eq!(log, [format!("save {}", item("kay"))]);
|
||||
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false));
|
||||
assert!(!update.saved);
|
||||
assert_eq!(update.deleted, ["kay"]);
|
||||
assert_eq!(log, [format!("delete {}", item("kay"))]);
|
||||
|
||||
// One that came from the Keychain is there already: nothing is written.
|
||||
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true));
|
||||
assert!(update.saved && log.is_empty());
|
||||
|
||||
// The local Codeman's own login never gets a copy, Remember or not.
|
||||
let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true));
|
||||
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
|
||||
|
||||
// Another user than before: the old user's item goes FIRST, then the save.
|
||||
let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true));
|
||||
assert!(update.saved);
|
||||
assert_eq!(update.deleted, ["old"]);
|
||||
assert_eq!(
|
||||
log,
|
||||
[format!("delete {}", item("old")), format!("save {}", item("new"))]
|
||||
);
|
||||
|
||||
// A password the server wants changed is never kept.
|
||||
let (update, log) = keychain_after(
|
||||
ProbeOutcome::PasswordChangeRequired,
|
||||
login(Some("kay"), Some("kay"), PasswordSource::Typed, true),
|
||||
);
|
||||
assert!(!update.saved);
|
||||
assert_eq!(update.deleted, ["kay"]);
|
||||
assert_eq!(log, [format!("delete {}", item("kay"))]);
|
||||
|
||||
// A login the server did not accept changes nothing.
|
||||
for refused in [
|
||||
ProbeOutcome::BadCredentials,
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::Refused,
|
||||
ProbeOutcome::Timeout,
|
||||
] {
|
||||
let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true));
|
||||
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1283
-338
File diff suppressed because it is too large.
Load diff
+245
-653
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
//! The menu bar.
|
||||
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::logo::{self, BlinkStyle, Signal};
|
||||
use crate::{APP_NAME, VERSION};
|
||||
|
||||
/// Codeman's repository and its documentation (the Help menu, About).
|
||||
pub const REPO_URL: &str = "https://github.com/Ark0N/Codeman";
|
||||
pub const DOCS_URL: &str = "https://github.com/Ark0N/Codeman/wiki";
|
||||
|
||||
pub mod menu_id {
|
||||
pub const SWITCH: &str = "switch-server";
|
||||
pub const ADD_AGENT: &str = "add-agent";
|
||||
pub const RELOAD: &str = "reload";
|
||||
pub const START_FULLSCREEN: &str = "start-fullscreen";
|
||||
pub const LOGO_BLINK: &str = "logo-blink";
|
||||
pub const DEVTOOLS: &str = "devtools";
|
||||
pub const HELP_DOCS: &str = "help-docs";
|
||||
pub const HELP_GITHUB: &str = "help-github";
|
||||
}
|
||||
|
||||
pub struct AppMenu {
|
||||
pub bar: Menu,
|
||||
pub window_menu: Submenu,
|
||||
pub help_menu: Submenu,
|
||||
pub start_fullscreen: CheckMenuItem,
|
||||
/// The one switch for the logo. None on a Mac without a lit logo, which has no
|
||||
/// such menu.
|
||||
pub logo_switch: Option<CheckMenuItem>,
|
||||
/// One item per state and style, empty without the switch.
|
||||
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
|
||||
}
|
||||
|
||||
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
let bar = Menu::new();
|
||||
let about = PredefinedMenuItem::about(
|
||||
Some("About Codeman"),
|
||||
Some(AboutMetadata {
|
||||
name: Some(APP_NAME.into()),
|
||||
version: Some(VERSION.into()),
|
||||
comments: Some("A native window for your Codeman server.".into()),
|
||||
website: Some(REPO_URL.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let switch = MenuItem::with_id(
|
||||
menu_id::SWITCH,
|
||||
"Switch Server\u{2026}",
|
||||
true,
|
||||
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)),
|
||||
);
|
||||
let app_menu = Submenu::with_items(
|
||||
APP_NAME,
|
||||
true,
|
||||
&[
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&switch,
|
||||
&MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(None),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::quit(None),
|
||||
],
|
||||
)?;
|
||||
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
|
||||
let edit = Submenu::with_items(
|
||||
"Edit",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::undo(None),
|
||||
&PredefinedMenuItem::redo(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::cut(None),
|
||||
&PredefinedMenuItem::copy(None),
|
||||
&PredefinedMenuItem::paste(None),
|
||||
&PredefinedMenuItem::select_all(None),
|
||||
],
|
||||
)?;
|
||||
let start_fullscreen = CheckMenuItem::with_id(
|
||||
menu_id::START_FULLSCREEN,
|
||||
"Always Open in Full Screen",
|
||||
true,
|
||||
config.start_fullscreen,
|
||||
None,
|
||||
);
|
||||
let view = Submenu::with_items(
|
||||
"View",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
menu_id::RELOAD,
|
||||
"Reload",
|
||||
true,
|
||||
Some(Accelerator::new(Modifiers::META, Code::KeyR)),
|
||||
),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::fullscreen(None),
|
||||
&start_fullscreen,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&MenuItem::with_id(
|
||||
menu_id::DEVTOOLS,
|
||||
"Developer Tools",
|
||||
true,
|
||||
Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)),
|
||||
),
|
||||
],
|
||||
)?;
|
||||
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
|
||||
// style for each state behind it.
|
||||
let mut logo_styles = Vec::new();
|
||||
let mut logo_switch = None;
|
||||
if logo::available() {
|
||||
let switch = CheckMenuItem::with_id(
|
||||
menu_id::LOGO_BLINK,
|
||||
"Blink Apple Logo",
|
||||
true,
|
||||
config.logo_blink.enabled,
|
||||
None,
|
||||
);
|
||||
let submenu = Submenu::new("Apple Logo Blink Styles", true);
|
||||
for signal in Signal::ALL {
|
||||
let group = Submenu::new(signal.label(), true);
|
||||
for style in BlinkStyle::ALL {
|
||||
let chosen = style == config.logo_blink.get(signal);
|
||||
let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None);
|
||||
group.append(&item)?;
|
||||
if style == BlinkStyle::Off {
|
||||
group.append(&PredefinedMenuItem::separator())?;
|
||||
}
|
||||
logo_styles.push((signal, style, item));
|
||||
}
|
||||
submenu.append(&group)?;
|
||||
}
|
||||
// Right under "Always Open in Full Screen", before the separator.
|
||||
view.insert(&switch, 4)?;
|
||||
view.insert(&submenu, 5)?;
|
||||
logo_switch = Some(switch);
|
||||
}
|
||||
let window_menu = Submenu::with_items(
|
||||
"Window",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::close_window(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(None),
|
||||
],
|
||||
)?;
|
||||
let help_menu = Submenu::with_items(
|
||||
"Help",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None),
|
||||
&MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None),
|
||||
],
|
||||
)?;
|
||||
bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?;
|
||||
Ok(AppMenu {
|
||||
bar,
|
||||
window_menu,
|
||||
help_menu,
|
||||
start_fullscreen,
|
||||
logo_switch,
|
||||
logo_styles,
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html";
|
||||
/// Caps on what a page may hand the native side.
|
||||
pub const MAX_URL_LEN: usize = 4096;
|
||||
pub const MAX_FIELD_LEN: usize = 1024;
|
||||
const MAX_MESSAGE_LEN: usize = 16 * 1024;
|
||||
|
||||
/// Where the main window is in reaching a server. The gates below take it
|
||||
/// instead of "is a server set", because what may talk to the native side
|
||||
@@ -61,6 +62,16 @@ impl Phase {
|
||||
}
|
||||
}
|
||||
|
||||
/// The phase a commit of `committed` in the main window moves to: Loaded once
|
||||
/// a page from the server that is loading is the document.
|
||||
pub fn loaded_on_commit(phase: &Phase, committed: &str) -> Option<Phase> {
|
||||
let Phase::Loading(server) = phase else {
|
||||
return None;
|
||||
};
|
||||
let url = Url::parse(committed).ok()?;
|
||||
same_origin(&url, server).then(|| Phase::Loaded(server.clone()))
|
||||
}
|
||||
|
||||
/// Turn what the user typed into a server URL: scheme added when missing,
|
||||
/// query and fragment dropped, and the path ending in `/` so `api/status`
|
||||
/// joins below a base path. The scheme is `https` for a tailnet `.ts.net`
|
||||
@@ -542,12 +553,9 @@ pub fn new_window(target: &str, phase: &Phase, via: Via) -> NewWindow {
|
||||
if target.len() > MAX_URL_LEN {
|
||||
return NewWindow::Deny;
|
||||
}
|
||||
let Ok(url) = Url::parse(target) else {
|
||||
let Some(url) = external_url(target) else {
|
||||
return NewWindow::Deny;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return NewWindow::Deny;
|
||||
}
|
||||
let Some(srv) = phase.loaded() else {
|
||||
return NewWindow::Deny;
|
||||
};
|
||||
@@ -631,7 +639,7 @@ pub enum ConnectCmd {
|
||||
/// Show the agent install that is running already.
|
||||
AgentAttach,
|
||||
/// Run the installer on another machine over SSH, in Terminal. `target`
|
||||
/// is what the user typed; setup::SshTarget::parse checks it.
|
||||
/// is what the user typed; terminal_install::SshTarget::parse checks it.
|
||||
InstallRemote {
|
||||
target: String,
|
||||
},
|
||||
@@ -646,7 +654,12 @@ pub enum Ipc {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[serde(
|
||||
tag = "cmd",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
enum RawConnect {
|
||||
Ready,
|
||||
Connect {
|
||||
@@ -686,18 +699,15 @@ enum RawConnect {
|
||||
},
|
||||
InstallPrepare,
|
||||
InstallStart {
|
||||
// rename_all above reaches variant names only, and deny_unknown_fields
|
||||
// would refuse the page's spelling of these.
|
||||
#[serde(default, rename = "networkPassword")]
|
||||
#[serde(default)]
|
||||
network_password: Option<String>,
|
||||
#[serde(default, rename = "aiCli")]
|
||||
#[serde(default)]
|
||||
ai_cli: Option<String>,
|
||||
},
|
||||
InstallAttach,
|
||||
InstallCancel,
|
||||
AgentPrepare,
|
||||
AgentStart {
|
||||
#[serde(rename = "aiCli")]
|
||||
ai_cli: String,
|
||||
},
|
||||
AgentAttach,
|
||||
@@ -751,7 +761,7 @@ fn is_connect_page(frame: &Url) -> bool {
|
||||
/// - A server page is listened to only once it committed, and only from a
|
||||
/// frame running Codeman's own app shell (trusted_frame).
|
||||
pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: bool) -> Option<Ipc> {
|
||||
if body.len() > 16 * 1024 {
|
||||
if body.len() > MAX_MESSAGE_LEN {
|
||||
return None;
|
||||
}
|
||||
let frame = Url::parse(frame_url).ok()?;
|
||||
@@ -766,17 +776,12 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
|
||||
username,
|
||||
password,
|
||||
remember,
|
||||
} => {
|
||||
if url.len() > MAX_URL_LEN {
|
||||
return None;
|
||||
}
|
||||
ConnectCmd::Connect {
|
||||
url,
|
||||
username: bounded(username, MAX_FIELD_LEN)?,
|
||||
password: bounded(password, MAX_FIELD_LEN)?,
|
||||
remember,
|
||||
}
|
||||
}
|
||||
} if url.len() <= MAX_URL_LEN => ConnectCmd::Connect {
|
||||
url,
|
||||
username: bounded(username, MAX_FIELD_LEN)?,
|
||||
password: bounded(password, MAX_FIELD_LEN)?,
|
||||
remember,
|
||||
},
|
||||
RawConnect::Cancel => ConnectCmd::Cancel,
|
||||
RawConnect::StartServer { url } if url.len() <= MAX_URL_LEN => ConnectCmd::StartServer { url },
|
||||
RawConnect::Forget { url } if url.len() <= MAX_URL_LEN => ConnectCmd::Forget { url },
|
||||
@@ -789,15 +794,10 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
|
||||
RawConnect::InstallStart {
|
||||
network_password,
|
||||
ai_cli,
|
||||
} => {
|
||||
if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) {
|
||||
return None;
|
||||
}
|
||||
ConnectCmd::InstallStart {
|
||||
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
|
||||
ai_cli,
|
||||
}
|
||||
}
|
||||
} if ai_cli.as_deref().is_none_or(is_cli_id) => ConnectCmd::InstallStart {
|
||||
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
|
||||
ai_cli,
|
||||
},
|
||||
RawConnect::InstallAttach => ConnectCmd::InstallAttach,
|
||||
RawConnect::InstallCancel => ConnectCmd::InstallCancel,
|
||||
RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,
|
||||
@@ -959,6 +959,32 @@ mod tests {
|
||||
assert!(!Phase::Loaded(srv).connect_page_live());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_from_the_loading_server_is_the_loaded_page() {
|
||||
let server = Url::parse("https://box.ts.net/codeman/").unwrap();
|
||||
let loading = Phase::Loading(server.clone());
|
||||
assert_eq!(
|
||||
loaded_on_commit(&loading, "https://box.ts.net/codeman/"),
|
||||
Some(Phase::Loaded(server.clone()))
|
||||
);
|
||||
// Any page of that origin counts: the server may redirect within it.
|
||||
assert_eq!(
|
||||
loaded_on_commit(&loading, "https://box.ts.net/codeman/session/abc"),
|
||||
Some(Phase::Loaded(server.clone()))
|
||||
);
|
||||
// Another origin, the connect page, or junk: still loading.
|
||||
assert_eq!(loaded_on_commit(&loading, "https://other.ts.net/codeman/"), None);
|
||||
assert_eq!(loaded_on_commit(&loading, "http://box.ts.net/codeman/"), None);
|
||||
assert_eq!(loaded_on_commit(&loading, CONNECT_PAGE), None);
|
||||
assert_eq!(loaded_on_commit(&loading, "not a url"), None);
|
||||
// Only a load under way can finish.
|
||||
assert_eq!(loaded_on_commit(&Phase::Connect, "https://box.ts.net/codeman/"), None);
|
||||
assert_eq!(
|
||||
loaded_on_commit(&Phase::Loaded(server), "https://box.ts.net/codeman/"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_policy() {
|
||||
let srv = u("http://127.0.0.1:3000/");
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
//!
|
||||
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
|
||||
//! The answer is classified into something the connect page can explain. It is
|
||||
//! asked once per user action, never in a loop: Codeman counts a request
|
||||
//! without valid credentials against the login rate limit (10 per IP).
|
||||
//! asked on a user action, or by one of the This Mac tab's waits (for an
|
||||
//! install to bring Codeman up, for a server that is starting), which end at
|
||||
//! the first answer, never as a standing poll: Codeman counts a request without
|
||||
//! valid credentials against the login rate limit (10 per IP).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
@@ -82,6 +84,16 @@ pub enum ProbeOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
impl ProbeOutcome {
|
||||
/// The server's version, when it answered as Codeman.
|
||||
pub fn version(&self) -> Option<String> {
|
||||
match self {
|
||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
|
||||
/// was asked, `location` and `retry_after` are those response headers.
|
||||
pub fn classify_response(
|
||||
@@ -94,12 +106,10 @@ pub fn classify_response(
|
||||
) -> ProbeOutcome {
|
||||
match status {
|
||||
200 => {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let version = parsed.as_ref().and_then(|v| {
|
||||
(v.get("success")? == &serde_json::Value::Bool(true))
|
||||
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
|
||||
.flatten()
|
||||
});
|
||||
let version = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.filter(|v| v["success"] == true)
|
||||
.and_then(|v| v["data"]["version"].as_str().map(str::to_string));
|
||||
match version {
|
||||
Some(version) => ProbeOutcome::Ok { version },
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
@@ -742,8 +752,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A Codeman that sends its status headers and then stalls (a blocked
|
||||
/// event loop, a lossy link) did not answer in time. It is not "something
|
||||
/// else on this port", which is what an empty body read as before.
|
||||
/// event loop, a lossy link) did not answer in time: a Timeout, not
|
||||
/// NotCodeman ("something else on this port").
|
||||
#[test]
|
||||
fn a_body_that_stalls_is_a_timeout() {
|
||||
use std::io::{Read, Write};
|
||||
|
||||
+29
-566
@@ -1,25 +1,12 @@
|
||||
//! The connect screen's "This Mac" tab and its Terminal installs: where the
|
||||
//! Codeman on this Mac listens, whether it is installed, its login, which
|
||||
//! other Codeman servers of this user run here, and the installer runs in
|
||||
//! Terminal for this Mac and for another machine over SSH.
|
||||
//! The install most people get runs in the app, in `gui_install.rs`.
|
||||
//!
|
||||
//! The Terminal route is for what that one cannot do. `install.sh` asks for
|
||||
//! the administrator password on a terminal (Homebrew, its sudo keep-alive),
|
||||
//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and
|
||||
//! passwords on a terminal too. So the app writes a small script that deletes itself and
|
||||
//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no
|
||||
//! permission, unlike scripting Terminal through osascript (an Automation
|
||||
//! prompt). Everything the scripts contain is built here and unit-tested; the
|
||||
//! only text from the user is the SSH login, which `SshTarget::parse` limits to
|
||||
//! the characters of a user and host name before it is quoted.
|
||||
//! The connect screen's "This Mac" tab: where the Codeman on this Mac listens,
|
||||
//! whether it is installed, its login, and which other Codeman servers of this
|
||||
//! user run here. The install most people get runs in the app
|
||||
//! (`gui_install.rs`); the Terminal installs are in `terminal_install.rs`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -29,6 +16,7 @@ use crate::gui_install;
|
||||
use crate::local_server;
|
||||
use crate::policy;
|
||||
use crate::probe::{self, ProbeOutcome};
|
||||
use crate::terminal_install::LOCAL_SCRIPT_NAME;
|
||||
|
||||
/// Codeman's published installer (a redirect to `install.sh` on master). A test
|
||||
/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is
|
||||
@@ -49,10 +37,9 @@ pub const INSTALL_ONE_LINER: &str = "curl -fsSL https://getcodeman.com/install |
|
||||
/// Codeman's default port, and the first one a fresh install tries.
|
||||
pub const DEFAULT_PORT: u16 = 3000;
|
||||
|
||||
/// The Terminal scripts' file names (`<name>-<pid>-<n>.command`). The local
|
||||
/// one is also how `inspect` recognizes an install that is still running.
|
||||
pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local";
|
||||
pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote";
|
||||
/// The username Codeman logs in with when only a password is set
|
||||
/// (`CODEMAN_USERNAME` unset).
|
||||
pub const DEFAULT_USERNAME: &str = "admin";
|
||||
|
||||
/// How many ports above 3000 a fresh install tries when 3000 is taken.
|
||||
const PORT_SEARCH: u16 = 100;
|
||||
@@ -195,10 +182,7 @@ pub fn parse_env_file(text: &str) -> HashMap<String, String> {
|
||||
let Some((name, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let mut chars = name.chars();
|
||||
let portable = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
if !portable {
|
||||
if !local_server::is_env_name(name) {
|
||||
continue;
|
||||
}
|
||||
let mut value = value.trim();
|
||||
@@ -246,13 +230,13 @@ pub fn login_for(
|
||||
}
|
||||
if let Some(s) = service {
|
||||
if let Some(password) = &s.password {
|
||||
let username = s.username.clone().unwrap_or_else(|| "admin".into());
|
||||
let username = s.username.clone().unwrap_or_else(|| DEFAULT_USERNAME.into());
|
||||
return Some((username, password.clone()));
|
||||
}
|
||||
}
|
||||
let from_file = |key: &str| env_file.get(key).filter(|v| !v.is_empty()).cloned();
|
||||
let password = from_file("CODEMAN_PASSWORD")?;
|
||||
let username = from_file("CODEMAN_USERNAME").unwrap_or_else(|| "admin".into());
|
||||
let username = from_file("CODEMAN_USERNAME").unwrap_or_else(|| DEFAULT_USERNAME.into());
|
||||
Some((username, password))
|
||||
}
|
||||
|
||||
@@ -348,12 +332,17 @@ pub fn is_codeman_server(command: &str) -> bool {
|
||||
server_args(command).is_some()
|
||||
}
|
||||
|
||||
/// The last path component of a command-line word.
|
||||
fn basename(word: &str) -> &str {
|
||||
word.rsplit('/').next().unwrap_or(word)
|
||||
}
|
||||
|
||||
/// What follows `web` on a Codeman server's command line, or None for any
|
||||
/// other command.
|
||||
fn server_args(command: &str) -> Option<Vec<&str>> {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let is_entry = |w: &str| {
|
||||
let name = w.rsplit('/').next().unwrap_or(w);
|
||||
let name = basename(w);
|
||||
w.ends_with("dist/index.js") || w.ends_with("src/index.ts") || name == "codeman" || name == "aicodeman"
|
||||
};
|
||||
// Any entry word that `web` follows: an earlier word may only look like
|
||||
@@ -372,14 +361,14 @@ fn server_args(command: &str) -> Option<Vec<&str>> {
|
||||
/// the port it asked for once the installer runs.
|
||||
pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string();
|
||||
let script = format!("{LOCAL_SCRIPT_NAME}-");
|
||||
if words.iter().any(|w| {
|
||||
let n = name(w);
|
||||
n.starts_with(&format!("{LOCAL_SCRIPT_NAME}-")) && n.ends_with(".command")
|
||||
let n = basename(w);
|
||||
n.starts_with(&script) && n.ends_with(".command")
|
||||
}) {
|
||||
return Some(None);
|
||||
}
|
||||
if words.iter().any(|w| name(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
|
||||
if words.iter().any(|w| basename(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
|
||||
let port = words
|
||||
.iter()
|
||||
.position(|w| *w == "--port")
|
||||
@@ -552,10 +541,7 @@ fn found_server(url: &Url, answer: &ProbeOutcome, default: &Url) -> FoundServer
|
||||
};
|
||||
FoundServer {
|
||||
url: url.to_string(),
|
||||
version: match answer {
|
||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||
_ => None,
|
||||
},
|
||||
version: answer.version(),
|
||||
state,
|
||||
is_default: url == default,
|
||||
}
|
||||
@@ -715,11 +701,10 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
};
|
||||
let answer = probe::probe(&default, auth.as_deref());
|
||||
|
||||
// Every address one of this user's Codeman servers listens at. The one at
|
||||
// the default address has just answered; the others are asked now, once
|
||||
// each. With nothing at the default address, the first of them that
|
||||
// answers is what the tab opens, as it always was, so each gets the
|
||||
// probe's usual time; otherwise they are only listed, and get less.
|
||||
// The other addresses this user's Codeman servers listen at, asked once
|
||||
// each. With nothing at the default address, the first that answers is
|
||||
// what the tab opens (`shown`), so each gets the probe's usual time;
|
||||
// otherwise they are only listed, and get less.
|
||||
let patience = if answer == ProbeOutcome::Refused {
|
||||
probe::TIMEOUT
|
||||
} else {
|
||||
@@ -755,10 +740,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
Depth::Interactive => local_server::installed(true),
|
||||
};
|
||||
let state = local_state(&outcome, !servers.is_empty(), !listening.is_empty(), installed);
|
||||
let version = match &outcome {
|
||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let version = outcome.version();
|
||||
LocalStatus {
|
||||
url: url.to_string(),
|
||||
state,
|
||||
@@ -896,290 +878,6 @@ fn pick_port(free: impl Fn(u16) -> bool) -> u16 {
|
||||
.unwrap_or(DEFAULT_PORT)
|
||||
}
|
||||
|
||||
/// `s` as one bash word.
|
||||
pub fn sh_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// What the This Mac install asks `install.sh` for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LocalInstall {
|
||||
pub port: u16,
|
||||
/// Let the installer ask how other devices reach Codeman (Tailscale, the
|
||||
/// local network with a password). Off: this Mac only (`--local`).
|
||||
pub network: bool,
|
||||
}
|
||||
|
||||
/// `install.sh`'s flags for a This Mac install. Always `--service`: a
|
||||
/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts
|
||||
/// again at login, and the app finds it there (read_service). No `--yes`: the
|
||||
/// user answers the installer's questions (Homebrew, AI coding tools).
|
||||
pub fn local_install_args(opts: LocalInstall) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
if !opts.network {
|
||||
args.push("--local".to_string());
|
||||
}
|
||||
args.push("--service".to_string());
|
||||
args.push("--port".to_string());
|
||||
args.push(opts.port.to_string());
|
||||
args
|
||||
}
|
||||
|
||||
/// The Terminal script for a This Mac install. It downloads the installer to a
|
||||
/// temporary file and runs it from there, so the installer's input is the
|
||||
/// terminal itself.
|
||||
pub fn local_install_script(opts: LocalInstall) -> String {
|
||||
let args = local_install_args(opts);
|
||||
let shown = format!("bash install.sh {}", args.join(" "));
|
||||
let quoted: Vec<String> = args.iter().map(|a| sh_quote(a)).collect();
|
||||
format!(
|
||||
r#"#!/bin/bash
|
||||
rm -f -- "$0"
|
||||
printf '\033]0;%s\007' 'Install Codeman'
|
||||
cat <<'CODEMAN_MSG'
|
||||
Installing Codeman on this Mac.
|
||||
|
||||
The installer may ask for your Mac password (it installs Homebrew, Node.js and
|
||||
tmux when they are missing) and which AI coding tools to set up. Codeman then
|
||||
runs in the background and starts when you log in, so your sessions keep
|
||||
running when the app is closed. The Codeman app opens it by itself when the
|
||||
install is done.
|
||||
|
||||
CODEMAN_MSG
|
||||
tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
echo {from}
|
||||
if ! curl -fsSL {url} -o "$tmp"; then
|
||||
echo
|
||||
echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.'
|
||||
exit 1
|
||||
fi
|
||||
echo {shown}
|
||||
echo
|
||||
bash "$tmp" {args}
|
||||
status=$?
|
||||
echo
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo 'Done. You can close this window.'
|
||||
else
|
||||
echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log"
|
||||
fi
|
||||
exit "$status"
|
||||
"#,
|
||||
from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")),
|
||||
url = sh_quote(INSTALLER_URL),
|
||||
shown = sh_quote(&format!("$ {shown}")),
|
||||
args = quoted.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a
|
||||
/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SshTarget {
|
||||
pub user: Option<String>,
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
const SSH_FORMAT: &str =
|
||||
"Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config.";
|
||||
|
||||
impl SshTarget {
|
||||
/// Only the characters of a user and host name, never a leading `-` (ssh
|
||||
/// would read it as an option) and never an IPv6 literal (ambiguous with
|
||||
/// the port; a host name or an IPv4 address works).
|
||||
pub fn parse(input: &str) -> Result<Self, String> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() || s.len() > 300 {
|
||||
return Err(SSH_FORMAT.into());
|
||||
}
|
||||
let (user, rest) = match s.split_once('@') {
|
||||
Some((u, r)) => (Some(u), r),
|
||||
None => (None, s),
|
||||
};
|
||||
let (host, port) = match rest.split_once(':') {
|
||||
Some((h, p)) => {
|
||||
let port = p.parse::<u16>().ok().filter(|p| *p > 0).ok_or_else(|| {
|
||||
"The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string()
|
||||
})?;
|
||||
(h, Some(port))
|
||||
}
|
||||
None => (rest, None),
|
||||
};
|
||||
let name_ok = |v: &str, max: usize| {
|
||||
!v.is_empty()
|
||||
&& v.len() <= max
|
||||
&& !v.starts_with(['-', '.'])
|
||||
&& v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
|
||||
};
|
||||
if let Some(u) = user {
|
||||
if !name_ok(u, 64) {
|
||||
return Err(SSH_FORMAT.into());
|
||||
}
|
||||
}
|
||||
if !name_ok(host, 253) {
|
||||
return Err(SSH_FORMAT.into());
|
||||
}
|
||||
Ok(SshTarget {
|
||||
user: user.map(str::to_string),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// `user@host`, or `host`.
|
||||
pub fn destination(&self) -> String {
|
||||
match &self.user {
|
||||
Some(u) => format!("{u}@{}", self.host),
|
||||
None => self.host.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address the connect screen suggests once the install is done: the
|
||||
/// installer's default port on that host. Tailscale and a sub-path give
|
||||
/// another address, which the installer prints at the end.
|
||||
pub fn suggested_url(&self) -> String {
|
||||
format!("http://{}:{DEFAULT_PORT}", self.host)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the remote login shell runs. It may be fish or tcsh, so the work is
|
||||
/// one single-quoted `bash -c` word with nothing in it those shells treat
|
||||
/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the
|
||||
/// documented install and the installer asks its questions on ssh's terminal
|
||||
/// (`-t`); `pipefail` makes a failed download fail the whole command, where a
|
||||
/// plain pipe would report bash's success on an empty script. The installer
|
||||
/// needs bash anyway.
|
||||
pub fn remote_command() -> String {
|
||||
format!(
|
||||
"bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \
|
||||
elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \
|
||||
else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'",
|
||||
u = INSTALLER_URL
|
||||
)
|
||||
}
|
||||
|
||||
/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the
|
||||
/// installer's questions, the port, and `--` before the destination.
|
||||
pub fn ssh_args(target: &SshTarget) -> Vec<String> {
|
||||
let mut args = vec!["-t".to_string()];
|
||||
if let Some(port) = target.port {
|
||||
args.push("-p".into());
|
||||
args.push(port.to_string());
|
||||
}
|
||||
args.push("--".into());
|
||||
args.push(target.destination());
|
||||
args.push(remote_command());
|
||||
args
|
||||
}
|
||||
|
||||
/// The Terminal script for an install on another machine over SSH.
|
||||
pub fn remote_install_script(target: &SshTarget) -> String {
|
||||
let args: Vec<String> = ssh_args(target).iter().map(|a| sh_quote(a)).collect();
|
||||
let dest = target.destination();
|
||||
format!(
|
||||
r#"#!/bin/bash
|
||||
rm -f -- "$0"
|
||||
printf '\033]0;%s\007' {title}
|
||||
echo {what}
|
||||
cat <<'CODEMAN_MSG'
|
||||
|
||||
ssh may ask you to confirm the machine and for its password. Then the installer
|
||||
asks how Codeman should be reached (Tailscale, the local network with a password,
|
||||
or that machine only) and whether to run it in the background. At the end it
|
||||
prints the address of Codeman there: connect to it in the Codeman app, under
|
||||
Another machine.
|
||||
|
||||
CODEMAN_MSG
|
||||
ssh {args}
|
||||
status=$?
|
||||
echo
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo 'Done. You can close this window.'
|
||||
else
|
||||
echo "ssh or the installer stopped (exit $status)."
|
||||
fi
|
||||
exit "$status"
|
||||
"#,
|
||||
title = sh_quote(&format!("Install Codeman on {}", target.host)),
|
||||
what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")),
|
||||
args = args.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Run `script` in a new Terminal window. The script file is private to this
|
||||
/// user (the per-user temporary folder, mode 0700, created fresh) and deletes
|
||||
/// itself as it starts.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id()));
|
||||
let written = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o700)
|
||||
.open(&path)
|
||||
.and_then(|mut f| f.write_all(script.as_bytes()));
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(format!("Could not write the install script: {e}"));
|
||||
}
|
||||
let out = Command::new("/usr/bin/open")
|
||||
.args(["-b", "com.apple.Terminal"])
|
||||
.arg(&path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) if o.status.success() => Ok(()),
|
||||
Ok(o) => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let said = String::from_utf8_lossy(&o.stderr).trim().to_string();
|
||||
Err(if said.is_empty() {
|
||||
"Could not open Terminal.".into()
|
||||
} else {
|
||||
format!("Could not open Terminal: {said}")
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Err(format!("Could not open Terminal: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> {
|
||||
let _ = &SCRIPT_COUNTER;
|
||||
Err("Installing from the app is only available on macOS so far.".into())
|
||||
}
|
||||
|
||||
/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure
|
||||
/// context, so it cannot use the web clipboard API itself.
|
||||
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
||||
let mut child = Command::new("/usr/bin/pbcopy")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not copy: {e}"))?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Could not copy: {e}"))?;
|
||||
}
|
||||
match child.wait() {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
_ => Err("Could not copy the command.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1217,8 +915,7 @@ mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn plutil_reads_both_launch_agent_shapes() {
|
||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}-{n}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let install_sh = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -1850,7 +1547,7 @@ mod tests {
|
||||
/// the answers come back in the order of the addresses.
|
||||
#[test]
|
||||
fn found_servers_are_asked_together() {
|
||||
use std::io::Read;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
@@ -1923,238 +1620,4 @@ mod tests {
|
||||
assert_eq!(pick_port(|p| p >= 3002), 3002);
|
||||
assert_eq!(pick_port(|_| false), 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(sh_quote("plain"), "'plain'");
|
||||
assert_eq!(sh_quote("it's"), r"'it'\''s'");
|
||||
assert_eq!(sh_quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_install_flags() {
|
||||
assert_eq!(
|
||||
local_install_args(LocalInstall {
|
||||
port: 3000,
|
||||
network: false
|
||||
}),
|
||||
["--local", "--service", "--port", "3000"]
|
||||
);
|
||||
assert_eq!(
|
||||
local_install_args(LocalInstall {
|
||||
port: 3007,
|
||||
network: true
|
||||
}),
|
||||
["--service", "--port", "3007"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a script under bash with `curl` and `bash`... replaced, and report
|
||||
/// what it did. The real installer never runs in a test.
|
||||
#[cfg(unix)]
|
||||
fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
for (name, body) in fakes {
|
||||
let p = dir.join("bin").join(name);
|
||||
std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let file = dir.join("run.command");
|
||||
std::fs::write(&file, script).unwrap();
|
||||
let out = Command::new("/bin/bash")
|
||||
.arg(&file)
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display()))
|
||||
.env("TMPDIR", &dir)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default();
|
||||
let self_deleted = !file.exists();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
(log, out.status.code().unwrap_or(-1), self_deleted)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_script_downloads_then_runs_the_installer_with_its_flags() {
|
||||
let script = local_install_script(LocalInstall {
|
||||
port: 3001,
|
||||
network: false,
|
||||
});
|
||||
let (log, code, self_deleted) = run_script(
|
||||
&script,
|
||||
&[(
|
||||
"curl",
|
||||
r#"#!/bin/sh
|
||||
echo "curl $*" >> DIR/log
|
||||
out=""
|
||||
while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done
|
||||
cat > "$out" <<'INSTALLER'
|
||||
for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done
|
||||
echo >> DIR/log
|
||||
exit 7
|
||||
INSTALLER
|
||||
"#,
|
||||
)],
|
||||
);
|
||||
assert!(self_deleted, "the script must delete itself");
|
||||
assert!(
|
||||
log.starts_with("curl -fsSL https://getcodeman.com/install -o "),
|
||||
"{log}"
|
||||
);
|
||||
assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}");
|
||||
assert_eq!(code, 7, "the installer's exit status is the script's");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_script_stops_when_the_download_fails() {
|
||||
let script = local_install_script(LocalInstall {
|
||||
port: 3000,
|
||||
network: true,
|
||||
});
|
||||
let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]);
|
||||
assert_eq!(log, "curl\n");
|
||||
assert_eq!(code, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_targets() {
|
||||
assert_eq!(
|
||||
SshTarget::parse(" kay@build-box ").unwrap(),
|
||||
SshTarget {
|
||||
user: Some("kay".into()),
|
||||
host: "build-box".into(),
|
||||
port: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
SshTarget::parse("pi@192.168.1.20:2222").unwrap(),
|
||||
SshTarget {
|
||||
user: Some("pi".into()),
|
||||
host: "192.168.1.20".into(),
|
||||
port: Some(2222)
|
||||
}
|
||||
);
|
||||
assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini");
|
||||
assert_eq!(
|
||||
SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(),
|
||||
"a.b@tnode.tailnet.ts.net"
|
||||
);
|
||||
for bad in [
|
||||
"",
|
||||
"-oProxyCommand=touch /tmp/x",
|
||||
"kay@-oProxyCommand=x",
|
||||
"-l@host",
|
||||
"kay@host name",
|
||||
"kay@host;rm",
|
||||
"kay@host$(x)",
|
||||
"k'y@host",
|
||||
"kay@",
|
||||
"@host",
|
||||
"kay@host:",
|
||||
"kay@host:0",
|
||||
"kay@host:x",
|
||||
"kay@fe80::1",
|
||||
"kay@[::1]",
|
||||
"a@b@c",
|
||||
".hidden",
|
||||
] {
|
||||
assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}");
|
||||
}
|
||||
assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_arguments() {
|
||||
let t = SshTarget::parse("kay@box:2222").unwrap();
|
||||
let args = ssh_args(&t);
|
||||
assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]);
|
||||
assert_eq!(args.len(), 6);
|
||||
let remote = &args[5];
|
||||
let head = "bash -o pipefail -c '";
|
||||
assert!(remote.starts_with(head) && remote.ends_with('\''));
|
||||
// One word for fish and tcsh: no quote inside, nothing they expand.
|
||||
let inner = &remote[head.len()..remote.len() - 1];
|
||||
assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}");
|
||||
assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash"));
|
||||
assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_script_runs_ssh_with_exactly_those_arguments() {
|
||||
let t = SshTarget::parse("kay@box:2222").unwrap();
|
||||
let (log, code, self_deleted) = run_script(
|
||||
&remote_install_script(&t),
|
||||
&[(
|
||||
"ssh",
|
||||
"#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n",
|
||||
)],
|
||||
);
|
||||
assert!(self_deleted);
|
||||
let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect();
|
||||
assert_eq!(log, format!("{expected}\n"));
|
||||
assert_eq!(code, 3);
|
||||
}
|
||||
|
||||
/// The remote command does what it says under a POSIX shell: curl first,
|
||||
/// wget without curl, and a clear error with neither.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_command_picks_a_downloader() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
let exe = |name: &str, body: &str| {
|
||||
let p = dir.join("bin").join(name);
|
||||
std::fs::write(&p, body).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
};
|
||||
// The outer `bash -o pipefail` is the real one; the `bash` the
|
||||
// download is piped into only reports what it was fed.
|
||||
exe(
|
||||
"bash",
|
||||
"#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n",
|
||||
);
|
||||
exe("wget", "#!/bin/sh\necho \"wget $*\"\n");
|
||||
let run = || {
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(remote_command())
|
||||
.env("PATH", dir.join("bin"))
|
||||
.output()
|
||||
.unwrap();
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
out.status.code(),
|
||||
)
|
||||
};
|
||||
let (out, _, _) = run();
|
||||
assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n");
|
||||
exe("curl", "#!/bin/sh\necho \"curl $*\"\n");
|
||||
let (out, _, _) = run();
|
||||
assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n");
|
||||
// A failed download fails the command (pipefail), not "Done".
|
||||
exe(
|
||||
"curl",
|
||||
"#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n",
|
||||
);
|
||||
let (_, _, code) = run();
|
||||
assert_ne!(code, Some(0));
|
||||
std::fs::remove_file(dir.join("bin/curl")).unwrap();
|
||||
std::fs::remove_file(dir.join("bin/wget")).unwrap();
|
||||
let (out, err, code) = run();
|
||||
assert_eq!(out, "");
|
||||
assert!(err.contains("needs curl or wget"), "{err}");
|
||||
assert_eq!(code, Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//! The installs that run in Terminal: on this Mac, and on another machine over
|
||||
//! SSH.
|
||||
//!
|
||||
//! The Terminal route is for what the in-app install (`gui_install.rs`) cannot
|
||||
//! do. `install.sh` asks for the administrator password on a terminal
|
||||
//! (Homebrew, its sudo keep-alive), the Xcode tools arrive as a system dialog,
|
||||
//! and ssh asks about host keys and passwords on a terminal too. So the app
|
||||
//! writes a small script that deletes itself and hands it to Terminal
|
||||
//! (`open -b com.apple.Terminal`), which needs no permission, unlike scripting
|
||||
//! Terminal through osascript (an Automation prompt). Everything the scripts
|
||||
//! contain is built here and unit-tested; the only text from the user is the
|
||||
//! SSH login, which `SshTarget::parse` limits to the characters of a user and
|
||||
//! host name before it is quoted.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::setup::{DEFAULT_PORT, INSTALLER_URL};
|
||||
|
||||
/// The Terminal scripts' file names (`<name>-<pid>-<n>.command`). The local
|
||||
/// one is also how `inspect` recognizes an install that is still running.
|
||||
pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local";
|
||||
pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote";
|
||||
|
||||
/// `s` as one bash word.
|
||||
pub fn sh_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// What the This Mac install asks `install.sh` for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LocalInstall {
|
||||
pub port: u16,
|
||||
/// Let the installer ask how other devices reach Codeman (Tailscale, the
|
||||
/// local network with a password). Off: this Mac only (`--local`).
|
||||
pub network: bool,
|
||||
}
|
||||
|
||||
/// `install.sh`'s flags for a This Mac install. Always `--service`: a
|
||||
/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts
|
||||
/// again at login, and the app finds it there (read_service). No `--yes`: the
|
||||
/// user answers the installer's questions (Homebrew, AI coding tools).
|
||||
pub fn local_install_args(opts: LocalInstall) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
if !opts.network {
|
||||
args.push("--local".to_string());
|
||||
}
|
||||
args.push("--service".to_string());
|
||||
args.push("--port".to_string());
|
||||
args.push(opts.port.to_string());
|
||||
args
|
||||
}
|
||||
|
||||
/// The Terminal script for a This Mac install. It downloads the installer to a
|
||||
/// temporary file and runs it from there, so the installer's input is the
|
||||
/// terminal itself.
|
||||
pub fn local_install_script(opts: LocalInstall) -> String {
|
||||
let args = local_install_args(opts);
|
||||
let shown = format!("bash install.sh {}", args.join(" "));
|
||||
let quoted: Vec<String> = args.iter().map(|a| sh_quote(a)).collect();
|
||||
format!(
|
||||
r#"#!/bin/bash
|
||||
rm -f -- "$0"
|
||||
printf '\033]0;%s\007' 'Install Codeman'
|
||||
cat <<'CODEMAN_MSG'
|
||||
Installing Codeman on this Mac.
|
||||
|
||||
The installer may ask for your Mac password (it installs Homebrew, Node.js and
|
||||
tmux when they are missing) and which AI coding tools to set up. Codeman then
|
||||
runs in the background and starts when you log in, so your sessions keep
|
||||
running when the app is closed. The Codeman app opens it by itself when the
|
||||
install is done.
|
||||
|
||||
CODEMAN_MSG
|
||||
tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
echo {from}
|
||||
if ! curl -fsSL {url} -o "$tmp"; then
|
||||
echo
|
||||
echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.'
|
||||
exit 1
|
||||
fi
|
||||
echo {shown}
|
||||
echo
|
||||
bash "$tmp" {args}
|
||||
status=$?
|
||||
echo
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo 'Done. You can close this window.'
|
||||
else
|
||||
echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log"
|
||||
fi
|
||||
exit "$status"
|
||||
"#,
|
||||
from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")),
|
||||
url = sh_quote(INSTALLER_URL),
|
||||
shown = sh_quote(&format!("$ {shown}")),
|
||||
args = quoted.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a
|
||||
/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SshTarget {
|
||||
pub user: Option<String>,
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
const SSH_FORMAT: &str =
|
||||
"Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config.";
|
||||
|
||||
impl SshTarget {
|
||||
/// Only the characters of a user and host name, never a leading `-` (ssh
|
||||
/// would read it as an option) and never an IPv6 literal (ambiguous with
|
||||
/// the port; a host name or an IPv4 address works).
|
||||
pub fn parse(input: &str) -> Result<Self, String> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() || s.len() > 300 {
|
||||
return Err(SSH_FORMAT.into());
|
||||
}
|
||||
let (user, rest) = match s.split_once('@') {
|
||||
Some((u, r)) => (Some(u), r),
|
||||
None => (None, s),
|
||||
};
|
||||
let (host, port) = match rest.split_once(':') {
|
||||
Some((h, p)) => {
|
||||
let port = p.parse::<u16>().ok().filter(|p| *p > 0).ok_or_else(|| {
|
||||
"The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string()
|
||||
})?;
|
||||
(h, Some(port))
|
||||
}
|
||||
None => (rest, None),
|
||||
};
|
||||
let name_ok = |v: &str, max: usize| {
|
||||
!v.is_empty()
|
||||
&& v.len() <= max
|
||||
&& !v.starts_with(['-', '.'])
|
||||
&& v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
|
||||
};
|
||||
if let Some(u) = user {
|
||||
if !name_ok(u, 64) {
|
||||
return Err(SSH_FORMAT.into());
|
||||
}
|
||||
}
|
||||
if !name_ok(host, 253) {
|
||||
return Err(SSH_FORMAT.into());
|
||||
}
|
||||
Ok(SshTarget {
|
||||
user: user.map(str::to_string),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// `user@host`, or `host`.
|
||||
pub fn destination(&self) -> String {
|
||||
match &self.user {
|
||||
Some(u) => format!("{u}@{}", self.host),
|
||||
None => self.host.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address the connect screen suggests once the install is done: the
|
||||
/// installer's default port on that host. Tailscale and a sub-path give
|
||||
/// another address, which the installer prints at the end.
|
||||
pub fn suggested_url(&self) -> String {
|
||||
format!("http://{}:{DEFAULT_PORT}", self.host)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the remote login shell runs. It may be fish or tcsh, so the work is
|
||||
/// one single-quoted `bash -c` word with nothing in it those shells treat
|
||||
/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the
|
||||
/// documented install and the installer asks its questions on ssh's terminal
|
||||
/// (`-t`); `pipefail` makes a failed download fail the whole command, where a
|
||||
/// plain pipe would report bash's success on an empty script. The installer
|
||||
/// needs bash anyway.
|
||||
pub fn remote_command() -> String {
|
||||
format!(
|
||||
"bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \
|
||||
elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \
|
||||
else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'",
|
||||
u = INSTALLER_URL
|
||||
)
|
||||
}
|
||||
|
||||
/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the
|
||||
/// installer's questions, the port, and `--` before the destination.
|
||||
pub fn ssh_args(target: &SshTarget) -> Vec<String> {
|
||||
let mut args = vec!["-t".to_string()];
|
||||
if let Some(port) = target.port {
|
||||
args.push("-p".into());
|
||||
args.push(port.to_string());
|
||||
}
|
||||
args.push("--".into());
|
||||
args.push(target.destination());
|
||||
args.push(remote_command());
|
||||
args
|
||||
}
|
||||
|
||||
/// The Terminal script for an install on another machine over SSH.
|
||||
pub fn remote_install_script(target: &SshTarget) -> String {
|
||||
let args: Vec<String> = ssh_args(target).iter().map(|a| sh_quote(a)).collect();
|
||||
let dest = target.destination();
|
||||
format!(
|
||||
r#"#!/bin/bash
|
||||
rm -f -- "$0"
|
||||
printf '\033]0;%s\007' {title}
|
||||
echo {what}
|
||||
cat <<'CODEMAN_MSG'
|
||||
|
||||
ssh may ask you to confirm the machine and for its password. Then the installer
|
||||
asks how Codeman should be reached (Tailscale, the local network with a password,
|
||||
or that machine only) and whether to run it in the background. At the end it
|
||||
prints the address of Codeman there: connect to it in the Codeman app, under
|
||||
Another machine.
|
||||
|
||||
CODEMAN_MSG
|
||||
ssh {args}
|
||||
status=$?
|
||||
echo
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo 'Done. You can close this window.'
|
||||
else
|
||||
echo "ssh or the installer stopped (exit $status)."
|
||||
fi
|
||||
exit "$status"
|
||||
"#,
|
||||
title = sh_quote(&format!("Install Codeman on {}", target.host)),
|
||||
what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")),
|
||||
args = args.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Run `script` in a new Terminal window. The script file is private to this
|
||||
/// user (the per-user temporary folder, mode 0700, created fresh) and deletes
|
||||
/// itself as it starts.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id()));
|
||||
let written = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o700)
|
||||
.open(&path)
|
||||
.and_then(|mut f| f.write_all(script.as_bytes()));
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(format!("Could not write the install script: {e}"));
|
||||
}
|
||||
let out = Command::new("/usr/bin/open")
|
||||
.args(["-b", "com.apple.Terminal"])
|
||||
.arg(&path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) if o.status.success() => Ok(()),
|
||||
Ok(o) => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let said = String::from_utf8_lossy(&o.stderr).trim().to_string();
|
||||
Err(if said.is_empty() {
|
||||
"Could not open Terminal.".into()
|
||||
} else {
|
||||
format!("Could not open Terminal: {said}")
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Err(format!("Could not open Terminal: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> {
|
||||
let _ = &SCRIPT_COUNTER;
|
||||
Err("Installing from the app is only available on macOS so far.".into())
|
||||
}
|
||||
|
||||
/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure
|
||||
/// context, so it cannot use the web clipboard API itself.
|
||||
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
||||
let mut child = Command::new("/usr/bin/pbcopy")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not copy: {e}"))?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Could not copy: {e}"))?;
|
||||
}
|
||||
match child.wait() {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
_ => Err("Could not copy the command.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(sh_quote("plain"), "'plain'");
|
||||
assert_eq!(sh_quote("it's"), r"'it'\''s'");
|
||||
assert_eq!(sh_quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_install_flags() {
|
||||
assert_eq!(
|
||||
local_install_args(LocalInstall {
|
||||
port: 3000,
|
||||
network: false
|
||||
}),
|
||||
["--local", "--service", "--port", "3000"]
|
||||
);
|
||||
assert_eq!(
|
||||
local_install_args(LocalInstall {
|
||||
port: 3007,
|
||||
network: true
|
||||
}),
|
||||
["--service", "--port", "3007"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a script under bash with `curl` and `bash`... replaced, and report
|
||||
/// what it did. The real installer never runs in a test.
|
||||
#[cfg(unix)]
|
||||
fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
for (name, body) in fakes {
|
||||
let p = dir.join("bin").join(name);
|
||||
std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let file = dir.join("run.command");
|
||||
std::fs::write(&file, script).unwrap();
|
||||
let out = Command::new("/bin/bash")
|
||||
.arg(&file)
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display()))
|
||||
.env("TMPDIR", &dir)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default();
|
||||
let self_deleted = !file.exists();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
(log, out.status.code().unwrap_or(-1), self_deleted)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_script_downloads_then_runs_the_installer_with_its_flags() {
|
||||
let script = local_install_script(LocalInstall {
|
||||
port: 3001,
|
||||
network: false,
|
||||
});
|
||||
let (log, code, self_deleted) = run_script(
|
||||
&script,
|
||||
&[(
|
||||
"curl",
|
||||
r#"#!/bin/sh
|
||||
echo "curl $*" >> DIR/log
|
||||
out=""
|
||||
while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done
|
||||
cat > "$out" <<'INSTALLER'
|
||||
for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done
|
||||
echo >> DIR/log
|
||||
exit 7
|
||||
INSTALLER
|
||||
"#,
|
||||
)],
|
||||
);
|
||||
assert!(self_deleted, "the script must delete itself");
|
||||
assert!(
|
||||
log.starts_with("curl -fsSL https://getcodeman.com/install -o "),
|
||||
"{log}"
|
||||
);
|
||||
assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}");
|
||||
assert_eq!(code, 7, "the installer's exit status is the script's");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_script_stops_when_the_download_fails() {
|
||||
let script = local_install_script(LocalInstall {
|
||||
port: 3000,
|
||||
network: true,
|
||||
});
|
||||
let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]);
|
||||
assert_eq!(log, "curl\n");
|
||||
assert_eq!(code, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_targets() {
|
||||
assert_eq!(
|
||||
SshTarget::parse(" kay@build-box ").unwrap(),
|
||||
SshTarget {
|
||||
user: Some("kay".into()),
|
||||
host: "build-box".into(),
|
||||
port: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
SshTarget::parse("pi@192.168.1.20:2222").unwrap(),
|
||||
SshTarget {
|
||||
user: Some("pi".into()),
|
||||
host: "192.168.1.20".into(),
|
||||
port: Some(2222)
|
||||
}
|
||||
);
|
||||
assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini");
|
||||
assert_eq!(
|
||||
SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(),
|
||||
"a.b@tnode.tailnet.ts.net"
|
||||
);
|
||||
for bad in [
|
||||
"",
|
||||
"-oProxyCommand=touch /tmp/x",
|
||||
"kay@-oProxyCommand=x",
|
||||
"-l@host",
|
||||
"kay@host name",
|
||||
"kay@host;rm",
|
||||
"kay@host$(x)",
|
||||
"k'y@host",
|
||||
"kay@",
|
||||
"@host",
|
||||
"kay@host:",
|
||||
"kay@host:0",
|
||||
"kay@host:x",
|
||||
"kay@fe80::1",
|
||||
"kay@[::1]",
|
||||
"a@b@c",
|
||||
".hidden",
|
||||
] {
|
||||
assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}");
|
||||
}
|
||||
assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_arguments() {
|
||||
let t = SshTarget::parse("kay@box:2222").unwrap();
|
||||
let args = ssh_args(&t);
|
||||
assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]);
|
||||
assert_eq!(args.len(), 6);
|
||||
let remote = &args[5];
|
||||
let head = "bash -o pipefail -c '";
|
||||
assert!(remote.starts_with(head) && remote.ends_with('\''));
|
||||
// One word for fish and tcsh: no quote inside, nothing they expand.
|
||||
let inner = &remote[head.len()..remote.len() - 1];
|
||||
assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}");
|
||||
assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash"));
|
||||
assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_script_runs_ssh_with_exactly_those_arguments() {
|
||||
let t = SshTarget::parse("kay@box:2222").unwrap();
|
||||
let (log, code, self_deleted) = run_script(
|
||||
&remote_install_script(&t),
|
||||
&[(
|
||||
"ssh",
|
||||
"#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n",
|
||||
)],
|
||||
);
|
||||
assert!(self_deleted);
|
||||
let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect();
|
||||
assert_eq!(log, format!("{expected}\n"));
|
||||
assert_eq!(code, 3);
|
||||
}
|
||||
|
||||
/// The remote command does what it says under a POSIX shell: curl first,
|
||||
/// wget without curl, and a clear error with neither.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_command_picks_a_downloader() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
let exe = |name: &str, body: &str| {
|
||||
let p = dir.join("bin").join(name);
|
||||
std::fs::write(&p, body).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
};
|
||||
// The outer `bash -o pipefail` is the real one; the `bash` the
|
||||
// download is piped into only reports what it was fed.
|
||||
exe(
|
||||
"bash",
|
||||
"#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n",
|
||||
);
|
||||
exe("wget", "#!/bin/sh\necho \"wget $*\"\n");
|
||||
let run = || {
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(remote_command())
|
||||
.env("PATH", dir.join("bin"))
|
||||
.output()
|
||||
.unwrap();
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
out.status.code(),
|
||||
)
|
||||
};
|
||||
let (out, _, _) = run();
|
||||
assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n");
|
||||
exe("curl", "#!/bin/sh\necho \"curl $*\"\n");
|
||||
let (out, _, _) = run();
|
||||
assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n");
|
||||
// A failed download fails the command (pipefail), not "Done".
|
||||
exe(
|
||||
"curl",
|
||||
"#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n",
|
||||
);
|
||||
let (_, _, code) = run();
|
||||
assert_ne!(code, Some(0));
|
||||
std::fs::remove_file(dir.join("bin/curl")).unwrap();
|
||||
std::fs::remove_file(dir.join("bin/wget")).unwrap();
|
||||
let (out, err, code) = run();
|
||||
assert_eq!(out, "");
|
||||
assert!(err.contains("needs curl or wget"), "{err}");
|
||||
assert_eq!(code, Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1053,9 +1053,16 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
return progress ? { ...next, progress } : next;
|
||||
}
|
||||
|
||||
/** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */
|
||||
/**
|
||||
* The stored state as a view or a verb must see it: settled, unless a verb is just starting a
|
||||
* job. A job writes its last state BEFORE it lets go of `this.job`, so when the job ended while
|
||||
* this read was out, the read may hold the `installing` from before that write; it is read
|
||||
* again rather than settled, or it would show as an interrupted (`paused`) job for a tick.
|
||||
*/
|
||||
private async currentState(): Promise<LocalModelStateFile> {
|
||||
const state = await this.deps.readState();
|
||||
const jobAtRead = this.job;
|
||||
let state = await this.deps.readState();
|
||||
if (jobAtRead && !this.job) state = await this.deps.readState();
|
||||
return this.jobStarting ? state : this.settled(state);
|
||||
}
|
||||
|
||||
|
||||
@@ -1938,6 +1938,57 @@ describe('view()', () => {
|
||||
await h.service.cancel();
|
||||
});
|
||||
|
||||
it('a view whose read began while the job ran shows how the job ended, never paused', async () => {
|
||||
// The window polls every second, so a read of the state file can be under way while the
|
||||
// job writes its last state and lets go. That read returns the old `installing`, and
|
||||
// settling it with no job in this process read it as an interrupted job: the window (and
|
||||
// the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`.
|
||||
const slow = {
|
||||
read: undefined as undefined | (() => Promise<LocalModelStateFile>),
|
||||
hold: undefined as undefined | Promise<void>,
|
||||
entered: undefined as undefined | (() => void),
|
||||
};
|
||||
const h = setup({
|
||||
overrides: {
|
||||
readState: async () => {
|
||||
const snapshot = await slow.read!();
|
||||
const hold = slow.hold;
|
||||
slow.hold = undefined;
|
||||
if (hold) {
|
||||
slow.entered?.();
|
||||
await hold;
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
},
|
||||
});
|
||||
slow.read = h.store.readState;
|
||||
let failVerify!: () => void;
|
||||
const verifying = new Promise<void>((resolve) => (failVerify = resolve));
|
||||
h.deps.verifyModel.mockImplementationOnce(async () => {
|
||||
await verifying;
|
||||
return { ok: false, code: 'no-tool-call' } as never;
|
||||
});
|
||||
|
||||
await h.service.install(REQUEST, ADMIN);
|
||||
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
|
||||
|
||||
// A view starts reading while the job is still in the verify step...
|
||||
let releaseRead!: () => void;
|
||||
slow.hold = new Promise<void>((resolve) => (releaseRead = resolve));
|
||||
const entered = new Promise<void>((resolve) => (slow.entered = resolve));
|
||||
const viewing = h.service.view();
|
||||
await entered;
|
||||
// ...the job fails, writes it and ends while that read is still out...
|
||||
failVerify();
|
||||
await h.service.idle();
|
||||
expect(h.store.get().state).toBe('failed');
|
||||
// ...and only then does the read come back, with the file as it was before.
|
||||
releaseRead();
|
||||
const view = await viewing;
|
||||
expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } });
|
||||
});
|
||||
|
||||
it('names the model to try next only when the catalogue still has it', async () => {
|
||||
const state: LocalModelStateFile = {
|
||||
...jobAt('verify', 'paused'),
|
||||
|
||||
Reference in new issue
Block a user