fix(router) fixed routing of different accounts for headroom

This commit is contained in:
nvrl committed 2026-09-23 13:00:19 +02:00
1 parent cc8c764a67
commit aea75fbf1c
9 files changed
+764 -87

No files matched your search

Generated
+10
View File
@@ -87,6 +87,15 @@ dependencies = [
"terminal_size", "terminal_size",
] ]
[[package]]
name = "clap_complete"
version = "4.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.7" version = "4.6.7"
@@ -111,6 +120,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"clap_complete",
"serde", "serde",
"serde_json", "serde_json",
] ]
+1
View File
@@ -14,6 +14,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
clap = { version = "4", features = ["derive", "wrap_help"] } clap = { version = "4", features = ["derive", "wrap_help"] }
clap_complete = "4.5.67"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] } serde_json = { version = "1", features = ["preserve_order"] }
+61 -6
View File
@@ -8,7 +8,8 @@ which of them the `claude` command runs as, and every account gets a Headroom
proxy of its own, reachable under a name that does not change. proxy of its own, reachable under a name that does not change.
```sh ```sh
claude-account setup # wrapper, a proxy per account, the router claude-account setup # wrapper, a proxy per account, the router, completions
claude-account add kunde # another account: profile, port, workspace, proxy
claude-account switch work # `claude` runs as the work account from now on claude-account switch work # `claude` runs as the work account from now on
claude-account status # everything, and everything that is wrong claude-account status # everything, and everything that is wrong
``` ```
@@ -35,6 +36,12 @@ started deliberately in another profile -- a jail does this -- stays where it
was put. Sessions that are already open are never disturbed by a switch; each was put. Sessions that are already open are never disturbed by a switch; each
keeps the profile it started with. keeps the profile it started with.
One thing beats the wrapper and should not: an `alias claude=...` or a shell
function of that name, which is found before anything in the `PATH` and, where
it sets `CLAUDE_CONFIG_DIR` itself, makes every switch look ignored. `status`
reads the startup files of bash, zsh and fish and names the file and the line,
because nothing else in the report would show why the account never changes.
## A proxy per account ## A proxy per account
A Headroom proxy keeps what it sees: a cache, a retrieval store and logs, all A Headroom proxy keeps what it sees: a cache, a retrieval store and logs, all
@@ -45,12 +52,23 @@ written from one systemd template:
| Address | What | | Address | What |
| --- | --- | | --- | --- |
| `headroom.localhost` | the proxy of the account that is active right now | | `<account>.headroom.localhost` | the proxy of that account, active or not |
| `<account>.headroom.localhost` | one account's proxy, active or not |
| `<jail>.headroom.localhost` | the proxy a jail started for one session | | `<jail>.headroom.localhost` | the proxy a jail started for one session |
| `headroom.localhost` | the proxy of the account that is active right now |
| `headroom.localhost/names` | everything that resolves, and `?port=` for one name |
The bare name follows the switch. That is the whole point of it: one address in Every account has its own name, so two accounts in use at the same time --
a bookmark, always showing the account being worked in. one in each terminal -- never share an address. The bare domain follows the
switch, for a bookmark that should show whichever account is being worked in,
but nothing prints it: an address printed in one session would open the other
account's proxy after a switch.
A jail called like an account does not get the name; the account keeps it, and
`status` points the jail out with the command that renames it. Programs that
want the name of a port ask `headroom.localhost/names?port=8791`, which answers
with the address as plain text, or 404 where the port has no name. That is how
the `enqs-jail` and `enqs-info` plugins find it, so the rules of who gets which
name live here alone.
Jails keep their own names. They are published as run files by the `enqs-jail` Jails keep their own names. They are published as run files by the `enqs-jail`
plugin, and the router here reads the same directory in the same format, so a plugin, and the router here reads the same directory in the same format, so a
@@ -67,12 +85,49 @@ workspaces over unchanged.
| `doctor` | the same, and a non-zero exit when something is wrong | | `doctor` | the same, and a non-zero exit when something is wrong |
| `list`, `current`, `dir`, `env`, `url` | the small answers, for scripts | | `list`, `current`, `dir`, `env`, `url` | the small answers, for scripts |
| `switch <account>` | make an account the one `claude` runs as | | `switch <account>` | make an account the one `claude` runs as |
| `setup` | wrapper, proxies and router in one go | | `add <name>` | register an account: profile, port, workspace, proxy, all at once |
| `remove <account>` | forget one; its profile directory stays |
| `completions [shell]` | print a completion script, or `--install` it |
| `setup` | wrapper, proxies, router and completions in one go |
| `adopt` | take over services that were written by hand | | `adopt` | take over services that were written by hand |
| `shim install \| uninstall` | the `claude` wrapper | | `shim install \| uninstall` | the `claude` wrapper |
| `headroom install \| start \| stop \| restart \| port \| logs` | one account's proxy | | `headroom install \| start \| stop \| restart \| port \| logs` | one account's proxy |
| `router serve \| install \| uninstall \| port \| list` | the name router | | `router serve \| install \| uninstall \| port \| list` | the name router |
## Adding an account
```sh
claude-account add kunde # ~/.claude-kunde, the next free port
claude-account add kunde --dir ~/elsewhere --port 8795
claude-account add kunde --switch # and run as it straight away
```
`add` does everything an account needs rather than half of it: it creates the
profile directory, reserves a port, gives the account a workspace of its own,
writes and starts its proxy service, and points the profile's `settings.json`
at that proxy. A login is the one thing left, because only a browser can do it:
`claude-account switch kunde && claude /login`.
`remove` is the counterpart and undoes the registration alone. The profile
directory and the proxy workspace stay where they are -- they hold a login,
projects and memories -- so a profile that still has credentials keeps showing
up in `list`, found rather than configured.
## Completions
```sh
claude-account completions --install # every shell on this machine
claude-account completions fish # or print one
```
`setup` installs them as well. The subcommands come from the parser, so they
cannot drift; the account names are not written into the file but asked for
when Tab is pressed (`claude-account list --names`), so an account added a
minute ago completes without regenerating anything. Files go to
`~/.config/fish/completions`, `~/.local/share/bash-completion/completions` and
`~/.local/share/zsh/site-functions` -- the last one has to be in the `fpath`
before `compinit`.
## Configuration ## Configuration
`~/.config/claude-account/config.json`. Everything in it has a default derived `~/.config/claude-account/config.json`. Everything in it has a default derived
+62 -2
View File
@@ -24,7 +24,8 @@ pub const DEFAULT_NAME: &str = "personal";
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Account { pub struct Account {
/// The name on the command line and in `<name>.headroom.localhost`. /// The name on the command line. An account has no name under the domain:
/// the bare one belongs to whichever account is active.
pub name: String, pub name: String,
/// The directory `CLAUDE_CONFIG_DIR` is pointed at. /// The directory `CLAUDE_CONFIG_DIR` is pointed at.
pub dir: PathBuf, pub dir: PathBuf,
@@ -175,7 +176,7 @@ pub fn find(config: &Config, name: &str) -> Result<Account> {
let wanted = router::label(name); let wanted = router::label(name);
all(config) all(config)
.into_iter() .into_iter()
.find(|account| account.name == wanted) .find(|account| router::label(&account.name) == wanted)
.ok_or_else(|| anyhow!("there is no account called {name}. Run `claude-account list`.")) .ok_or_else(|| anyhow!("there is no account called {name}. Run `claude-account list`."))
} }
@@ -215,6 +216,65 @@ pub fn switch(config: &mut Config, name: &str) -> Result<Account> {
Ok(account) Ok(account)
} }
/// Register an account: a new profile directory, or one this machine already
/// holds that discovery would not have found.
///
/// The directory is created empty when it does not exist. An empty profile is
/// a working one -- Claude Code fills it on the first run and `/login` puts a
/// credential in it -- so nothing is copied from another account, which would
/// be the one way to end up with two accounts sharing a login again.
pub fn add(config: &mut Config, name: &str, dir: Option<PathBuf>, port: Option<u16>) -> Result<Account> {
let name = router::label(name);
if name.is_empty() {
return Err(anyhow!("an account needs a name of letters, digits or hyphens"));
}
if let Some(existing) = all(config).into_iter().find(|account| account.name == name) {
return Err(anyhow!("there is already an account called {name}, at {}", shorten(&existing.dir)));
}
let dir = dir.unwrap_or_else(|| dir_for(&name));
if let Some(other) = all(config).into_iter().find(|account| account.dir == dir) {
return Err(anyhow!("{} is already the account {}", shorten(&dir), other.name));
}
if let Some(port) = port {
if let Some(other) = all(config).into_iter().find(|account| account.port == port) {
return Err(anyhow!("port {port} already belongs to {}", other.name));
}
}
std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", shorten(&dir)))?;
let entry = config.entry_mut(&name);
entry.dir = Some(shorten(&dir));
entry.port = port;
config.save()?;
// Read the account back rather than building one here, so that a port and
// a workspace nobody named are the ones discovery hands out, and are the
// same numbers every later run will see.
let account = find(config, &name)?;
let entry = config.entry_mut(&name);
entry.port = Some(account.port);
entry.workspace.get_or_insert_with(|| shorten(&account.workspace));
config.save()?;
Ok(account)
}
/// Forget an account: the configuration entry goes, the profile stays.
///
/// A profile directory holds a login, projects and memories, so removing an
/// account here never removes any of that. What the command undoes is the
/// registration; `rm -rf` is a decision for a reader who means it.
pub fn forget(config: &mut Config, name: &str) -> Result<Account> {
let account = find(config, name)?;
if config.active.as_deref() == Some(account.name.as_str()) {
return Err(anyhow!(
"{} is the account `claude` runs as. Switch to another one first.",
account.name
));
}
config.accounts.remove(&account.name);
config.save()?;
Ok(account)
}
/// Sessions of Claude Code that are running right now, by the profile they /// Sessions of Claude Code that are running right now, by the profile they
/// were started in. /// were started in.
/// ///
+133
View File
@@ -0,0 +1,133 @@
//! Tab completion, including the account names.
//!
//! The subcommands come out of the parser, so they cannot drift from what the
//! tool accepts. The account names cannot be baked in the same way: an account
//! is added, renamed or forgotten long after a completion file was written, and
//! a file listing yesterday's accounts is worse than none. So the generated
//! script is followed by a few lines per shell that ask this tool for the names
//! at the moment somebody presses Tab -- `claude-account list --names` is there
//! for that and prints nothing else.
use anyhow::{Context, Result};
use clap::CommandFactory;
use clap_complete::Shell;
use std::path::PathBuf;
use crate::paths::{home, shorten, which};
use crate::Cli;
/// The subcommands whose argument is an account. The dynamic rules offer names
/// after each of them; anywhere else the generated script has the last word.
const TAKES_ACCOUNT: &str = "switch dir env url remove start stop restart logs install uninstall port";
/// The completion script for one shell.
pub fn script(shell: Shell) -> String {
let mut command = Cli::command();
let name = command.get_name().to_string();
let mut out = Vec::new();
clap_complete::generate(shell, &mut command, name, &mut out);
let mut script = String::from_utf8_lossy(&out).to_string();
script.push_str(&dynamic(shell));
script
}
/// Where a shell looks for a completion file it does not have to be told about.
pub fn destination(shell: Shell) -> Option<PathBuf> {
let home = home();
match shell {
Shell::Fish => Some(home.join(".config/fish/completions/claude-account.fish")),
Shell::Bash => Some(home.join(".local/share/bash-completion/completions/claude-account")),
Shell::Zsh => Some(home.join(".local/share/zsh/site-functions/_claude-account")),
_ => None,
}
}
/// Write the completion file for one shell.
pub fn install(shell: Shell) -> Result<PathBuf> {
let path = destination(shell)
.with_context(|| format!("{shell} has no place this tool knows to put a completion file"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", shorten(parent)))?;
}
std::fs::write(&path, script(shell)).with_context(|| format!("cannot write {}", shorten(&path)))?;
Ok(path)
}
/// The shells on this machine that a completion file would reach.
///
/// A shell nobody uses gets no file: the point of installing without being
/// asked which shell is that the answer is obvious, and writing into three
/// directories to be safe is not that.
pub fn present() -> Vec<Shell> {
let home = home();
let mut out = Vec::new();
if home.join(".config/fish").is_dir() || which("fish").is_some() {
out.push(Shell::Fish);
}
if home.join(".bashrc").is_file() || which("bash").is_some() {
out.push(Shell::Bash);
}
if home.join(".zshrc").is_file() || which("zsh").is_some() {
out.push(Shell::Zsh);
}
out
}
/// The lines that put live account names into the completion.
fn dynamic(shell: Shell) -> String {
match shell {
Shell::Fish => format!(
"\n# The accounts as they are right now, asked for when Tab is pressed.\n\
complete -c claude-account -n '__fish_seen_subcommand_from {TAKES_ACCOUNT}' \
-f -a '(claude-account list --names)'\n"
),
// A wrapper rather than an edit of the generated function: the
// generated part stays exactly as clap wrote it, and the dynamic rule
// is one case statement that hands back to it for everything else.
Shell::Bash => format!(
"\n# The accounts as they are right now, asked for when Tab is pressed.\n\
_claude_account_dynamic() {{\n\
\x20 local previous=\"${{COMP_WORDS[COMP_CWORD-1]}}\"\n\
\x20 case \" {TAKES_ACCOUNT} \" in\n\
\x20 *\" $previous \"*)\n\
\x20 mapfile -t COMPREPLY < <(compgen -W \"$(claude-account list --names \
2>/dev/null)\" -- \"${{COMP_WORDS[COMP_CWORD]}}\")\n\
\x20 return 0 ;;\n\
\x20 esac\n\
\x20 _claude-account \"$@\"\n\
}}\n\
complete -F _claude_account_dynamic -o nosort -o bashdefault -o default claude-account\n"
),
Shell::Zsh => format!(
"\n# The accounts as they are right now, asked for when Tab is pressed.\n\
_claude_account_dynamic() {{\n\
\x20 local previous=${{words[CURRENT-1]}}\n\
\x20 if [[ \" {TAKES_ACCOUNT} \" == *\" $previous \"* ]]; then\n\
\x20 local -a names\n\
\x20 names=(${{(f)\"$(claude-account list --names 2>/dev/null)\"}})\n\
\x20 if (( ${{#names}} )); then\n\
\x20 _describe -t accounts account names\n\
\x20 return\n\
\x20 fi\n\
\x20 fi\n\
\x20 _claude-account \"$@\"\n\
}}\n\
compdef _claude_account_dynamic claude-account\n"
),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_script_asks_for_the_names_at_completion_time() {
for shell in [Shell::Fish, Shell::Bash, Shell::Zsh] {
let script = script(shell);
assert!(script.contains("claude-account list --names"), "{shell} has no dynamic names");
assert!(script.contains("switch"), "{shell} is missing the subcommands");
}
}
}
+180 -20
View File
@@ -4,6 +4,7 @@
//! whichever account was switched to last. //! whichever account was switched to last.
mod account; mod account;
mod completions;
mod config; mod config;
mod headroom; mod headroom;
mod paths; mod paths;
@@ -16,6 +17,7 @@ mod ui;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use clap_complete::Shell;
use crate::account::Account; use crate::account::Account;
use crate::config::Config; use crate::config::Config;
@@ -28,7 +30,7 @@ use crate::paths::shorten;
about = "Switch the Claude account `claude` runs as, with a Headroom proxy per account.", about = "Switch the Claude account `claude` runs as, with a Headroom proxy per account.",
disable_help_subcommand = true disable_help_subcommand = true
)] )]
struct Cli { pub struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Command>, command: Option<Command>,
} }
@@ -42,12 +44,35 @@ enum Command {
verbose: bool, verbose: bool,
}, },
/// List the accounts, one per line /// List the accounts, one per line
List, List {
/// Print the names alone, for scripts and for tab completion
#[arg(long)]
names: bool,
},
/// Make an account the one `claude` runs as /// Make an account the one `claude` runs as
Switch { Switch {
/// The account, as `list` names it /// The account, as `list` names it
account: String, account: String,
}, },
/// Register another account, with a profile and a proxy of its own
Add {
/// What to call it; also the directory, as `~/.claude-<name>`
name: String,
/// A profile directory elsewhere
#[arg(long)]
dir: Option<std::path::PathBuf>,
/// A port for its proxy; the next free one by default
#[arg(long)]
port: Option<u16>,
/// Make it the account `claude` runs as straight away
#[arg(long)]
switch: bool,
},
/// Forget an account. Its profile directory is left where it is
Remove {
/// The account, as `list` names it
account: String,
},
/// Print the name of the active account /// Print the name of the active account
Current, Current,
/// Print the profile directory of an account, for `CLAUDE_CONFIG_DIR` /// Print the profile directory of an account, for `CLAUDE_CONFIG_DIR`
@@ -86,6 +111,14 @@ enum Command {
#[command(subcommand)] #[command(subcommand)]
command: RouterCommand, command: RouterCommand,
}, },
/// Tab completion, with the account names in it
Completions {
/// The shell; every shell on this machine when left out
shell: Option<Shell>,
/// Write the file where the shell reads it, instead of printing it
#[arg(long)]
install: bool,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -165,9 +198,13 @@ fn run() -> Result<()> {
} }
std::process::exit(1); std::process::exit(1);
} }
Command::List => { Command::List { names } => {
let active = account::active(&config).map(|account| account.name); let active = account::active(&config).map(|account| account.name);
for account in account::all(&config) { for account in account::all(&config) {
if names {
println!("{}", account.name);
continue;
}
let marker = if Some(&account.name) == active.as_ref() { "*" } else { " " }; let marker = if Some(&account.name) == active.as_ref() { "*" } else { " " };
println!( println!(
"{marker} {:<10} {:<28} {}", "{marker} {:<10} {:<28} {}",
@@ -179,6 +216,10 @@ fn run() -> Result<()> {
Ok(()) Ok(())
} }
Command::Switch { account } => switch(&mut config, &account), Command::Switch { account } => switch(&mut config, &account),
Command::Add { name, dir, port, switch: and_switch } => {
add(&mut config, &name, dir, port, and_switch)
}
Command::Remove { account } => remove(&mut config, &account),
Command::Current => { Command::Current => {
let account = require_active(&config)?; let account = require_active(&config)?;
println!("{}", account.name); println!("{}", account.name);
@@ -195,22 +236,13 @@ fn run() -> Result<()> {
println!("export ENQS_CLAUDE_PROFILE={}", account.dir.display()); println!("export ENQS_CLAUDE_PROFILE={}", account.dir.display());
Ok(()) Ok(())
} }
Command::Url { name } => { Command::Url { name } => url(&config, name.as_deref()),
let name = match name {
Some(name) => name,
None => require_active(&config)?.name,
};
println!("{}", router::url(&config, &name));
if router::resolve(&config, &name).is_none() {
eprintln!("{} nothing is published as {name} right now", ui::yellow("note:"));
}
Ok(())
}
Command::Setup => setup(&mut config), Command::Setup => setup(&mut config),
Command::Adopt => adopt(&mut config), Command::Adopt => adopt(&mut config),
Command::Shim { command } => shim_command(command), Command::Shim { command } => shim_command(command),
Command::Headroom { command } => headroom_command(&mut config, command), Command::Headroom { command } => headroom_command(&mut config, command),
Command::Router { command } => router_command(&mut config, command), Command::Router { command } => router_command(&mut config, command),
Command::Completions { shell, install } => completions_command(shell, install),
} }
} }
@@ -241,6 +273,111 @@ fn switch(config: &mut Config, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Register another account and give it everything an account has: a profile
/// directory, a port, a workspace and a proxy service of its own.
///
/// Doing all of that here is the point. An account added by hand is an entry
/// in a file that nothing acts on until somebody remembers the three commands
/// that follow it, and a proxy that is missing is only noticed when a session
/// cannot connect.
fn add(
config: &mut Config,
name: &str,
dir: Option<std::path::PathBuf>,
port: Option<u16>,
and_switch: bool,
) -> Result<()> {
let account = account::add(config, name, dir, port)?;
ui::done(format!("Account {} added.", ui::bold(&account.name)));
ui::field("profile", shorten(&account.dir));
match headroom::binary() {
Some(_) => {
let installed = headroom::install(&account)?;
ui::field("proxy", format!("port {} ({})", account.port, installed.unit));
ui::field("workspace", shorten(&account.workspace));
}
None => {
ui::field(
"proxy",
ui::yellow(format!("port {} is reserved, but headroom is not installed", account.port)),
);
ui::hint("pip install \"headroom-ai[proxy]\"");
}
}
if and_switch {
return switch(config, &account.name);
}
if account.email().is_none() {
ui::field("login", ui::yellow("none yet"));
ui::hint(format!("claude-account switch {} && claude /login", account.name));
}
Ok(())
}
/// Forget an account. The profile directory and the workspace stay: this
/// command undoes a registration, not a login and not a year of projects.
fn remove(config: &mut Config, name: &str) -> Result<()> {
let account = account::forget(config, name)?;
headroom::uninstall(&account)?;
ui::done(format!("Account {} forgotten; its proxy service is gone.", ui::bold(&account.name)));
ui::field("profile", format!("{} is left where it is", shorten(&account.dir)));
ui::field("workspace", format!("{} is left where it is", shorten(&account.workspace)));
// Discovery looks at `~/.claude*` as well, so a profile that still holds a
// login comes back in the listing without its overrides. Better said than
// discovered in the next `list`.
if account.has_credentials() {
ui::field("note", "the profile still holds a login, so `list` keeps showing it");
}
Ok(())
}
/// The address of a name: an account's proxy, or a jail's.
///
/// An account's own label comes before a jail's, the way the router resolves
/// it, and the active account is meant when nothing is named.
fn url(config: &Config, name: Option<&str>) -> Result<()> {
let name = match name {
Some(name) => name.to_string(),
None => require_active(config)?.name,
};
let Some(port) = router::resolve(config, &name) else {
return Err(anyhow!("nothing is called {name} right now. Run `claude-account router list`."));
};
println!("{}", router::label_url(config, &name));
if !router::is_listening(port) {
eprintln!("{} nothing answers on port {port}, where that name points", ui::yellow("note:"));
}
Ok(())
}
fn completions_command(shell: Option<Shell>, install: bool) -> Result<()> {
let shells: Vec<Shell> = match shell {
Some(shell) => vec![shell],
None if install => completions::present(),
None => {
return Err(anyhow!(
"name a shell to print its completion, or use --install to write the files for \
every shell on this machine"
))
}
};
if !install {
for shell in shells {
print!("{}", completions::script(shell));
}
return Ok(());
}
for shell in shells {
let path = completions::install(shell)?;
ui::done(format!("Completion for {shell} written to {}.", shorten(&path)));
if shell == Shell::Zsh {
ui::field("note", "that directory has to be in the fpath before compinit");
}
}
ui::field("in this shell", "open a new one, or reload the completions");
Ok(())
}
fn setup(config: &mut Config) -> Result<()> { fn setup(config: &mut Config) -> Result<()> {
let (path, real) = shim::install(false)?; let (path, real) = shim::install(false)?;
ui::done(format!("Wrapper written to {} (hands over to {}).", shorten(&path), shorten(&real))); ui::done(format!("Wrapper written to {} (hands over to {}).", shorten(&path), shorten(&real)));
@@ -257,14 +394,31 @@ fn setup(config: &mut Config) -> Result<()> {
let (path, unit) = service::install(config)?; let (path, unit) = service::install(config)?;
ui::done(format!("Router on port {}.", config.router_port())); ui::done(format!("Router on port {}.", config.router_port()));
ui::field("unit", format!("{unit} ({})", shorten(&path))); ui::field("unit", format!("{unit} ({})", shorten(&path)));
if let Some(account) = account::active(config) { for account in account::all(config) {
ui::field("bare name", format!("{} goes to {}", router::bare_url(config), account.name)); ui::field(&account.name, router::label_url(config, &account.name));
}
for shell in completions::present() {
let path = completions::install(shell)?;
ui::done(format!("Completion for {shell} in {}.", shorten(&path)));
} }
let legacy = headroom::legacy_units(); let legacy = headroom::legacy_units();
if !legacy.is_empty() { if !legacy.is_empty() {
ui::field("note", format!("{} still run beside these", legacy.join(", "))); ui::field("note", format!("{} still run beside these", legacy.join(", ")));
ui::hint("claude-account adopt"); ui::hint("claude-account adopt");
} }
// A leftover alias is the one thing here that cannot be repaired from this
// process: it lives in somebody's shell configuration and beats every
// executable in the PATH, so all it can be given is a line and a location.
for shadow in shim::shadows() {
ui::field(
"in the way",
ui::yellow(format!(
"{}:{} defines `claude` itself and wins over the wrapper",
shorten(&shadow.file),
shadow.line
)),
);
}
Ok(()) Ok(())
} }
@@ -355,7 +509,7 @@ fn headroom_command(config: &mut Config, command: HeadroomCommand) -> Result<()>
ui::field("unit", installed.unit); ui::field("unit", installed.unit);
ui::field("environment", shorten(&installed.environment)); ui::field("environment", shorten(&installed.environment));
ui::field("workspace", shorten(&account.workspace)); ui::field("workspace", shorten(&account.workspace));
ui::field("address", router::url(config, &account.name)); ui::field("address", address(config, &account));
if installed.routed { if installed.routed {
ui::field("settings.json", "now points at that proxy"); ui::field("settings.json", "now points at that proxy");
} }
@@ -406,7 +560,7 @@ fn headroom_command(config: &mut Config, command: HeadroomCommand) -> Result<()>
let installed = headroom::install(&account)?; let installed = headroom::install(&account)?;
ui::done(format!("{} now runs its proxy on port {port}.", account.name)); ui::done(format!("{} now runs its proxy on port {port}.", account.name));
ui::field("unit", installed.unit); ui::field("unit", installed.unit);
ui::field("address", router::url(config, &account.name)); ui::field("address", address(config, &account));
Ok(()) Ok(())
} }
HeadroomCommand::Logs { account, lines } => { HeadroomCommand::Logs { account, lines } => {
@@ -470,7 +624,7 @@ fn router_command(config: &mut Config, command: RouterCommand) -> Result<()> {
service::install(config)?; service::install(config)?;
ui::field("service", "rewritten and restarted"); ui::field("service", "rewritten and restarted");
} }
ui::field("addresses", router::bare_url(config)); ui::field("addresses", format!("{} and every <name> under it", router::bare_url(config)));
Ok(()) Ok(())
} }
RouterCommand::List => { RouterCommand::List => {
@@ -478,7 +632,7 @@ fn router_command(config: &mut Config, command: RouterCommand) -> Result<()> {
let answers = if router::is_listening(entry.port) { "" } else { " (nothing answers)" }; let answers = if router::is_listening(entry.port) { "" } else { " (nothing answers)" };
println!( println!(
"{:<40} {:>6} {}{answers}", "{:<40} {:>6} {}{answers}",
router::url(config, &entry.name), router::name_url(config, &entry),
entry.port, entry.port,
entry.source entry.source
); );
@@ -488,6 +642,12 @@ fn router_command(config: &mut Config, command: RouterCommand) -> Result<()> {
} }
} }
/// Where one account's proxy is reached: its own name, whichever account is
/// active.
fn address(config: &Config, account: &Account) -> String {
router::label_url(config, &account.name)
}
/// Write what discovery worked out into the configuration, so that the router /// Write what discovery worked out into the configuration, so that the router
/// service and the wrapper see the same numbers as this run. /// service and the wrapper see the same numbers as this run.
fn remember(config: &mut Config, account: &Account) { fn remember(config: &mut Config, account: &Account) {
+188 -49
View File
@@ -6,16 +6,27 @@
//! name, and one router on a fixed port forwards that name to the port of the //! name, and one router on a fixed port forwards that name to the port of the
//! moment: //! moment:
//! //!
//! * `headroom.localhost` -- the proxy of the account that is active right
//! now. The shortest address follows the switch, which is the point: there
//! is one address to keep in a bookmark and it is always the dashboard of
//! the account being worked in.
//! * `<account>.headroom.localhost` -- that one account's proxy, active or //! * `<account>.headroom.localhost` -- that one account's proxy, active or
//! not, for looking at the other one without switching. //! not. Two accounts can have sessions open at the same time, and each of
//! them has to be able to name its own proxy: an address that follows the
//! switch would send a session of the other account to the wrong dashboard.
//! * `<jail>.headroom.localhost` -- the proxy a jail started for itself. Those //! * `<jail>.headroom.localhost` -- the proxy a jail started for itself. Those
//! are published as run files by the `enqs-jail` plugin, and this router //! are published as run files by the `enqs-jail` plugin, and this router
//! reads the same directory in the same format, so a jail keeps its name //! reads the same directory in the same format.
//! whichever of the two routers is serving the domain. //! * `headroom.localhost` -- the proxy of the account that is active right
//! now, for a bookmark. Nothing this tool or the plugin prints uses it,
//! because which account it means changes under a session that is open.
//!
//! Accounts and jails share one set of labels, so a jail may be called like an
//! account. The account keeps the name then: it is there for good and a jail
//! only for one session, and a bookmark must not start opening a customer's
//! proxy because a jail of that name happens to be running. The jail answers
//! on its port instead, and `status` says why.
//!
//! What a name points at is only ever decided here. A client that wants to
//! print one asks `/names?port=<port>` which name reaches that port, and gets
//! the address or a 404; it never builds a name itself and so never prints one
//! that goes somewhere else.
//! //!
//! `*.localhost` resolves to the loopback address without any configuration, //! `*.localhost` resolves to the loopback address without any configuration,
//! in every browser and on this machine, so none of these names costs a line //! in every browser and on this machine, so none of these names costs a line
@@ -56,6 +67,8 @@ pub struct Published {
/// What one name stands for, for the listing and the diagnostics. /// What one name stands for, for the listing and the diagnostics.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Name { pub struct Name {
/// The label in front of the domain. Empty for the bare name, which is
/// what the active account answers on.
pub name: String, pub name: String,
pub port: u16, pub port: u16,
pub kind: Kind, pub kind: Kind,
@@ -65,10 +78,22 @@ pub struct Name {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind { pub enum Kind {
/// The bare name, pointing at the account that is active right now.
Active,
Account, Account,
Jail, Jail,
} }
impl Kind {
pub fn describe(&self) -> &'static str {
match self {
Kind::Active => "the active account",
Kind::Account => "account",
Kind::Jail => "jail",
}
}
}
/// A name as a DNS label: lower case, letters, digits and hyphens. /// A name as a DNS label: lower case, letters, digits and hyphens.
/// ///
/// A jail may be called anything, and a name that is not a label would simply /// A jail may be called anything, and a name that is not a label would simply
@@ -113,53 +138,81 @@ pub fn loopback_port(url: &str) -> Option<u16> {
} }
} }
/// The URL a name is reachable at. /// The URL a label is reachable at, an account's or a jail's.
/// ///
/// Port 80 is left out, because a browser and a terminal both assume it: that /// Port 80 is left out, because a browser and a terminal both assume it: that
/// is what a router on the privileged port buys. /// is what a router on the privileged port buys.
pub fn url(config: &Config, name: &str) -> String { pub fn label_url(config: &Config, name: &str) -> String {
let host = format!("{}.{DOMAIN}", label(name)); host_url(config, &format!("{}.{DOMAIN}", label(name)))
}
/// The URL the domain itself is reachable at: the active account's proxy.
pub fn bare_url(config: &Config) -> String {
host_url(config, DOMAIN)
}
fn host_url(config: &Config, host: &str) -> String {
match config.router_port() { match config.router_port() {
80 => format!("http://{host}"), 80 => format!("http://{host}"),
port => format!("http://{host}:{port}"), port => format!("http://{host}:{port}"),
} }
} }
/// The URL the domain itself is reachable at: the active account's proxy. /// Every name that resolves right now: the bare one, the accounts, then the
pub fn bare_url(config: &Config) -> String { /// jails that do not share a label with an account.
match config.router_port() {
80 => format!("http://{DOMAIN}"),
port => format!("http://{DOMAIN}:{port}"),
}
}
/// Every name that resolves right now.
///
/// Jails come first: a jail may be called `work` as well, and the proxy a
/// session started for itself is the one that session means.
pub fn names(config: &Config) -> Vec<Name> { pub fn names(config: &Config) -> Vec<Name> {
let mut out: Vec<Name> = published() let mut out = Vec::new();
.into_iter() if let Some(account) = account::active(config) {
.map(|entry| Name { name: entry.name, port: entry.port, kind: Kind::Jail, source: entry.jail }) out.push(Name {
.collect(); name: String::new(),
port: account.port,
kind: Kind::Active,
source: crate::paths::shorten(&account.dir),
});
}
for account in account::all(config) { for account in account::all(config) {
if out.iter().any(|name| name.name == account.name) { let name = label(&account.name);
if out.iter().any(|entry| entry.kind != Kind::Active && entry.name == name) {
continue; continue;
} }
out.push(Name { out.push(Name {
name: account.name, name,
port: account.port, port: account.port,
kind: Kind::Account, kind: Kind::Account,
source: crate::paths::shorten(&account.dir), source: crate::paths::shorten(&account.dir),
}); });
} }
for entry in published() {
if out.iter().any(|name| name.kind != Kind::Active && name.name == entry.name) {
continue;
}
out.push(Name { name: entry.name, port: entry.port, kind: Kind::Jail, source: entry.jail });
}
out out
} }
/// The port a name resolves to right now. /// The jails that are running but have no name, because an account holds the
/// label they would have had.
pub fn shadowed_jails(config: &Config) -> Vec<Published> {
let accounts: Vec<String> = account::all(config).iter().map(|account| label(&account.name)).collect();
published().into_iter().filter(|entry| accounts.contains(&entry.name)).collect()
}
/// The URL one of those names is reachable at.
pub fn name_url(config: &Config, name: &Name) -> String {
match name.kind {
Kind::Active => bare_url(config),
Kind::Account | Kind::Jail => label_url(config, &name.name),
}
}
/// The port a label resolves to right now.
pub fn resolve(config: &Config, name: &str) -> Option<u16> { pub fn resolve(config: &Config, name: &str) -> Option<u16> {
let wanted = label(name); let wanted = label(name);
names(config).into_iter().find(|entry| entry.name == wanted).map(|entry| entry.port) names(config)
.into_iter()
.find(|entry| entry.kind != Kind::Active && entry.name == wanted)
.map(|entry| entry.port)
} }
/// The port the domain itself resolves to: the proxy of the active account. /// The port the domain itself resolves to: the proxy of the active account.
@@ -167,6 +220,19 @@ pub fn resolve_bare(config: &Config) -> Option<u16> {
account::active(config).map(|account| account.port) account::active(config).map(|account| account.port)
} }
/// The name that reaches a port, as an address.
///
/// Never the bare domain: that one follows the switch, and the session that
/// asks keeps its account when the switch moves on. `None` when no label
/// reaches the port, which is the answer for a jail whose name an account
/// holds, and for a port nothing here knows.
pub fn lookup(config: &Config, port: u16) -> Option<String> {
names(config)
.into_iter()
.find(|entry| entry.kind != Kind::Active && entry.port == port)
.map(|entry| name_url(config, &entry))
}
/// Every jail proxy whose session is still running. /// Every jail proxy whose session is still running.
pub fn published() -> Vec<Published> { pub fn published() -> Vec<Published> {
let Ok(entries) = std::fs::read_dir(runtime_dir()) else { return Vec::new() }; let Ok(entries) = std::fs::read_dir(runtime_dir()) else { return Vec::new() };
@@ -244,9 +310,23 @@ fn handle(mut client: TcpStream) -> Result<()> {
let Some(host) = header(&head, "host") else { let Some(host) = header(&head, "host") else {
return answer(&mut client, 400, "No Host header, so there is no name to resolve."); return answer(&mut client, 400, "No Host header, so there is no name to resolve.");
}; };
let name = host_label(&host); let Some(name) = host_label(&host) else {
let bare = name.is_empty() || name == "headroom"; return answer(
&mut client,
404,
&format!("{host} is not a name under {DOMAIN}. The list is at http://{DOMAIN}/names."),
);
};
let bare = name.is_empty();
if bare && target(&head).as_deref() == Some("/names") { if bare && target(&head).as_deref() == Some("/names") {
// A client that wants to print an address asks here which one reaches
// its port, rather than working a name out for itself.
if let Some(wanted) = query(&head, "port") {
return match wanted.parse::<u16>().ok().and_then(|port| lookup(&config, port)) {
Some(url) => answer_plain(&mut client, 200, &url),
None => answer(&mut client, 404, &format!("No name reaches port {wanted}.")),
};
}
return answer_html(&mut client, &index(&config)); return answer_html(&mut client, &index(&config));
} }
let port = if bare { resolve_bare(&config) } else { resolve(&config, &name) }; let port = if bare { resolve_bare(&config) } else { resolve(&config, &name) };
@@ -312,13 +392,37 @@ fn target(head: &[u8]) -> Option<String> {
Some(path.split('?').next().unwrap_or(path).to_string()) Some(path.split('?').next().unwrap_or(path).to_string())
} }
/// The label in front of `.headroom.localhost` in a Host header. /// One parameter of the request's query.
fn host_label(host: &str) -> String { fn query(head: &[u8], wanted: &str) -> Option<String> {
let host = host.split(':').next().unwrap_or(host); let text = String::from_utf8_lossy(head);
let host = host.strip_suffix('.').unwrap_or(host); let line = text.lines().next()?;
let front = host.strip_suffix(".localhost").unwrap_or(host); let (_, query) = line.split_whitespace().nth(1)?.split_once('?')?;
let front = front.strip_suffix(".headroom").unwrap_or(front); query.split('&').find_map(|pair| {
sanitize(front) let (key, value) = pair.split_once('=')?;
(key == wanted).then(|| value.to_string())
})
}
/// The label in front of `.headroom.localhost` in a Host header: empty for
/// the domain itself, and `None` for a host that is not under the domain.
///
/// Strict, because a name that is only nearly right must not reach a proxy:
/// `localhost` or `127.0.0.1` would otherwise become a label, and so would
/// every level of `a.b.headroom.localhost`.
fn host_label(host: &str) -> Option<String> {
if host.starts_with('[') {
return None;
}
let host = host.split(':').next().unwrap_or(host).trim_end_matches('.').to_ascii_lowercase();
if host == DOMAIN {
return Some(String::new());
}
let front = host.strip_suffix(&format!(".{DOMAIN}"))?;
if front.contains('.') {
return None;
}
let name = sanitize(front);
(!name.is_empty()).then_some(name)
} }
fn answer(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> { fn answer(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> {
@@ -333,6 +437,19 @@ fn answer(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// A one-line answer for a program, without the status repeated in the body.
fn answer_plain(stream: &mut TcpStream, status: u16, line: &str) -> Result<()> {
let body = format!("{line}\n");
let head = format!(
"HTTP/1.1 {status} \r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(head.as_bytes())?;
stream.write_all(body.as_bytes())?;
stream.flush()?;
Ok(())
}
fn answer_html(stream: &mut TcpStream, body: &str) -> Result<()> { fn answer_html(stream: &mut TcpStream, body: &str) -> Result<()> {
let head = format!( let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -349,12 +466,8 @@ fn index(config: &Config) -> String {
let active = account::active(config).map(|account| account.name).unwrap_or_default(); let active = account::active(config).map(|account| account.name).unwrap_or_default();
let mut rows = String::new(); let mut rows = String::new();
for entry in names(config) { for entry in names(config) {
let kind = match entry.kind { let kind = entry.kind.describe();
Kind::Account if entry.name == active => "account, active", let address = name_url(config, &entry);
Kind::Account => "account",
Kind::Jail => "jail",
};
let address = url(config, &entry.name);
rows.push_str(&format!( rows.push_str(&format!(
"<tr><td><a href=\"{address}\">{address}</a></td><td>{kind}</td><td>{}</td><td>{}</td></tr>", "<tr><td><a href=\"{address}\">{address}</a></td><td>{kind}</td><td>{}</td><td>{}</td></tr>",
entry.port, entry.port,
@@ -367,7 +480,8 @@ fn index(config: &Config) -> String {
td,th{{padding:.25rem 1rem .25rem 0;text-align:left;vertical-align:top}}\ td,th{{padding:.25rem 1rem .25rem 0;text-align:left;vertical-align:top}}\
code{{background:#eee;padding:.1rem .3rem;border-radius:3px}}</style>\ code{{background:#eee;padding:.1rem .3rem;border-radius:3px}}</style>\
<h1>headroom.localhost</h1>\ <h1>headroom.localhost</h1>\
<p>The bare name goes to the proxy of the active account, <code>{active}</code>.</p>\ <p>Every account and every jail has a name of its own. The bare name goes to the proxy of \
the active account, <code>{active}</code>, and follows the switch.</p>\
<table><tr><th>Address<th>What<th>Port<th>Where</tr>{rows}</table>", <table><tr><th>Address<th>What<th>Port<th>Where</tr>{rows}</table>",
active = html(&active), active = html(&active),
) )
@@ -383,9 +497,34 @@ mod tests {
#[test] #[test]
fn a_host_header_gives_the_label() { fn a_host_header_gives_the_label() {
assert_eq!(host_label("work.headroom.localhost"), "work"); assert_eq!(host_label("work.headroom.localhost").as_deref(), Some("work"));
assert_eq!(host_label("headroom.localhost:80"), "headroom"); assert_eq!(host_label("Work.Headroom.Localhost.:80").as_deref(), Some("work"));
assert_eq!(host_label("my_jail.headroom.localhost"), "my-jail"); assert_eq!(host_label("my_jail.headroom.localhost").as_deref(), Some("my-jail"));
assert_eq!(host_label("headroom.headroom.localhost").as_deref(), Some("headroom"));
}
#[test]
fn the_domain_itself_is_the_bare_name() {
assert_eq!(host_label("headroom.localhost").as_deref(), Some(""));
assert_eq!(host_label("headroom.localhost:80").as_deref(), Some(""));
}
#[test]
fn a_host_outside_the_domain_is_no_name() {
assert_eq!(host_label("localhost"), None);
assert_eq!(host_label("127.0.0.1:80"), None);
assert_eq!(host_label("[::1]:80"), None);
assert_eq!(host_label("a.b.headroom.localhost"), None);
assert_eq!(host_label("evilheadroom.localhost"), None);
assert_eq!(host_label("-.headroom.localhost"), None);
}
#[test]
fn the_port_is_read_from_the_query() {
let head = b"GET /names?x=1&port=8788 HTTP/1.1\r\nHost: headroom.localhost\r\n\r\n";
assert_eq!(query(head, "port").as_deref(), Some("8788"));
assert_eq!(target(head).as_deref(), Some("/names"));
assert_eq!(query(b"GET /names HTTP/1.1\r\n\r\n", "port"), None);
} }
#[test] #[test]
+83
View File
@@ -24,6 +24,69 @@ pub fn path() -> PathBuf {
home().join(".local/bin/claude") home().join(".local/bin/claude")
} }
/// A line in a shell's startup file that defines `claude` itself.
///
/// This is the one way to be in the right profile and still land in the wrong
/// account, and it is invisible from here otherwise: an alias or a function
/// beats every executable in the `PATH`, and one that sets `CLAUDE_CONFIG_DIR`
/// beats the wrapper twice over, because the wrapper leaves a value that is
/// already set alone. The machine this tool was written on had exactly that,
/// left over from before there was a switch, and every switch looked ignored.
#[derive(Debug, Clone)]
pub struct Shadow {
pub file: PathBuf,
pub line: usize,
pub text: String,
}
/// The startup files that are read often enough to hide a definition in.
fn startup_files() -> Vec<PathBuf> {
let home = home();
let mut files = vec![
home.join(".bashrc"),
home.join(".bash_profile"),
home.join(".zshrc"),
home.join(".profile"),
home.join(".config/fish/config.fish"),
];
if let Ok(entries) = std::fs::read_dir(home.join(".config/fish/conf.d")) {
files.extend(entries.flatten().map(|entry| entry.path()));
}
files
}
/// Every definition of `claude` a shell would pick up instead of the wrapper.
pub fn shadows() -> Vec<Shadow> {
let mut out = Vec::new();
for file in startup_files() {
let Ok(body) = std::fs::read_to_string(&file) else { continue };
for (index, line) in body.lines().enumerate() {
let text = line.trim();
if text.starts_with('#') {
continue;
}
if defines_claude(text) {
out.push(Shadow { file: file.clone(), line: index + 1, text: text.to_string() });
}
}
}
out
}
/// Whether a line defines the `claude` command, in any of the three shells.
///
/// `claude-work` and the like are deliberately not matched: a second name is
/// somebody's shortcut and shadows nothing.
fn defines_claude(line: &str) -> bool {
let defined = |keyword: &str| -> bool {
let Some(rest) = line.strip_prefix(keyword) else { return false };
let rest = rest.trim_start();
let name = rest.split(|character: char| character.is_whitespace() || character == '=').next();
name.map(|name| name.trim_matches(['"', '\''])) == Some("claude")
};
defined("alias ") || defined("function ") || line.starts_with("claude()")
}
/// What the wrapper looks like right now. /// What the wrapper looks like right now.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct State { pub struct State {
@@ -122,3 +185,23 @@ fn make_executable(path: &Path) -> Result<()> {
std::fs::set_permissions(path, permissions)?; std::fs::set_permissions(path, permissions)?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_definition_of_claude_is_recognised_in_every_shell() {
assert!(defines_claude("alias claude=\"CLAUDE_CONFIG_DIR=~/.claude command claude\""));
assert!(defines_claude("alias claude='claude --verbose'"));
assert!(defines_claude("function claude --description 'x'"));
assert!(defines_claude("claude() { command claude \"$@\"; }"));
}
#[test]
fn another_command_is_not_a_shadow() {
assert!(!defines_claude("alias claude-work=\"CLAUDE_CONFIG_DIR=~/.claude-work claude\""));
assert!(!defines_claude("alias cc=claude"));
assert!(!defines_claude("function claudia"));
}
}
+46 -10
View File
@@ -12,7 +12,7 @@ use crate::account::{self, Account};
use crate::config::Config; use crate::config::Config;
use crate::headroom; use crate::headroom;
use crate::paths::shorten; use crate::paths::shorten;
use crate::router::{self, Kind}; use crate::router;
use crate::service; use crate::service;
use crate::shim; use crate::shim;
use crate::systemd; use crate::systemd;
@@ -68,7 +68,7 @@ fn account_section(
let plan = account.plan().map(|plan| format!(" ({plan})")).unwrap_or_default(); let plan = account.plan().map(|plan| format!(" ({plan})")).unwrap_or_default();
println!(" {marker} {:<10} {}{}", ui::bold(&account.name), who, ui::dim(plan)); println!(" {marker} {:<10} {}{}", ui::bold(&account.name), who, ui::dim(plan));
ui::field("profile", shorten(&account.dir)); ui::field("profile", shorten(&account.dir));
ui::field("proxy", format!("{} on port {}", router::url(config, &account.name), account.port)); ui::field("proxy", proxy_line(config, account));
ui::field("plugins", plugins_line(account)); ui::field("plugins", plugins_line(account));
match account.token_minutes_left() { match account.token_minutes_left() {
Some(minutes) if minutes < 0 => { Some(minutes) if minutes < 0 => {
@@ -122,6 +122,12 @@ fn account_section(
let _ = config; let _ = config;
} }
/// Where an account's proxy is reached. Every account has a name of its own,
/// so a session of either one can show its dashboard while both are open.
fn proxy_line(config: &Config, account: &Account) -> String {
format!("{} on port {}", router::label_url(config, &account.name), account.port)
}
fn plugins_line(account: &Account) -> String { fn plugins_line(account: &Account) -> String {
let plugins = account.plugins(); let plugins = account.plugins();
match plugins.len() { match plugins.len() {
@@ -199,6 +205,22 @@ fn command_section(active: Option<&Account>, problems: &mut Vec<Problem>) {
ui::field("this shell", format!("CLAUDE_CONFIG_DIR={explicit}")); ui::field("this shell", format!("CLAUDE_CONFIG_DIR={explicit}"));
} }
} }
// An alias or a shell function called `claude` is found before anything in
// the PATH, and one that sets CLAUDE_CONFIG_DIR is never overruled by the
// wrapper, which leaves a value that is already set alone. Every switch
// then looks ignored, and nothing else in this report would show why.
for shadow in shim::shadows() {
ui::warn("shell", format!("{}:{} defines `claude`", shorten(&shadow.file), shadow.line));
ui::field("line", ui::dim(&shadow.text));
problems.push(Problem::new(
format!(
"{}:{} defines `claude` in the shell, which wins over the wrapper and pins the account",
shorten(&shadow.file),
shadow.line
),
Some("remove that line; `claude` follows the switch by itself"),
));
}
} }
fn proxy_section(accounts: &[Account], active: Option<&Account>, problems: &mut Vec<Problem>, verbose: bool) { fn proxy_section(accounts: &[Account], active: Option<&Account>, problems: &mut Vec<Problem>, verbose: bool) {
@@ -345,20 +367,22 @@ fn router_section(config: &Config, active: Option<&Account>, problems: &mut Vec<
match active { match active {
Some(account) => ui::field( Some(account) => ui::field(
"bare name", "bare name",
format!("{} goes to {} on port {}", router::bare_url(config), account.name, account.port), format!(
"{} follows the switch, now to {} on port {}",
router::bare_url(config),
account.name,
account.port
),
), ),
None => ui::field("bare name", ui::yellow("goes nowhere: no account is active")), None => ui::field("bare name", ui::yellow("goes nowhere: no account is active")),
} }
for entry in router::names(config) { for entry in router::names(config) {
let kind = match entry.kind { let kind = entry.kind.describe();
Kind::Account => "account",
Kind::Jail => "jail",
};
let answers = router::is_listening(entry.port); let answers = router::is_listening(entry.port);
let mark = if answers { ui::green("✓") } else { ui::yellow("!") }; let mark = if answers { ui::green("✓") } else { ui::yellow("!") };
println!( println!(
" {mark} {:<38} {}", " {mark} {:<38} {}",
router::url(config, &entry.name), router::name_url(config, &entry),
ui::dim(format!("{kind}, port {} — {}", entry.port, entry.source)) ui::dim(format!("{kind}, port {} — {}", entry.port, entry.source))
); );
} }
@@ -370,10 +394,22 @@ fn jail_section(config: &Config, problems: &mut Vec<Problem>) {
return; return;
} }
ui::heading("Jails with a proxy of their own"); ui::heading("Jails with a proxy of their own");
let shadowed: Vec<String> = router::shadowed_jails(config).into_iter().map(|jail| jail.name).collect();
for jail in jails { for jail in jails {
let answers = router::is_listening(jail.port); let answers = router::is_listening(jail.port);
if answers { if answers && shadowed.contains(&jail.name) {
ui::ok(&jail.name, format!("{} — {}", router::url(config, &jail.name), jail.jail)); // The label is the account's, so printing it would hand out the
// address of another proxy. The port is the one right answer.
ui::warn(&jail.name, format!("http://127.0.0.1:{} — {}", jail.port, jail.jail));
problems.push(Problem::new(
format!(
"the jail {} has no name: the account {} holds that label, so the jail answers on port {} only",
jail.name, jail.name, jail.port
),
Some("register the jail under another name with `enqs-jail register <dir> --name <name> --force`"),
));
} else if answers {
ui::ok(&jail.name, format!("{} — {}", router::label_url(config, &jail.name), jail.jail));
} else { } else {
ui::bad(&jail.name, format!("published on port {} but nothing answers", jail.port)); ui::bad(&jail.name, format!("published on port {} but nothing answers", jail.port));
problems.push(Problem::new( problems.push(Problem::new(