mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-11 01:39:34 +01:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25cc08606 | ||
|
|
9608a17d99 | ||
|
|
f62c1f7e2f | ||
|
|
86502b6904 | ||
|
|
9a1bed449d | ||
|
|
43a86eee39 | ||
|
|
404930d55e | ||
|
|
4bde072355 | ||
|
|
ad5455044a | ||
|
|
db6fb53f5e | ||
|
|
f3087a12c0 | ||
|
|
0c3a70676e | ||
|
|
ccb76c3325 | ||
|
|
01b4150672 |
No files matched your search
@@ -192,7 +192,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ The answer lists EVERY model that fits, and the install accepts only a listed row: `MAX_ALTERNATIVES` is a bound above the catalogue's size (`test/local-model-catalog.test.ts` fails for a catalogue that outgrows it), never a way to shorten the list. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
||||
|
||||
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaveLine truncated
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
+6
-1
@@ -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">
|
||||
|
||||
@@ -1766,7 +1766,7 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
|
||||
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so.
|
||||
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
|
||||
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
|
||||
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
||||
- `pick` (absent with `none`) and `alternatives[]` (every other model of the catalogue that fits, best first; empty with `none`), each one model at one context length with one agent CLI. An install is accepted only for a row of this list. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
||||
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
|
||||
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
|
||||
- `needBytes` and `needGb` (memory the model needs at that context, at its PEAK: the weights, what stays held per token of context, what the runner holds beside both, and what it takes for a moment while a long prompt is processed), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
|
||||
|
||||
+35
-22
@@ -17,28 +17,29 @@ in [architecture-invariants.md](architecture-invariants.md#local-model-install).
|
||||
## Which model fits which Mac
|
||||
|
||||
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
|
||||
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
|
||||
the catalogue answers today, before the runtime measured anything (the budget is then an
|
||||
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
|
||||
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||
fits this Mac's memory at a context a coding agent can work with, and under it every other
|
||||
model of the catalogue that fits (ten models today: a 16 GB Mac sees one of them, a 512 GB
|
||||
Mac all ten). What the catalogue answers today, before the runtime measured anything (the
|
||||
budget is then an estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and
|
||||
never more than the memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
|
||||
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
|
||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
|
||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
|
||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 9.3 GB at 65,536 tokens |
|
||||
| 16 GB | 10.4 GB | A small model (`limited`): Ornith 1.0 9B at 65,536 tokens, 9.3 GB, tight (90%). Its build has no vision projector, which makes it 0.9 GB smaller than the other two 9B builds: MiMo V2.6 9B needs 10.2 GB there, 98.7% of the budget, just past the 98% a tight fit may reach. Once the runtime measures what a 16 GB Mac mini measured (10.7 GB), all three fit and MiMo is the pick. No 9B was run on such a Mac by us: only a run settles it |
|
||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Ornith 1.0 9B (10.5 GB) and Qwen3.5 9B are the alternatives |
|
||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||
| 32 GB | 21.8 GB | No longer a small-model Mac: Muse Glimmer 30B fits at 65,536 tokens (21.2 GB, 97%, tight). It is a dense model. On an M1 Max or M2 Max it computes to 11.8 tokens per second and is the pick; on a base or Pro chip it computes to under 6 and is listed as slow, and the pick stays MiMo V2.6 9B at 131,072 tokens with the note that it is a small model. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight. Muse Glimmer 30B at 131,072 tokens (21.6 GB, 78%) is the alternative with room to spare |
|
||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||
|
||||
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
|
||||
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
|
||||
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
|
||||
after the fast ones, which is why the chip changes the pick at 32 to 96 GB. Every model is
|
||||
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
|
||||
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
|
||||
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
|
||||
@@ -77,8 +78,10 @@ Where every figure comes from:
|
||||
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
|
||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
|
||||
| `ornith:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B; its config and its GGUF header say so) | 9.3: the same architecture and weights size |
|
||||
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
|
||||
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
|
||||
| `muse-glimmer:30b-q4_k_m` | llama.cpp | computed from its config: 13 full-attention and 39 sliding-window layers | 2.95: the dense 27B's, whose weights are the same size |
|
||||
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
|
||||
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
|
||||
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
|
||||
@@ -86,13 +89,16 @@ Where every figure comes from:
|
||||
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
|
||||
|
||||
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
|
||||
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
|
||||
calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
2026-11-08 (the Ornith 1.0 and Muse Glimmer rows on 2026-11-10). Only one row was ever run on
|
||||
a Mac, so every other answer in the table above is a calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
|
||||
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
|
||||
below). The measured row's figures come from single long prompts, not a session of many
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
|
||||
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left
|
||||
out on purpose (no evidence of working tool calls, a restrictive licence, a known bug, a
|
||||
build that needs a newer runtime than the pinned one) and why. The strongest of those today
|
||||
is Ripple 65B Preview, published on 2026-11-09: every build of it requires Ollama 0.40.16,
|
||||
one above the pin.
|
||||
|
||||
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
|
||||
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
||||
@@ -355,6 +361,13 @@ or `python3`.
|
||||
others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
||||
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
|
||||
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
||||
- **A tool call that quotes a tool call can be cut off.** The pinned runtime reads the tool
|
||||
calls of every Qwen-family model in the catalogue (the Ornith, MiMo and Qwen rows, the pair
|
||||
we ran included) with one parser, and that parser ends a call at the first closing tag it
|
||||
sees. An argument that itself contains a whole tool call, such as a prompt handed to a
|
||||
sub-agent that shows one, arrives cut off and the rest as text (runtime issue 16992, filed
|
||||
on `ornith:35b`; the fix, runtime pull request 17021, is open). The check after a download
|
||||
asks for one plain tool call and does not see this.
|
||||
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
||||
unloads it.
|
||||
|
||||
|
||||
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"] }
|
||||
|
||||
+21
-19
@@ -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
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
@@ -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,11 +219,12 @@ 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
|
||||
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
|
||||
@@ -305,15 +307,15 @@ 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
|
||||
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.
|
||||
@@ -335,16 +337,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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +270,7 @@ const SETTLE: Duration = Duration::from_secs(3);
|
||||
const LOOK_EVERY: Duration = Duration::from_millis(250);
|
||||
|
||||
/// What a blink needs from the machine, so its decisions can be tested.
|
||||
pub trait Logo {
|
||||
trait Logo {
|
||||
/// The current mode, None if it could not be read.
|
||||
fn mode(&self) -> Option<i64>;
|
||||
/// Set the mode; false if the daemon did not take it.
|
||||
@@ -281,7 +281,7 @@ pub trait Logo {
|
||||
}
|
||||
|
||||
/// The file that says a blink may have left the logo dark.
|
||||
pub struct Marker(Option<PathBuf>);
|
||||
struct Marker(Option<PathBuf>);
|
||||
|
||||
impl Marker {
|
||||
fn default_location() -> Self {
|
||||
@@ -363,7 +363,7 @@ fn blink(logo: &impl Logo, marker: &Marker, pattern: &[Beat], mut wait: impl FnM
|
||||
}
|
||||
|
||||
/// Put back a logo that a blink could not finish with.
|
||||
pub fn recover(logo: &impl Logo, marker: &Marker) {
|
||||
fn recover(logo: &impl Logo, marker: &Marker) {
|
||||
if !marker.is_set() {
|
||||
return;
|
||||
}
|
||||
@@ -507,25 +507,24 @@ fn spawn_child(connection: Connection) -> Option<(Connection, ChildStdin)> {
|
||||
Some((connection, stdin))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use system::{available, blink_main, recover_at_launch, switched_off};
|
||||
|
||||
/// No lit logo to blink outside macOS.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn available() -> bool {
|
||||
false
|
||||
}
|
||||
mod system {
|
||||
pub fn available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn blink_main() -> ! {
|
||||
std::process::exit(0)
|
||||
}
|
||||
pub fn blink_main() -> ! {
|
||||
std::process::exit(0)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn recover_at_launch() {}
|
||||
pub fn recover_at_launch() {}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn switched_off() -> bool {
|
||||
false
|
||||
pub fn switched_off() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
+196
-636
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
//! 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 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,
|
||||
/// One item per state and style. Empty on a Mac without a lit logo, which has no
|
||||
/// such menu.
|
||||
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 this menu at all.
|
||||
let mut logo_styles = Vec::new();
|
||||
if logo::available() {
|
||||
let submenu = Submenu::new("Blink Apple Logo", 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(&submenu, 4)?;
|
||||
}
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,9 @@
|
||||
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
|
||||
* so a bad row fails the gate and never reaches the route.
|
||||
*
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-08). [V] read
|
||||
* at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-08; the two rows
|
||||
* added on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m`, were read that day).
|
||||
* [V] read at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
*
|
||||
* | Figure | Source | Checked |
|
||||
* |----------------------|-----------------------------------------------------------|--------------------------------------|
|
||||
@@ -74,8 +75,10 @@
|
||||
* src/local-model/runtime.ts, q8_0: 34 bytes per 32 values), and does not grow after that.
|
||||
* "MTP" below is a GGUF with a multi-token-prediction layer (`nextn_predict_layers` 1 in
|
||||
* its header, read on 2026-11-08 from the first bytes of each model layer: the two `-mtp`
|
||||
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not). For
|
||||
* such a model the runtime always passes `--spec-type draft-mtp --spec-draft-n-max N`, N
|
||||
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not; read
|
||||
* the same way on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m` do not
|
||||
* either). For such a model the runtime always passes
|
||||
* `--spec-type draft-mtp --spec-draft-n-max N`, N
|
||||
* being the tag's own `draft_num_predict` (its params layer: 4 for
|
||||
* `qwen3.8:27b-mtp-q4_k_m`, 2 for `qwen3.6:35b-a3b-mtp-q4_k_m`) or the runtime's default
|
||||
* of 4 (`qwen3.8:27b-q8_0` sets none).
|
||||
@@ -83,9 +86,11 @@
|
||||
* adds its prediction layer, whose cache llama-server keeps in a draft context of the
|
||||
* same length in the draft's own cache type, f16 by default (2 bytes per value).
|
||||
* - overhead = the fixed caches (the f32 recurrent state of the linear-attention layers,
|
||||
* or the sliding-window cache of `n_swa + 512` cells), held 1 + N times for an MTP
|
||||
* model (llama.cpp keeps one recurrent state per drafted token for rollback,
|
||||
* `need_n_rs_seq()`), + the context checkpoints: `LLAMA_ARG_CTX_CHECKPOINTS` host copies
|
||||
* or the sliding-window cache of `n_swa + 512` cells: `size_swa` in
|
||||
* src/llama-kv-cache-iswa.cpp, the cache every model with a sliding window gets), held
|
||||
* 1 + N times for an MTP model (llama.cpp keeps one recurrent state per drafted token
|
||||
* for rollback, `need_n_rs_seq()`), + the context checkpoints:
|
||||
* `LLAMA_ARG_CTX_CHECKPOINTS` host copies
|
||||
* (`RUNTIME_CTX_CHECKPOINTS`, 8) of the state that cannot be rolled back (the recurrent
|
||||
* state, or the sliding-window cache), + 1.0 GB for the runner's own buffers (calibrated
|
||||
* on a third-party table of a 27B model on llama.cpp: 8.4 GB at 4,096 tokens with
|
||||
@@ -125,26 +130,59 @@
|
||||
* checked. For an `mlx` row it proves only the weights (issue 19417): the figures above are
|
||||
* all that stands between such a row and swap.
|
||||
*
|
||||
* Models set aside on purpose (a weekly review should look at them again):
|
||||
* Models set aside on purpose (a weekly review should look at them again). Every reason was
|
||||
* read again at its source on 2026-11-10:
|
||||
* - `ripple-preview:65b` (Ricursive, Apache-2.0, a dense 65B published on 2026-11-09;
|
||||
* Terminal-Bench 2.1 80.5 and SWE-bench Verified 76.5 on its card; 38.8 GB as a GGUF build,
|
||||
* 45.8 GB as the MLX build a Mac pulls): every build requires runtime 0.40.16, one above
|
||||
* the pin. Computed from its config.json by the llamacpp rule, its GGUF build would be the
|
||||
* first row between the 27B and the 122B (47 GB at 131,072 tokens: 64 and 96 GB Macs, slow
|
||||
* below an Ultra chip). Look at it the day the pin moves.
|
||||
* - `bonsai-2:27b`, `bonsai-2:9b` (ternary builds of Qwen3.8-27B and Qwen3.5-9B; the 27B
|
||||
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop, and
|
||||
* whether 2-bit weights keep tool calls reliable was never measured.
|
||||
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop (its card
|
||||
* of 2026-11-04 still lists BFCL v3 only), and whether 2-bit weights keep tool calls
|
||||
* reliable was never measured.
|
||||
* - `ornith-1.5:9b`, `ornith-1.5:35b`, `ornith-1.5:397b` (GGUF only): strong cards, but their
|
||||
* manifests name no renderer and no tool parser, so whether tool calls work through the
|
||||
* runtime is unknown (runtime pull request 17965 is open). `ornith-1.5:122b` has both.
|
||||
* runtime is unknown (runtime pull request 17965 is still open). `ornith-1.5:122b` has
|
||||
* both.
|
||||
* - `ornith:35b` (Ornith 1.0 35B-A3B, MIT, with a renderer and a tool parser; SWE-bench
|
||||
* Verified 75.6 against 73.4 for Qwen3.6 35B-A3B on Ornith's card): runtime issue 16992,
|
||||
* open, reports its tool calls arriving as text in long agent chains, and its size is
|
||||
* served by the pair we ran. Its 9B sibling `ornith:9b-q4_k_m` is listed: see that row
|
||||
* for why.
|
||||
* - `gpt-oss:20b`: weak in agentic coding (SWE-bench Verified 52.4, Terminal-Bench 2.1 15.2
|
||||
* in NVIDIA's table).
|
||||
* - `flint:40b`: runtime issue 19191 breaks integer tool arguments after the first call.
|
||||
* - `flint:40b`: runtime issue 19191 (open) breaks integer tool arguments after the first
|
||||
* call.
|
||||
* - `lfm2.5:24b-a2b`: its licence allows commercial use only below USD 10M of revenue.
|
||||
* - `laguna-xs-2.1`: the runtime's own page names an open problem with it on macOS.
|
||||
* `laguna-s-2.1`: open runtime issues 17602 and 17632.
|
||||
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence.
|
||||
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence, and its
|
||||
* builds require runtime 0.40.16 since 2026-11-09.
|
||||
* - `nemotron-3.5-super:120b` (79.6 GB as the MLX build a Mac pulls): Terminal-Bench 2.1
|
||||
* 60.7 and SWE-bench Verified 71.1 in NVIDIA's table, below `ornith-1.5:122b` at a larger
|
||||
* download, so it adds no choice a 128 GB Mac would take.
|
||||
* - `qwen3.5:4b-q4_k_m` (3.3 GB, it would fit a 16 GB Mac with room): runtime issue 18916,
|
||||
* open, has it answer with thinking only in a long conversation at 65,536 tokens.
|
||||
* - `qwen3-coder-next` (51.7 GB): the generation before Qwen3.6. Its card shows its results
|
||||
* as pictures only, so there is no figure to rank it by against the rows listed here.
|
||||
* - `muse-glimmer:30b-mlx` and the `-dflash` tags of that model: open runtime issues 17656
|
||||
* and 18269 on the MLX build, and no rule here covers the memory of a separate draft
|
||||
* model. Its GGUF build `muse-glimmer:30b-q4_k_m` is listed.
|
||||
* - The plain tags `qwen3.5:9b`, `mimo-v2.6:9b` and `qwen3.8:27b`: a Mac pulls their MLX
|
||||
* builds, whose 8 GiB of prefix snapshots do not fit the Macs a 9B or a 27B is for. Their
|
||||
* GGUF builds are listed instead.
|
||||
* - `qwen3.5:122b-a10b`: replaced by `ornith-1.5:122b`, a fine-tune of it (SWE-bench
|
||||
* Verified 84.5 against 72.0) whose MLX build carries a renderer and a tool parser.
|
||||
*
|
||||
* One open issue touches rows that ARE listed: runtime issue 16992 (above). Its one diagnosed
|
||||
* cause sits in the tool parser itself (it closes a call at the first closing tag, so a tool
|
||||
* argument that quotes a whole tool call is cut off; the fix, runtime pull request 17021, is
|
||||
* open). The names `ornith` and `qwen3.5` map to that one parser in the pinned runtime
|
||||
* (model/parsers/parsers.go), so every Qwen-family row here shares it, the pair we ran
|
||||
* included. The check after a download asks for one plain tool call and cannot see this.
|
||||
*
|
||||
* `tested` has one entry: `qwen3.6:35b-a3b` with OpenCode on an Apple M5 Pro with 48 GB,
|
||||
* 2026-11-06, 67.7 tokens per second. Only a Mac of exactly that chip and memory size is told
|
||||
* "Recommended" and shown that speed in place of the estimate. Every other row was never run
|
||||
@@ -336,7 +374,7 @@ const MLX_PREFIX_CACHE_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
*/
|
||||
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-08',
|
||||
updated: '2026-11-10',
|
||||
// The oldest macOS the model runtime supports.
|
||||
minMacosMajor: 14,
|
||||
// Ordered by rank. The higher rank wins among models that fit and whose speed is graded
|
||||
@@ -344,7 +382,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
// that are left out and why.
|
||||
models: [
|
||||
{
|
||||
// Small Macs (16 to 32 GB, `limited`): the base model of the row below.
|
||||
// Small Macs (18 to 32 GB): the base model of the two rows below.
|
||||
id: 'qwen3.5:9b-q4_k_m',
|
||||
label: 'Qwen3.5 9B',
|
||||
license: 'Apache-2.0',
|
||||
@@ -365,7 +403,35 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Small Macs (16 to 32 GB, `limited`). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
|
||||
// The model a 16 GB Mac can hold: 9.3 GB at 65,536 tokens, tight. Ornith 1.0 9B, a
|
||||
// fine-tune of Qwen3.5-9B for coding agents, as a GGUF build without a vision projector,
|
||||
// so 0.9 GB smaller than the rows around it. SWE-bench Verified 69.4, SWE-bench Pro 42.9
|
||||
// and Terminal-Bench 2.1 43.1, against 53.2, 31.3 and 21.3 for its base (Ornith's card,
|
||||
// which puts that base 7 points under Xiaomi's figure for it: the two cards do not
|
||||
// compare). Ranked under MiMo for that, and because runtime issue 16992 was filed on its
|
||||
// 35B sibling, which has the same renderer and parser (see the header): it is the pick
|
||||
// only where nothing else fits. Text only.
|
||||
id: 'ornith:9b-q4_k_m',
|
||||
label: 'Ornith 1.0 9B',
|
||||
license: 'MIT',
|
||||
paramsB: 9,
|
||||
runner: 'llamacpp',
|
||||
bytes: 5_629_110_568,
|
||||
// The architecture of the row above (its config.json and its GGUF header say so), so
|
||||
// its figures.
|
||||
heldBytesPerToken: 17_408,
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above, the same architecture and the same weights file
|
||||
// size (5.63 GB without the projector).
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Small Macs (18 to 32 GB). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
|
||||
// Qwen3.5-9B for code and agents: SWE-bench Verified 61.1 and SWE-bench Pro 44.6,
|
||||
// against 60.0 and 32.0 for its base (Xiaomi's card). Requires exactly the pinned
|
||||
// runtime, 0.40.15.
|
||||
@@ -375,14 +441,14 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
paramsB: 9,
|
||||
runner: 'llamacpp',
|
||||
bytes: 6_550_811_033,
|
||||
// The architecture and the quantization of the row above, so its figures.
|
||||
// The architecture and the quantization of the base model's row, so its figures.
|
||||
heldBytesPerToken: 17_408,
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above, the same architecture and file size.
|
||||
rank: 3,
|
||||
// Not measured: the 9.3 of the base model's row, the same architecture and file size.
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
@@ -404,12 +470,39 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 3,
|
||||
rank: 4,
|
||||
// Not measured: the 13.6 measured for Qwen3.6 35B-A3B on llama.cpp, which has the same
|
||||
// 3 billion active parameters.
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Meta's 30B for local agents, a dense 27.9B language model with a vision encoder:
|
||||
// SWE-bench Verified 76.0, SWE-bench Pro 51.2, Terminal-Bench 2.1 51.7 (Meta's card). Its
|
||||
// K/V cache is the smallest of the 27B class (2 K/V heads of 128, and three layers in
|
||||
// four look at a window of 2,048 tokens), so it is the first model of that class a 32 GB
|
||||
// Mac can hold: 21.2 GB at 65,536 tokens, tight. Dense, so it is slow below a Max chip.
|
||||
// Its own window ends at 131,072 tokens.
|
||||
id: 'muse-glimmer:30b-q4_k_m',
|
||||
label: 'Muse Glimmer 30B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 29.6,
|
||||
runner: 'llamacpp',
|
||||
bytes: 18_157_010_252,
|
||||
// 13 full-attention layers x 2 K/V heads x 128 x 2 x 34/32.
|
||||
heldBytesPerToken: 7_072,
|
||||
// 39 sliding-window layers: (2,048 + 512) cells x 2 x 128 x 2 x 34/32 = 54,312,960, live
|
||||
// and in each context checkpoint.
|
||||
overheadBytes: 54_312_960 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 5,
|
||||
// Not measured: the 2.95 of Qwen3.8 27B, a dense model whose weights are the same size on
|
||||
// llama.cpp (16.8 GB each without the projector).
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Macs of 36 GB on a slower chip: the model of the tested pair, as a GGUF build whose
|
||||
// memory does not grow after it loaded. With its multi-token-prediction layer.
|
||||
@@ -430,7 +523,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 4,
|
||||
rank: 6,
|
||||
// Measured: 54.5 tokens per second on 400 GB/s (llama.cpp, without prediction).
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
@@ -448,7 +541,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'measured',
|
||||
contexts: [131072, 65536],
|
||||
rank: 5,
|
||||
rank: 7,
|
||||
// Measured: 67.8 tokens per second on 400 GB/s with MLX. The one Mac we tested ran it at
|
||||
// 67.7 on 307 GB/s, so on other chips the estimate is on the low side.
|
||||
speedPer100Gbps: 16.9,
|
||||
@@ -473,7 +566,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 6,
|
||||
rank: 8,
|
||||
// Not measured: the 2.95 of the row below scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
@@ -497,7 +590,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 7,
|
||||
rank: 9,
|
||||
// Measured: 11.8 tokens per second on 400 GB/s (llama.cpp, Q4_K_M, without prediction).
|
||||
// This build predicts several tokens at a time, so it is faster than this figure says.
|
||||
speedPer100Gbps: 2.95,
|
||||
@@ -521,7 +614,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: MEASURED_MEMORY.peakExtraBytes,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072],
|
||||
rank: 8,
|
||||
rank: 10,
|
||||
// Not measured: the 16.9 of Qwen3.6 35B-A3B on MLX x 3/10, its active parameters
|
||||
// against these, rounded down.
|
||||
speedPer100Gbps: 5,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,10 @@
|
||||
* gated, installed or installable, and accepting the model id.
|
||||
* - Ranking, lexicographic: tested on a Mac like this before not tested (a measurement beats
|
||||
* a formula); ok before unknown before slow; the higher model rank; the higher harness
|
||||
* rank; the larger context. The pick is the first candidate, the alternatives are the next
|
||||
* four MODELS, each at its own best context and harness, so a model is always offered at
|
||||
* the largest context it fits at.
|
||||
* rank; the larger context. The pick is the first candidate, the alternatives are EVERY
|
||||
* other model that fits (`MAX_ALTERNATIVES` is above what the catalogue holds), each at its
|
||||
* own best context and harness, so a model is always offered at the largest context it
|
||||
* fits at.
|
||||
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
|
||||
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
|
||||
* with what is missing.
|
||||
@@ -109,8 +110,13 @@ export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
|
||||
export const LIMITED_BELOW_PARAMS_B = 12;
|
||||
/** What must stay free on the disk after the model download. */
|
||||
export const DISK_HEADROOM_BYTES = 10 * GIB;
|
||||
/** How many other models are listed under the pick. */
|
||||
export const MAX_ALTERNATIVES = 4;
|
||||
/**
|
||||
* How many other models are listed under the pick, at most. Above what the stock catalogue
|
||||
* holds (10 models: 9 beside the pick), so a Mac is shown every model that fits it and the
|
||||
* install accepts every one of them. Still a bound: test/local-model-catalog.test.ts fails for
|
||||
* a catalogue that outgrows it, so nothing drops out of the list unseen.
|
||||
*/
|
||||
export const MAX_ALTERNATIVES = 11;
|
||||
|
||||
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
|
||||
const LARGE_LIMIT_FROM_GB = 36;
|
||||
|
||||
@@ -577,10 +577,10 @@ export interface Recommendation {
|
||||
/** Present exactly when `verdict` is `ok` or `limited`. */
|
||||
pick?: Candidate;
|
||||
/**
|
||||
* The next models that fit and have room on the disk (at most four), best first: tested
|
||||
* before not tested, then ok before unknown before slow speed, then the higher model rank.
|
||||
* Each is at the largest context it fits at, with the best harness that can run it there.
|
||||
* Empty when `verdict` is `none`.
|
||||
* Every other model that fits and has room on the disk (`MAX_ALTERNATIVES` is above what the
|
||||
* catalogue holds), best first: tested before not tested, then ok before unknown before
|
||||
* slow speed, then the higher model rank. Each is at the largest context it fits at, with
|
||||
* the best harness that can run it there. Empty when `verdict` is `none`.
|
||||
*/
|
||||
alternatives: Candidate[];
|
||||
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
LocalModelCatalogSchema,
|
||||
STOCK_LOCAL_MODEL_CATALOG,
|
||||
} from '../src/config/local-model-catalog.js';
|
||||
import { MAX_ALTERNATIVES } from '../src/local-model/recommend.js';
|
||||
import {
|
||||
RUNTIME_CTX_CHECKPOINTS,
|
||||
RUNTIME_HOST_PROMPT_CACHE_MIB,
|
||||
@@ -63,9 +64,11 @@ const GIB = 1024 * MIB;
|
||||
|
||||
/**
|
||||
* The architecture of every model the stock catalogue lists, read from its config.json on
|
||||
* Hugging Face (2026-11-08): full-attention layers, their K/V heads and head size, and the
|
||||
* layers whose cache does not grow with the context (Gated DeltaNet layers with their value
|
||||
* and key heads, or sliding-window layers with their window). Fine-tunes share their base's.
|
||||
* Hugging Face (2026-11-08; Muse Glimmer on 2026-11-10): full-attention layers, their K/V heads
|
||||
* and head size, and the layers whose cache does not grow with the context (Gated DeltaNet
|
||||
* layers with their value and key heads, or sliding-window layers with their window).
|
||||
* Fine-tunes share their base's: the config.json of Ornith 1.0 9B (2026-11-10) is that of
|
||||
* Qwen3.5-9B in every field below, and the GGUF header of its build says the same.
|
||||
*/
|
||||
const ARCHITECTURES = {
|
||||
'Qwen3.5-9B': { attentionLayers: 8, kvHeads: 4, headDim: 256, linear: { layers: 24, vHeads: 32, kHeads: 16 } },
|
||||
@@ -78,6 +81,8 @@ const ARCHITECTURES = {
|
||||
linear: { layers: 36, vHeads: 64, kHeads: 16 },
|
||||
},
|
||||
'North-Mini-Code-1.0': { attentionLayers: 13, kvHeads: 4, headDim: 128, sliding: { layers: 36, window: 4096 } },
|
||||
// 52 layers, three in four on a sliding window (the GGUF header's `sliding_window_pattern`).
|
||||
'Muse-Glimmer-30B': { attentionLayers: 13, kvHeads: 2, headDim: 128, sliding: { layers: 39, window: 2048 } },
|
||||
} as const;
|
||||
|
||||
type ArchitectureName = keyof typeof ARCHITECTURES;
|
||||
@@ -86,7 +91,8 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
|
||||
/**
|
||||
* Which architecture each stock row runs, and how many tokens the runtime drafts per step with
|
||||
* its multi-token-prediction layer: 0 when the GGUF has none (`nextn_predict_layers` in the GGUF
|
||||
* header, read on 2026-11-08 from the first bytes of each model layer), else the tag's own
|
||||
* header, read on 2026-11-08 from the first bytes of each model layer; on 2026-11-10 for the
|
||||
* Ornith 1.0 and Muse Glimmer rows), else the tag's own
|
||||
* `draft_num_predict` (its params layer) or the runtime's default of 4 when the tag sets none.
|
||||
* Prediction costs a cache for that layer and one rollback copy of the recurrent state per
|
||||
* drafted token (see the catalogue header). A row the catalogue gains must be added here, or the
|
||||
@@ -94,8 +100,12 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
|
||||
*/
|
||||
const ROW_ARCHITECTURE: Record<string, { architecture: ArchitectureName; draftTokens: number }> = {
|
||||
'qwen3.5:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
// A fine-tune of Qwen3.5-9B; its GGUF has 32 blocks and no prediction layer.
|
||||
'ornith:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
'mimo-v2.6:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
'north-mini-code-1.0:q4_k_m': { architecture: 'North-Mini-Code-1.0', draftTokens: 0 },
|
||||
// No prediction layer; the tags with a separate draft model (`-dflash`) are not listed.
|
||||
'muse-glimmer:30b-q4_k_m': { architecture: 'Muse-Glimmer-30B', draftTokens: 0 },
|
||||
// Its params layer sets `draft_num_predict` 2.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 2 },
|
||||
'qwen3.6:35b-a3b': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 0 },
|
||||
@@ -183,20 +193,22 @@ describe('the stock catalogue', () => {
|
||||
expect(getLocalModelCatalog()).toBe(first);
|
||||
});
|
||||
|
||||
it('lists eight models, by rank, and none of those set aside in the header', () => {
|
||||
it('lists ten models, by rank, and none of those set aside in the header', () => {
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b-q4_k_m',
|
||||
'ornith:9b-q4_k_m',
|
||||
'mimo-v2.6:9b-q4_k_m',
|
||||
'north-mini-code-1.0:q4_k_m',
|
||||
'muse-glimmer:30b-q4_k_m',
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m',
|
||||
'qwen3.6:35b-a3b',
|
||||
'qwen3.8:27b-q8_0',
|
||||
'qwen3.8:27b-mtp-q4_k_m',
|
||||
'ornith-1.5:122b',
|
||||
]);
|
||||
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
const setAside =
|
||||
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:122b)|^ornith-1\.5:(9b|35b|397b)/;
|
||||
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:(122b|4b)|ripple-preview|nemotron-3\.5-super|qwen3-coder-next)|^ornith-1\.5:(9b|35b|397b)|^ornith:35b|^muse-glimmer:.*(mlx|dflash)/;
|
||||
expect(models.filter((m) => setAside.test(m.id)).map((m) => m.id)).toEqual([]);
|
||||
// The plain 9B and 27B tags are MLX builds on a Mac: their GGUF builds are listed instead.
|
||||
for (const plain of ['qwen3.5:9b', 'mimo-v2.6:9b', 'qwen3.8:27b']) {
|
||||
@@ -207,8 +219,10 @@ describe('the stock catalogue', () => {
|
||||
it('names the runner a Mac uses for each tag', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.runner]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': 'llamacpp',
|
||||
'ornith:9b-q4_k_m': 'llamacpp',
|
||||
'mimo-v2.6:9b-q4_k_m': 'llamacpp',
|
||||
'north-mini-code-1.0:q4_k_m': 'llamacpp',
|
||||
'muse-glimmer:30b-q4_k_m': 'llamacpp',
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 'llamacpp',
|
||||
// Manifest lists: a Mac takes the MLX child.
|
||||
'qwen3.6:35b-a3b': 'mlx',
|
||||
@@ -222,8 +236,12 @@ describe('the stock catalogue', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.bytes]))).toEqual({
|
||||
// The registry's manifests as fetched on 2026-11-08, in lowercase.
|
||||
'qwen3.5:9b-q4_k_m': 6_550_825_550,
|
||||
// Fetched on 2026-11-10. No vision projector: the weights, a system prompt, the licence.
|
||||
'ornith:9b-q4_k_m': 5_629_110_568,
|
||||
'mimo-v2.6:9b-q4_k_m': 6_550_811_033,
|
||||
'north-mini-code-1.0:q4_k_m': 18_593_967_008,
|
||||
// Fetched on 2026-11-10: 16.76 GB of weights and a 1.40 GB projector.
|
||||
'muse-glimmer:30b-q4_k_m': 18_157_010_252,
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 22_621_314_381,
|
||||
// The MLX child of the list, and the total of a real pull on a Mac, byte for byte.
|
||||
'qwen3.6:35b-a3b': 23_105_798_956,
|
||||
@@ -254,8 +272,11 @@ describe('the stock catalogue', () => {
|
||||
it('gives every model a parameter count, an id of the default registry and a license', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, [m.paramsB, m.license]]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': [9, 'Apache-2.0'],
|
||||
'ornith:9b-q4_k_m': [9, 'MIT'],
|
||||
'mimo-v2.6:9b-q4_k_m': [9, 'MIT'],
|
||||
'north-mini-code-1.0:q4_k_m': [30, 'Apache-2.0'],
|
||||
// The card's total: a 27.9B language model and a vision encoder.
|
||||
'muse-glimmer:30b-q4_k_m': [29.6, 'Apache-2.0'],
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': [35, 'Apache-2.0'],
|
||||
'qwen3.6:35b-a3b': [35, 'Apache-2.0'],
|
||||
'qwen3.8:27b-q8_0': [27.8, 'Apache-2.0'],
|
||||
@@ -263,8 +284,12 @@ describe('the stock catalogue', () => {
|
||||
'ornith-1.5:122b': [122, 'MIT'],
|
||||
});
|
||||
for (const model of models) expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
|
||||
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
|
||||
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual(['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m']);
|
||||
// The three 9B rows are the ones under the 12 billion line of the `limited` verdict.
|
||||
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b-q4_k_m',
|
||||
'ornith:9b-q4_k_m',
|
||||
'mimo-v2.6:9b-q4_k_m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers no model below the 65536 tokens a harness needs, nor beyond its own window', () => {
|
||||
@@ -276,11 +301,14 @@ describe('the stock catalogue', () => {
|
||||
|
||||
it('has a speed figure only where one was measured or scaled from a measurement', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
|
||||
// 37.1 tokens per second on 400 GB/s, and the same for the fine-tune of the same size.
|
||||
// 37.1 tokens per second on 400 GB/s, and the same for the two fine-tunes of the same size.
|
||||
'qwen3.5:9b-q4_k_m': 9.3,
|
||||
'ornith:9b-q4_k_m': 9.3,
|
||||
'mimo-v2.6:9b-q4_k_m': 9.3,
|
||||
// The 35B-A3B's llama.cpp figure: both have 3 billion active parameters.
|
||||
'north-mini-code-1.0:q4_k_m': 13.6,
|
||||
// The dense 27B's figure: the weights of the two are the same size.
|
||||
'muse-glimmer:30b-q4_k_m': 2.95,
|
||||
// 54.5 on 400 GB/s with llama.cpp.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 13.6,
|
||||
// 67.8 on 400 GB/s with MLX.
|
||||
@@ -302,6 +330,13 @@ describe('the stock catalogue', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('holds no more models than one window lists: the pick and MAX_ALTERNATIVES others', () => {
|
||||
// recommend() cuts the list at MAX_ALTERNATIVES. While the catalogue stays under it, a Mac
|
||||
// is shown every model that fits and the install accepts every one of them. A catalogue
|
||||
// that outgrows the bound must fail here, so the bound is raised (or kept) on purpose.
|
||||
expect(models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
|
||||
});
|
||||
|
||||
it('names the oldest macOS and its schema version', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1);
|
||||
@@ -348,9 +383,12 @@ describe('the memory figures of the stock rows: each row has its own', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, figuresOf(m)]))).toEqual({
|
||||
// State x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
|
||||
'qwen3.5:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
'ornith:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
'mimo-v2.6:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
// The sliding window x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
|
||||
'north-mini-code-1.0:q4_k_m': [14_144, 3_698_117_120, 0],
|
||||
// The same rule: 39 layers x 2,560 cells x 544 bytes = 54,312,960 per copy.
|
||||
'muse-glimmer:30b-q4_k_m': [7_072, 2_562_558_464, 0],
|
||||
// State x (1 live + 2 rollback copies + 8 checkpoints) + 1.0 GB + 1 GiB; the prediction
|
||||
// layer's cache in f16 per token. The 27B rows below draft 4 tokens: 4 rollback copies.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': [12_928, 2_798_242_304, 0],
|
||||
@@ -374,9 +412,9 @@ describe('the memory figures of the stock rows: each row has its own', () => {
|
||||
byFigures.set(key, [...(byFigures.get(key) ?? []), model]);
|
||||
}
|
||||
const shared = [...byFigures.values()].filter((group) => group.length > 1);
|
||||
// The two 9B rows (a fine-tune and its base) and the two 27B rows (two quantizations of one model).
|
||||
// The three 9B rows (a base and two fine-tunes of it) and the two 27B rows (two quantizations of one model).
|
||||
expect(shared.map((group) => group.map((m) => m.id))).toEqual([
|
||||
['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
|
||||
['qwen3.5:9b-q4_k_m', 'ornith:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
|
||||
['qwen3.8:27b-q8_0', 'qwen3.8:27b-mtp-q4_k_m'],
|
||||
]);
|
||||
for (const group of shared) {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getLocalModelCatalog } from '../src/config/local-model-catalog.js';
|
||||
import { getLocalModelCatalog, LocalModelCatalogSchema } from '../src/config/local-model-catalog.js';
|
||||
import { formatGb, memClassGb } from '../src/local-model/format.js';
|
||||
import { modelFitsBudget, recommend } from '../src/local-model/recommend.js';
|
||||
import { MAX_ALTERNATIVES, modelFitsBudget, recommend } from '../src/local-model/recommend.js';
|
||||
import type { HardwareFacts, HarnessFact } from '../src/types.js';
|
||||
|
||||
/** A Mac with Apple silicon and `gb` GB of memory as sold. */
|
||||
@@ -72,18 +72,46 @@ describe('modelFitsBudget', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not depend on the ranking: a model that fits and is listed nowhere still fits', () => {
|
||||
// On a 128 GB Mac more models fit than the recommendation lists.
|
||||
const hw = mac(128, 'Apple M5 Max');
|
||||
it('does not depend on what the recommendation lists: a model left out for disk space still fits', () => {
|
||||
// The recommendation lists every model that fits the memory, unless its download would
|
||||
// leave under 10 GiB on the disk. With 40 GB free on a 128 GB Mac that leaves out the two
|
||||
// largest downloads, which fit the memory all the same.
|
||||
const hw = { ...mac(128, 'Apple M5 Max'), freeDiskBytes: 40_000_000_000 };
|
||||
const offered = recommend(hw, catalog, harnessFacts());
|
||||
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
|
||||
const unlisted = catalog.models.filter((model) => !listed.has(model.id));
|
||||
expect(unlisted.length).toBeGreaterThan(0);
|
||||
expect(unlisted.map((model) => model.id)).toEqual(['qwen3.8:27b-q8_0', 'ornith-1.5:122b']);
|
||||
for (const model of unlisted) {
|
||||
expect(modelFitsBudget(hw, catalog, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not depend on the ranking: a model cut off by the length of the list still fits', () => {
|
||||
// A catalogue longer than the list: the stock rows plus six more copies of its smallest.
|
||||
const smallest = catalog.models[0];
|
||||
const long = LocalModelCatalogSchema.parse({
|
||||
...structuredClone(catalog),
|
||||
models: [
|
||||
...structuredClone(catalog.models),
|
||||
...Array.from({ length: 6 }, (_, index) => ({
|
||||
...structuredClone(smallest),
|
||||
id: `extra-${index + 1}:9b`,
|
||||
rank: -1 - index,
|
||||
})),
|
||||
],
|
||||
});
|
||||
expect(long.models.length).toBeGreaterThan(1 + MAX_ALTERNATIVES);
|
||||
const hw = mac(512, 'Apple M5 Max');
|
||||
const offered = recommend(hw, long, harnessFacts());
|
||||
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
|
||||
expect(listed.size).toBe(1 + MAX_ALTERNATIVES);
|
||||
const unlisted = long.models.filter((model) => !listed.has(model.id));
|
||||
expect(unlisted.length).toBe(long.models.length - 1 - MAX_ALTERNATIVES);
|
||||
for (const model of unlisted) {
|
||||
expect(modelFitsBudget(hw, long, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rules out a model that is too large, and one the catalogue does not have', () => {
|
||||
expect(modelFitsBudget(mac(48), catalog, 'ornith-1.5:122b', 131072)).toBe(false);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -487,8 +487,12 @@ describe('needBytes', () => {
|
||||
it('states the need of every stock row at its largest context', () => {
|
||||
expect(Object.fromEntries(STOCK.models.map((m) => [m.id, needBytes(m, m.contexts[0])]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': 11_380_487_246,
|
||||
// 0.92 GB under the other two 9B builds: it has no vision projector.
|
||||
'ornith:9b-q4_k_m': 10_458_772_264,
|
||||
'mimo-v2.6:9b-q4_k_m': 11_380_472_729,
|
||||
'north-mini-code-1.0:q4_k_m': 25_999_848_864,
|
||||
// At its own window of 131072 tokens: 18.2 GB + 0.9 GB of K/V cache + 2.6 GB.
|
||||
'muse-glimmer:30b-q4_k_m': 21_646_509_900,
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 28_808_554_317,
|
||||
'qwen3.6:35b-a3b': 35_955_462_956,
|
||||
'qwen3.8:27b-q8_0': 44_292_142_594,
|
||||
@@ -544,6 +548,9 @@ describe('the fit grade', () => {
|
||||
// ---------- 2. The two real Macs ----------
|
||||
|
||||
describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
/** The same Mac without the probe: 48 GiB and the chip are all the fit and the speed depend on. */
|
||||
const hw48 = mac(48, 'Apple M5 Pro');
|
||||
|
||||
/**
|
||||
* Expected: Qwen3.6 35B-A3B at 131,072 tokens with OpenCode, a TIGHT fit, 67.7 tokens per
|
||||
* second, "Recommended for this Mac".
|
||||
@@ -642,7 +649,7 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
expect(result.notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists four other models that fit, each at the largest context it fits at, the fast ones first', async () => {
|
||||
it('lists every other model that fits, each at the largest context it fits at, the fast ones first', async () => {
|
||||
const result = recommend(await macbook(), STOCK, ALL_INSTALLED);
|
||||
expect(
|
||||
result.alternatives.map((c) => [c.modelId, c.context, c.fit, c.speed, c.tokensPerSecond ?? null, c.tag?.kind])
|
||||
@@ -651,16 +658,21 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
['qwen3.6:35b-a3b-mtp-q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
|
||||
['north-mini-code-1.0:q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
|
||||
['mimo-v2.6:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
|
||||
['ornith:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
|
||||
['qwen3.5:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
|
||||
// The dense models compute to 2.95 x 3.07 = 9.1 tokens per second on this chip (5.2 in
|
||||
// 8 bits): slow, so they come after every fast model, in the order of their rank.
|
||||
['qwen3.8:27b-mtp-q4_k_m', 262144, 'tight', 'slow', 9.1, 'tight'],
|
||||
['qwen3.8:27b-q8_0', 65536, 'tight', 'slow', 5.2, 'tight'],
|
||||
['muse-glimmer:30b-q4_k_m', 131072, 'good', 'slow', 9.1, 'slow'],
|
||||
]);
|
||||
// Only the tested row rests on a measurement of that model.
|
||||
expect(result.alternatives.map((c) => c.needSource)).toEqual(['estimated', 'estimated', 'estimated', 'estimated']);
|
||||
// The dense 27B fits too (32.1 GB at 262144) but computes to 2.95 x 3.07 = 9.1 tokens per
|
||||
// second here: slow, so it ranks after four fast models and is not listed.
|
||||
expect(modelFitsBudget(await macbook(), STOCK, 'qwen3.8:27b-mtp-q4_k_m', 262144)).toBe(true);
|
||||
expect(order(result).some((entry) => entry.startsWith('qwen3.8:27b'))).toBe(false);
|
||||
expect(new Set(result.alternatives.map((c) => c.needSource))).toEqual(new Set(['estimated']));
|
||||
// Every model of the catalogue that fits is on the list: nothing is cut off after four.
|
||||
const fitting = STOCK.models.filter((m) => m.contexts.some((ctx) => modelFitsBudget(hw48, STOCK, m.id, ctx)));
|
||||
expect(rows(result).map((c) => c.modelId).sort()).toEqual(fitting.map((m) => m.id).sort());
|
||||
// The 122B fits at no context.
|
||||
expect(order(result).some((entry) => entry.startsWith('ornith'))).toBe(false);
|
||||
expect(order(result).some((entry) => entry.startsWith('ornith-1.5'))).toBe(false);
|
||||
});
|
||||
|
||||
it('would get the same model as "Best fit", with the estimated speed, had nobody tested it', async () => {
|
||||
@@ -692,41 +704,68 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
['qwen3.8:27b-mtp-q4_k_m', 'unknown'],
|
||||
['qwen3.8:27b-q8_0', 'unknown'],
|
||||
['qwen3.6:35b-a3b-mtp-q4_k_m', 'unknown'],
|
||||
['muse-glimmer:30b-q4_k_m', 'unknown'],
|
||||
['north-mini-code-1.0:q4_k_m', 'unknown'],
|
||||
['mimo-v2.6:9b-q4_k_m', 'unknown'],
|
||||
['ornith:9b-q4_k_m', 'unknown'],
|
||||
['qwen3.5:9b-q4_k_m', 'unknown'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
/**
|
||||
* Expected: verdict `none` (reason `memory`) on the estimated budget, and `limited` with
|
||||
* MiMo V2.6 9B (a GGUF build) at 65,536 tokens, a TIGHT fit, once the runtime measured its
|
||||
* budget.
|
||||
* Expected: `limited` with Ornith 1.0 9B at 65,536 tokens, a TIGHT fit, on the estimated
|
||||
* budget, and MiMo V2.6 9B at 65,536 tokens, also tight, once the runtime measured its budget.
|
||||
*
|
||||
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). The 9B on
|
||||
* llama.cpp needs 6.55 GB of weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens +
|
||||
* 2.55 GB beside them (its recurrent state live and in 8 context checkpoints, the runner, and
|
||||
* the 1 GiB prompt cache the runtime allows) = 10.2 GB: 0.987 of the estimate, just past the
|
||||
* 0.98 a tight fit may reach. The runtime measured 11.8 GiB on this Mac; the 6 GiB reserve
|
||||
* leaves 10 GiB (10.7 GB) of it, and the same 10.2 GB is 0.954 of that.
|
||||
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). A 9B on llama.cpp
|
||||
* needs its weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens + 2.55 GB beside them (its
|
||||
* recurrent state live and in 8 context checkpoints, the runner, and the 1 GiB prompt cache
|
||||
* the runtime allows). MiMo and its base are 6.55 GB (a vision projector of 0.92 GB
|
||||
* included): 10.2 GB, 0.987 of the estimate, just past the 0.98 a tight fit may reach. The
|
||||
* Ornith build has no projector, 5.63 GB: 9.3 GB, 0.898 of the estimate. The runtime measured
|
||||
* 11.8 GiB on this Mac; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it, which admits all
|
||||
* three, and the rank puts MiMo first.
|
||||
*
|
||||
* Every figure of this row is computed from the model's architecture, none was measured
|
||||
* (`needSource: 'estimated'`), so this Mac is on the edge either way. Only a run of the 9B on
|
||||
* it settles which side.
|
||||
* Every figure of these rows is computed from the model's architecture, none was measured
|
||||
* (`needSource: 'estimated'`), and no 9B was ever run on such a Mac by us.
|
||||
*/
|
||||
it('gets no model on the estimated budget: the 9B needs 0.987 of it', async () => {
|
||||
it('gets Ornith 1.0 9B at 65536 tokens on the estimated budget, tight: the other 9B builds need 0.987 of it', async () => {
|
||||
const hw = await macMini();
|
||||
expect(hw.chip).toBe('Apple M4');
|
||||
expect(hw.memClassGb).toBe(16);
|
||||
|
||||
const result = recommend(hw, STOCK, ALL_INSTALLED);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'none', reason: 'memory', budgetSource: 'estimated' });
|
||||
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '16' } });
|
||||
expect(result.pick).toBeUndefined();
|
||||
expect(result).toMatchObject({ verdict: 'limited', budgetSource: 'estimated' });
|
||||
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.headlineLimited });
|
||||
expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]);
|
||||
// floor(17,179,869,184 x 2/3) - 1 GiB.
|
||||
expect(result.budgetBytes).toBe(10_379_504_298);
|
||||
expect(result.budgetGb).toBe('10.4');
|
||||
expect(result.pick).toMatchObject({
|
||||
modelId: 'ornith:9b-q4_k_m',
|
||||
modelLabel: 'Ornith 1.0 9B',
|
||||
license: 'MIT',
|
||||
context: 65536,
|
||||
cli: 'opencode',
|
||||
// 5,629,110,568 + 17,408 x 65,536 + 2,547,960,320.
|
||||
needBytes: 9_317_921_576,
|
||||
needGb: '9.3',
|
||||
memory: { modelBytes: 8_177_070_888, contextBytes: 1_140_850_688, peakBytes: 0 },
|
||||
downloadGb: '5.6',
|
||||
budgetShare: 0.898,
|
||||
fit: 'tight',
|
||||
// 9.3 x 120 / 100.
|
||||
speed: 'ok',
|
||||
tokensPerSecond: 11.2,
|
||||
tested: false,
|
||||
needSource: 'estimated',
|
||||
tag: { kind: 'best-tight', label: 'Best fit, tight' },
|
||||
});
|
||||
// It is the only model that fits, and only at this context: at 131072 it needs 10.5 GB.
|
||||
expect(order(result)).toEqual(['ornith:9b-q4_k_m@65536']);
|
||||
expect(modelFitsBudget(hw, STOCK, 'ornith:9b-q4_k_m', 131072)).toBe(false);
|
||||
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
|
||||
// 6,550,811,033 + 17,408 x 65,536 + 2,547,960,320.
|
||||
expect(needBytes(nineB, 65536)).toBe(10_239_622_041);
|
||||
@@ -734,7 +773,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
expect(needBytes(nineB, 65536) / result.budgetBytes).toBeLessThan(0.99);
|
||||
});
|
||||
|
||||
it('would need 11.4 GB at 131072 tokens, more than the whole budget', async () => {
|
||||
it('would need 11.4 GB for MiMo at 131072 tokens, more than the whole budget', async () => {
|
||||
const hw = await macMini();
|
||||
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
|
||||
const budget = deviceBudget(hw).budgetBytes;
|
||||
@@ -744,7 +783,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
expect(modelFitsBudget(hw, STOCK, 'mimo-v2.6:9b-q4_k_m', 65536)).toBe(false);
|
||||
});
|
||||
|
||||
it('gets a small model with the budget its runtime measured: the 9B at 65536 tokens, tight', async () => {
|
||||
it('gets MiMo with the budget its runtime measured: at 65536 tokens, tight', async () => {
|
||||
// The runtime reported 11.8 GiB here; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it.
|
||||
const result = recommend(await macMini(), STOCK, ALL_INSTALLED, { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
|
||||
expect(result).toMatchObject({
|
||||
@@ -773,13 +812,20 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
needSource: 'estimated',
|
||||
tag: { kind: 'best-tight', label: 'Best fit, tight' },
|
||||
});
|
||||
// Its base model, the same figures, is the one alternative.
|
||||
expect(order(result)).toEqual(['mimo-v2.6:9b-q4_k_m@65536', 'qwen3.5:9b-q4_k_m@65536']);
|
||||
// The other two 9B builds follow by rank. The Ornith build is small enough for 131072
|
||||
// tokens here (10.5 GB, 0.974), and a model is listed at the largest context it fits at.
|
||||
expect(order(result)).toEqual([
|
||||
'mimo-v2.6:9b-q4_k_m@65536',
|
||||
'ornith:9b-q4_k_m@131072',
|
||||
'qwen3.5:9b-q4_k_m@65536',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gets the same with Claude Code as its only coding agent, which also needs 65536 tokens', async () => {
|
||||
const hw = await macMini();
|
||||
expect(recommend(hw, STOCK, [fact('claude')])).toMatchObject({ verdict: 'none', reason: 'memory' });
|
||||
const estimated = recommend(hw, STOCK, [fact('claude')]);
|
||||
expect(estimated.verdict).toBe('limited');
|
||||
expect(estimated.pick).toMatchObject({ modelId: 'ornith:9b-q4_k_m', context: 65536, cli: 'claude' });
|
||||
const measured = recommend(hw, STOCK, [fact('claude')], { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
|
||||
expect(measured.verdict).toBe('limited');
|
||||
expect(measured.pick).toMatchObject({ modelId: 'mimo-v2.6:9b-q4_k_m', context: 65536, cli: 'claude' });
|
||||
@@ -791,22 +837,26 @@ describe('every memory size, with the stock catalogue', () => {
|
||||
// size. A change of a catalogue row or of a threshold that moves one of these answers has to
|
||||
// change this table on purpose. docs/local-models.md has the same table for people.
|
||||
const EXPECTED: [soldGb: number, chip: string, answer: string][] = [
|
||||
// The smallest need at 65536 tokens is 10.2 GB; the budget is 2.1 GB.
|
||||
// The smallest need at 65536 tokens is 9.3 GB; the budget is 2.1 GB.
|
||||
[8, 'Apple M2', 'none memory'],
|
||||
// The 9B needs 10.2 GB at 65536 tokens, 0.987 of the 10.4 GB estimate: just out. A runtime
|
||||
// that measured the budget a real 16 GB Mac measured admits it (see the Mac mini above).
|
||||
[16, 'Apple M4', 'none memory'],
|
||||
[16, 'Apple M1', 'none memory'],
|
||||
// The 9B build without a vision projector needs 9.3 GB at 65536 tokens, 0.898 of the 10.4 GB
|
||||
// estimate. The other two need 10.2 GB, 0.987: just out, until a runtime measures what a
|
||||
// real 16 GB Mac measured (see the Mac mini above). On an M1 (68 GB/s) a 9B is slow: 6.3.
|
||||
[16, 'Apple M4', 'limited ornith:9b-q4_k_m@65536 tight ok'],
|
||||
[16, 'Apple M1', 'limited ornith:9b-q4_k_m@65536 tight slow'],
|
||||
// 11.4 GB at 131072 tokens, 0.964 of 11.8 GB, and nothing of 12 billion fits: `limited`.
|
||||
[18, 'Apple M3 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 tight ok'],
|
||||
// On a base chip with 100 GB/s the 9B computes to 9.3 tokens per second: slow.
|
||||
[24, 'Apple M3', 'limited mimo-v2.6:9b-q4_k_m@131072 good slow'],
|
||||
[24, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
[24, 'Apple M4 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
// North Mini Code needs 23.2 GB at 65536 tokens: 1.06 of the 21.8 GB estimate. A runtime
|
||||
// that measures about 3/4 of the memory admits it (see below).
|
||||
[32, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
[32, 'Apple M1 Max', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
// Muse Glimmer 30B fits at 65536 tokens: 21.2 GB, 0.970 of the 21.8 GB estimate. So a 32 GB
|
||||
// Mac is no small-model Mac (`ok`). The model is dense: on a base chip it computes to 3.5
|
||||
// tokens per second and the fast 9B stays the pick, on a Max chip (11.8) it is the pick.
|
||||
// North Mini Code needs 23.2 GB at 65536 tokens, 1.06 of the estimate: a runtime that
|
||||
// measures about 3/4 of the memory admits it (see below).
|
||||
[32, 'Apple M4', 'ok mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
[32, 'Apple M1 Max', 'ok muse-glimmer:30b-q4_k_m@65536 tight ok'],
|
||||
// A Max chip runs the dense 27B fast enough (12.1); an M3 Max (300 GB/s) does not (8.9),
|
||||
// and takes the 35B-A3B as a GGUF build, whose memory does not grow after it loaded. Both
|
||||
// at 131072 tokens, 0.966 and 0.971 of the budget.
|
||||
@@ -846,18 +896,25 @@ describe('every memory size, with the stock catalogue', () => {
|
||||
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '8' } });
|
||||
// The smallest model a harness can work with needs more than four times the budget.
|
||||
const smallest = Math.min(...STOCK.models.map((m) => needBytes(m, 65536)));
|
||||
expect(formatGb(smallest)).toBe('10.2');
|
||||
expect(formatGb(smallest)).toBe('9.3');
|
||||
expect(smallest / result.budgetBytes).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('gives no model to 8 and 16 GB, a small model to every Mac from 18 to 32 GB, and every larger one a model of 27 billion or more', () => {
|
||||
it('gives no model to 8 GB, a small model to every Mac from 16 to 24 GB, and every Mac from 36 GB a model of 27 billion or more', () => {
|
||||
for (const hw of SWEEP) {
|
||||
const result = recommend(hw, STOCK, ALL_INSTALLED);
|
||||
const label = `${hw.memClassGb} GB ${hw.chip}`;
|
||||
if (hw.memClassGb <= 16) expect(result.reason, label).toBe('memory');
|
||||
else if (hw.memClassGb <= 32) {
|
||||
if (hw.memClassGb <= 8) expect(result.reason, label).toBe('memory');
|
||||
else if (hw.memClassGb <= 24) {
|
||||
expect(result.verdict, label).toBe('limited');
|
||||
expect(result.pick?.paramsB, label).toBe(9);
|
||||
} else if (hw.memClassGb === 32) {
|
||||
// A model of 30 billion fits, so the Mac is not `limited`. It is dense: the pick only
|
||||
// where it is not slow (a chip this file knows is fast, or one it knows nothing about).
|
||||
expect(result.verdict, label).toBe('ok');
|
||||
expect(rows(result).map((c) => c.modelId), label).toContain('muse-glimmer:30b-q4_k_m');
|
||||
expect(result.pick?.paramsB, label).toBe(hw.chip === 'Apple M4' ? 9 : 29.6);
|
||||
if (hw.chip === 'Apple M4') expect(result.notes[0], label).toEqual({ text: LOCAL_MODEL_LABELS.noteSmallPick });
|
||||
} else {
|
||||
expect(result.verdict, label).toBe('ok');
|
||||
expect(result.pick?.paramsB, label).toBeGreaterThanOrEqual(27);
|
||||
@@ -1620,11 +1677,13 @@ describe('the ranking', () => {
|
||||
expect(result.pick).toMatchObject({ context: 1024, fit: 'tight', budgetShare: 0.92 });
|
||||
});
|
||||
|
||||
it('lists each model once, never the pick again, and at most four alternatives', () => {
|
||||
expect(MAX_ALTERNATIVES).toBe(4);
|
||||
// Seven models fit a 128 GB Mac; the pick plus four are shown.
|
||||
it('lists each model once, never the pick again, and every model that fits', () => {
|
||||
// The bound is above what either catalogue holds, so nothing that fits is cut off.
|
||||
expect(MAX_ALTERNATIVES).toBe(11);
|
||||
for (const [, catalog] of BOTH) expect(catalog.models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
|
||||
// Seven models fit a 128 GB Mac: the pick and all six others are shown.
|
||||
const result = recommend(mac(128, 'Apple M5 Ultra'), RULES, ALL_INSTALLED);
|
||||
expect(result.alternatives).toHaveLength(4);
|
||||
expect(result.alternatives).toHaveLength(6);
|
||||
const ids = rows(result).map((c) => c.modelId);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const [, catalog] of BOTH) {
|
||||
@@ -1633,9 +1692,26 @@ describe('the ranking', () => {
|
||||
const sweptIds = rows(swept).map((c) => c.modelId);
|
||||
expect(new Set(sweptIds).size).toBe(sweptIds.length);
|
||||
expect(swept.alternatives.length).toBeLessThanOrEqual(MAX_ALTERNATIVES);
|
||||
// Every model that fits at a context a harness works at is on the list (the sweep's
|
||||
// Macs have 500 GB of free disk, so none is left out for room).
|
||||
const fitting = catalog.models.filter((m) =>
|
||||
m.contexts.some((ctx) => ctx >= 65536 && modelFitsBudget(hw, catalog, m.id, ctx))
|
||||
);
|
||||
expect([...sweptIds].sort(), `${hw.memClassGb} GB ${hw.chip}`).toEqual(fitting.map((m) => m.id).sort());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('still cuts a list longer than the bound, and the install can only take what is listed', () => {
|
||||
// Fourteen models that all fit: the pick and MAX_ALTERNATIVES others, best rank first.
|
||||
const many = catalogOf(
|
||||
Array.from({ length: 14 }, (_, index) => modelNeeding(`m${index + 1}:x`, GIB, { rank: index + 1 }))
|
||||
);
|
||||
const result = recommend(HUNDRED_GIB_MAC, many, [fact('alpha')]);
|
||||
expect(rows(result)).toHaveLength(1 + MAX_ALTERNATIVES);
|
||||
expect(rows(result)[0].modelId).toBe('m14:x');
|
||||
expect(rows(result).at(-1)?.modelId).toBe('m3:x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('harnesses', () => {
|
||||
@@ -2064,6 +2140,8 @@ describe('what each row of the chooser carries', () => {
|
||||
'qwen3.5:9b-q8_0': 'estimated',
|
||||
'qwen3.5:9b': 'estimated',
|
||||
'gpt-oss:20b': 'estimated',
|
||||
// Slow on this chip, so it was the sixth row: listed since the list holds every model.
|
||||
'qwen3.8:27b-q8_0': 'estimated',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2075,10 +2153,19 @@ describe('the measured budget, end to end', () => {
|
||||
const measured = recommend(hw, STOCK, ALL_INSTALLED, { measuredTotalBytes: MACBOOK_MEASURED_TOTAL });
|
||||
expect([estimated.budgetSource, estimated.budgetGb]).toEqual(['estimated', '37.6']);
|
||||
expect([measured.budgetSource, measured.budgetGb]).toEqual(['measured', '40.2']);
|
||||
// The same rows in the same order; only the share of each changes.
|
||||
expect(order(measured)).toEqual(order(estimated));
|
||||
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([0.957, 0.767, 0.692, 0.303, 0.303]);
|
||||
expect(rows(measured).map((c) => c.budgetShare)).toEqual([0.895, 0.717, 0.647, 0.283, 0.283]);
|
||||
// The same models in the same order. The share of each changes, and one model steps up a
|
||||
// context: the 27B in 8 bits fits 65536 tokens under the estimate (0.975) and 131072 under
|
||||
// what the runtime reported (0.976).
|
||||
expect(rows(measured).map((c) => c.modelId)).toEqual(rows(estimated).map((c) => c.modelId));
|
||||
const stepped = order(estimated).filter((entry, index) => order(measured)[index] !== entry);
|
||||
expect(stepped).toEqual(['qwen3.8:27b-q8_0@65536']);
|
||||
expect(order(measured)).toContain('qwen3.8:27b-q8_0@131072');
|
||||
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([
|
||||
0.957, 0.767, 0.692, 0.303, 0.278, 0.303, 0.853, 0.975, 0.576,
|
||||
]);
|
||||
expect(rows(measured).map((c) => c.budgetShare)).toEqual([
|
||||
0.895, 0.717, 0.647, 0.283, 0.26, 0.283, 0.798, 0.976, 0.539,
|
||||
]);
|
||||
for (const candidate of rows(measured)) {
|
||||
expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / MACBOOK_MEASURED_TOTAL, 3);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user