Unify link and service management, add shared/private link semantics and update warnings, expose versioned JSON output with structured help and recovery suggestions, and add portable daemon supervision support.
- commit
- 0625da737cf0b3c4b964be16b323e12ce091aca2
- parent
- ff99393fc01128d57d34afb7ecce54047537f507
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 59 | +- |
| M | crates/ | 948 | +++++++++++++++++++++++++----- |
| A | crates/ | 106 | +++ |
| M | crates/ | 94 | +++- |
| M | crates/ | 4 | +- |
| M | crates/ | 6 | +- |
| M | crates/ | 12 | +- |
| M | crates/ | 14 | +- |
| A | docs/ | 29 | + |
| M | docs/ | 14 | +- |
| A | packaging/ | 14 | + |
| A | packaging/ | 14 | + |
| M | web/ | 3 | +- |
@@ -1,6 +1,6 @@
11 # dev.site22 3-dev.site is a profile for public links and private TCP services. A service stays on the3+dev.site is a profile for links and private TCP services. A service stays on the44 machine that owns it. The machine opens an outbound Iroh connection, and an authorized55 viewer uses the `devsite` CLI to map that service onto a loopback port.66 @@ -40,25 +40,25 @@ After signing in at <https://dev.site>, create a named machine credential on the
4040 devsite login dsm_...4141 ```4242 43-Expose a loopback TCP port. Services default to private:43+Host a loopback TCP port. Services default to private:4444 4545 ```bash46-devsite expose 300047-# exposed port 3000 → 127.0.0.1:3000 (private)46+devsite service host 300047+# hosting port 3000 → 127.0.0.1:3000 (private)4848 # https://dev.site/s/res_...4949 ```5050 51-Names and folders are presentation. Port exposures enter the `Services` folder unless a51+Names and folders are presentation. Hosted ports enter the `Services` folder unless a5252 different folder is named:5353 5454 ```bash55-devsite expose 5432 --name postgres --folder databases55+devsite service host 5432 --name postgres --folder databases5656 ```5757 5858 Sharing remains per service. A recipient must approve the invitation on their dashboard:5959 6060 ```bash61-devsite expose 6379 --name redis --folder databases --share @bob61+devsite service host 6379 --name redis --folder databases --share @bob6262 ```6363 6464 A permitted viewer opens the service on dev.site, clicks **Get ticket**, and passes the@@ -77,18 +77,24 @@ devsite connect dst_... --listen 127.0.0.1:15432
7777 psql -h 127.0.0.1 -p 154327878 ```7979 80-The connector refuses non-loopback listeners. Stop exposing a service with:80+The connector refuses non-loopback listeners. Stop hosting a service with:8181 8282 ```bash83-devsite unexpose postgres83+devsite service remove postgres8484 ```8585 86-Public HTTP(S) links remain ordinary profile entries:86+Links default to private, can be shared for recipient approval, or made public:8787 8888 ```bash89-devsite link add --name klot.ski --url https://klot.ski --public --folder Games89+devsite link set --name runbook --url https://example.com/runbook90+devsite link set --name staging --url https://staging.example.com --share @bob91+devsite link set --name klot.ski --url https://klot.ski --public --folder Games9092 ```9193 94+Setting an existing link or hosting an existing service prints the visibility, recipient,95+destination, and folder changes before applying the upsert. Changing a shared link's URL96+requires its recipients to approve the new destination again.97+9298 ## Daemon9399 94100 `devsite daemon run` is the portable foreground service entrypoint. A service manager only@@ -98,20 +104,41 @@ needs to keep that command alive:
98104 devsite daemon run99105 ```100106 107+Every command accepts `--json` for automation. Finite commands emit one success or error108+object; `connect` and `daemon run` emit newline-delimited lifecycle events. JSON mode never109+prompts. Command-specific `--help` is available as structured JSON, and failures include a110+recovery suggestion. The complete stdout and exit-code contract is in111+[`docs/json-output.md`](docs/json-output.md).112+113+Homebrew installs can supervise it at login on macOS or Linux:114+115+```bash116+brew services start devsite117+```118+119+Native Linux packages may install `packaging/systemd/devsite.service` as a user unit:120+121+```bash122+systemctl --user enable --now devsite.service123+```124+101125 The endpoint identity persists in the devsite config directory. The daemon registers that102126 public endpoint id with the control plane once at startup, publishes its address through103127 Iroh, and reloads service targets from the local config every two seconds. Adding or104128 removing a service does not require restarting it.105129 106-`devsite status` prints the config location, pinned signing key, and locally served ports.130+`devsite daemon status` reports whether this config directory has a live daemon. The same131+liveness appears in `devsite status` alongside the config location, pinned signing key, and132+locally served ports. The lock is released by the operating system on exit, so a crash does133+not leave a stale running state.107134 108135 ## Profiles and folders109136 110137 A profile is a list of ordinary links and TCP services. Folders are repeated labels on111138 entries and exist only to group the UI; they are not authorization containers. Accepted112139 shares appear as ordinary rows in those folders with their owner noted, not in a separate113-top-level sharing section. Every service retains its own visibility, invitation state,114-revocation, and capability checks.140+top-level sharing section. Every site retains its own visibility, invitation state, and141+revocation; services additionally enforce access for every tunnel connection.115142 116143 The signed-in homepage is the dashboard. It manages approved and pending shares, revocable117144 machine credentials, the private-only profile setting, and logout. Themes remain a bounded@@ -163,14 +190,14 @@ either is intentionally disruptive.
163190 | `crates/devsite-proto` | Opaque ids, signed capabilities, and the TCP stream handshake. |164191 | `crates/devsite-client` | Native Iroh viewer endpoint and authorized service streams. |165192 | `crates/devsite-daemon` | Capability verification and fixed-target TCP forwarding. |166-| `crates/devsite-cli` | Login, profiles, links, expose/connect, themes, and daemon lifecycle. |193+| `crates/devsite-cli` | Login, links, service hosting/connect, themes, and daemon lifecycle. |167194 | `crates/devsite-server` | Axum/SQLite control plane and capability issuance. |168195 | `web/` | Semantic HTML, plain JavaScript, Pico CSS, and vendored fonts. |169196 170197 ## Security boundaries171198 172199 - A peer supplies only a signed capability. It never supplies the daemon's target address.173-- `devsite expose PORT` always stores `127.0.0.1:PORT`; port zero is rejected.200+- `devsite service host PORT` always stores `127.0.0.1:PORT`; port zero is rejected.174201 - `devsite connect` listens only on a loopback address.175202 - Browser-minted tickets are short-lived, stored only as hashes, and consumed once. A176203 successful redemption becomes a client-key-bound tunnel session kept only in CLI memory.@@ -1,14 +1,14 @@
1-//! `devsite` — configure a profile and expose local services.1+//! `devsite` — configure a profile and host local services.22 33 mod client;44 55 use anyhow::{bail, Context, Result};6-use clap::{Parser, Subcommand};6+use clap::{error::ErrorKind, Parser, Subcommand};77 use devsite_client::{ServiceStream, ViewerEndpoint};8-use devsite_daemon::config::{DaemonConfig, ExposedResource, Paths, Visibility};8+use devsite_daemon::config::{DaemonConfig, HostedService, Paths, Visibility};99 use devsite_daemon::Daemon;1010 use devsite_proto::SignedCapability;11-use serde::Deserialize;11+use serde::{Deserialize, Serialize};1212 use std::net::{IpAddr, Ipv4Addr, SocketAddr};1313 use std::sync::Arc;1414 use tokio::io::{copy, AsyncWriteExt};@@ -20,7 +20,8 @@ use crate::client::ControlPlane;
2020 2121 2222 23-23+24+2425 2526 struct Cli {2627 /// Control plane base URL.@@ -32,6 +33,10 @@ struct Cli {
3233 )]3334 server: String,3435 36+ /// Emit stable JSON on stdout. Resident commands emit newline-delimited events.37+ 38+ json: bool,39+3540 3641 command: Command,3742 }@@ -43,26 +48,12 @@ enum Command {
4348 /// Session token shown by the website after signing in.4449 token: Option<String>,4550 },46- /// Profile management.47- 48- Profile(ProfileCommand),49- /// Add an ordinary external link.51+ /// Manage ordinary external links.5052 5153 Link(LinkCommand),52- /// Expose a local service.53- Expose {54- /// Local TCP port on 127.0.0.1.55- port: u16,56- /// Presentation name. Defaults to `port PORT`.57- 58- name: Option<String>,59- /// Invite a specific user to accept the share, e.g. --share @bob. Repeatable.60- 61- share: Vec<String>,62- /// Presentation folder. TCP services default to `Services`.63- 64- folder: Option<String>,65- },54+ /// Host local TCP services.55+ 56+ Service(ServiceCommand),6657 /// Forward a local TCP port to a service you may access.6758 Connect {6859 /// Short-lived connection ticket minted on dev.site.@@ -71,11 +62,6 @@ enum Command {
7162 7263 listen: SocketAddr,7364 },74- /// Stop exposing a local service and take it off your profile.75- Unexpose {76- /// The name it was exposed under.77- name: String,78- },7965 /// Profile appearance.8066 8167 Theme(ThemeCommand),@@ -86,35 +72,63 @@ enum Command {
8672 Daemon(DaemonCommand),8773 }8874 89-90-enum ProfileCommand {91- /// Claim a handle, e.g. `devsite profile create @alice`.92- Create { handle: String },93-}94-9575 9676 enum LinkCommand {97- Add {77+ /// Create or replace a named link.78+ Set {79+ /// Stable presentation name used to identify later updates and removal.9880 9981 name: String,82+ /// HTTP(S) destination without embedded credentials.10083 10184 url: String,102- 85+ /// Make the link visible to everyone. Without this or --share it is private.86+ 10387 public: bool,88+ /// Invite a specific user to accept the link. Repeatable.89+ 90+ share: Vec<String>,10491 /// File it under a folder on your profile. Leaving it off takes it out of10592 /// whatever folder it was in.10693 10794 folder: Option<String>,10895 },109- /// Take a link off your profile. Re-running `add` with the same name edits110- /// it in place; this is for when it should not be there at all.111- Remove { name: String },96+ /// Take a link off your profile.97+ Remove {98+ /// Name previously passed to `link set`.99+ name: String,100+ },101+}102+103+104+enum ServiceCommand {105+ /// Host a loopback TCP port as a named service.106+ Host {107+ /// Local TCP port on 127.0.0.1.108+ port: u16,109+ /// Presentation name. Defaults to `port PORT`.110+ 111+ name: Option<String>,112+ /// Invite a specific user to accept the share, e.g. --share @bob. Repeatable.113+ 114+ share: Vec<String>,115+ /// Presentation folder. TCP services default to `Services`.116+ 117+ folder: Option<String>,118+ },119+ /// Stop hosting a service and take it off your profile.120+ Remove {121+ /// Name previously passed to `service host`.122+ name: String,123+ },112124 }113125 114126 115127 enum DaemonCommand {116128 /// Run the daemon in the foreground.117129 Run,130+ /// Report whether a daemon is serving this machine's config.131+ Status,118132 }119133 120134 /// A theme is a list of `--pico-*` declarations, checked by the control plane@@ -124,13 +138,97 @@ enum ThemeCommand {
124138 /// Print the theme currently applied to your profile.125139 Show,126140 /// Replace your theme with declarations read from a file, or `-` for stdin.127- Set { file: String },141+ Set {142+ /// CSS declaration file, or `-` to read stdin.143+ file: String,144+ },128145 /// Remove your theme and go back to the defaults.129146 Clear,130147 /// List every property a theme may set, and what each one accepts.131148 Properties,132149 }133150 151+impl Command {152+ fn name(&self) -> &'static str {153+ match self {154+ Self::Login { .. } => "login",155+ Self::Link(LinkCommand::Set { .. }) => "link.set",156+ Self::Link(LinkCommand::Remove { .. }) => "link.remove",157+ Self::Service(ServiceCommand::Host { .. }) => "service.host",158+ Self::Service(ServiceCommand::Remove { .. }) => "service.remove",159+ Self::Connect { .. } => "connect",160+ Self::Theme(ThemeCommand::Show) => "theme.show",161+ Self::Theme(ThemeCommand::Set { .. }) => "theme.set",162+ Self::Theme(ThemeCommand::Clear) => "theme.clear",163+ Self::Theme(ThemeCommand::Properties) => "theme.properties",164+ Self::Status => "status",165+ Self::Daemon(DaemonCommand::Run) => "daemon.run",166+ Self::Daemon(DaemonCommand::Status) => "daemon.status",167+ }168+ }169+}170+171+fn help_target(args: &[std::ffi::OsString]) -> Option<String> {172+ let mut words = Vec::new();173+ let mut skip_value = false;174+ for arg in args.iter().skip(1) {175+ let arg = arg.to_string_lossy();176+ if skip_value {177+ skip_value = false;178+ continue;179+ }180+ if arg == "--server" {181+ skip_value = true;182+ continue;183+ }184+ if arg.starts_with('-') || arg.starts_with("http://") || arg.starts_with("https://") {185+ continue;186+ }187+ words.push(arg.into_owned());188+ }189+190+ let top = words.first()?.as_str();191+ if !matches!(192+ top,193+ "login" | "link" | "service" | "connect" | "theme" | "status" | "daemon"194+ ) {195+ return None;196+ }197+ let child = words.get(1).map(String::as_str);198+ let child_is_command = matches!(199+ (top, child),200+ ("link", Some("set" | "remove"))201+ | ("service", Some("host" | "remove"))202+ | ("theme", Some("show" | "set" | "clear" | "properties"))203+ | ("daemon", Some("run" | "status"))204+ );205+ Some(if child_is_command {206+ format!("{top}.{}", child.unwrap())207+ } else {208+ top.to_string()209+ })210+}211+212+fn help_suggestion(command: Option<&str>) -> String {213+ let path = command.map(|value| value.replace('.', " "));214+ match path {215+ Some(path) => format!("Run `devsite {path} --help` for valid arguments."),216+ None => "Run `devsite --help` to list available commands.".to_string(),217+ }218+}219+220+fn runtime_suggestions(command: &str, err: &anyhow::Error) -> Vec<String> {221+ let mut suggestions = Vec::new();222+ let message = format!("{err:#}");223+ if message.contains("not signed in") || message.contains("run `devsite login` first") {224+ suggestions.push(225+ "Create a machine credential on dev.site, then run `devsite login TOKEN`.".to_string(),226+ );227+ }228+ suggestions.push(help_suggestion(Some(command)));229+ suggestions230+}231+134232 135233 struct ThemeResponse {136234 css: String,@@ -141,7 +239,7 @@ struct ThemeProperties {
141239 properties: Vec<ThemeProperty>,142240 }143241 144-242+145243 struct ThemeProperty {146244 name: String,147245 accepts: String,@@ -158,11 +256,6 @@ struct PublicConfig {
158256 minimum_cli_version: String,159257 }160258 161-162-struct HandleResponse {163- handle: String,164-}165-166259 167260 struct CreateResourceResponse {168261 resource_id: String,@@ -192,12 +285,64 @@ struct Resource {
192285 resource_id: String,193286 name: String,194287 kind: String,288+ visibility: String,195289 /// Set for links only; a service's target never leaves the machine serving it.196290 url: Option<String>,291+ folder: Option<String>,292+ 293+ shares: Vec<ResourceShare>,294+}295+296+297+struct ResourceShare {298+ handle: String,197299 }198300 199301 200-async fn main() -> Result<()> {302+async fn main() {303+ let args = std::env::args_os().collect::<Vec<_>>();304+ let json_requested = args.iter().any(|arg| arg == "--json");305+ let cli = match Cli::try_parse_from(&args) {306+ Ok(cli) => cli,307+ Err(err) if err.kind() == ErrorKind::DisplayHelp && json_requested => {308+ Output::json().success(309+ "help",310+ serde_json::json!({311+ "command": help_target(&args),312+ "text": err.to_string(),313+ }),314+ );315+ return;316+ }317+ Err(err) if err.kind() == ErrorKind::DisplayVersion && json_requested => {318+ Output::json().success(319+ "version",320+ serde_json::json!({ "text": err.to_string().trim() }),321+ );322+ return;323+ }324+ Err(err)325+ if matches!(326+ err.kind(),327+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion328+ ) =>329+ {330+ err.print().ok();331+ return;332+ }333+ Err(err) if json_requested => {334+ let command = help_target(&args);335+ Output::json().error(336+ "usage",337+ command.as_deref(),338+ &anyhow::anyhow!(err.to_string()),339+ &[help_suggestion(command.as_deref())],340+ );341+ std::process::exit(2);342+ }343+ Err(err) => err.exit(),344+ };345+201346 tracing_subscriber::fmt()202347 .with_env_filter(203348 tracing_subscriber::EnvFilter::try_from_default_env()@@ -206,34 +351,132 @@ async fn main() -> Result<()> {
206351 .with_target(false)207352 .init();208353 209- let cli = Cli::parse();354+ let output = Output { json: cli.json };355+ let command = cli.command.name();356+ if let Err(err) = run(cli, output).await {357+ let suggestions = runtime_suggestions(command, &err);358+ output.error("runtime", Some(command), &err, &suggestions);359+ std::process::exit(1);360+ }361+}362+363+364+struct Output {365+ json: bool,366+}367+368+impl Output {369+ const fn json() -> Self {370+ Self { json: true }371+ }372+373+ fn success(&self, command: &str, result: serde_json::Value) {374+ if self.json {375+ self.write(&serde_json::json!({376+ "schema_version": 1,377+ "ok": true,378+ "command": command,379+ "result": result,380+ }));381+ }382+ }383+384+ fn event(&self, command: &str, event: &str, data: serde_json::Value) {385+ if self.json {386+ self.write(&serde_json::json!({387+ "schema_version": 1,388+ "ok": true,389+ "command": command,390+ "event": event,391+ "data": data,392+ }));393+ }394+ }395+396+ fn error(397+ &self,398+ kind: &str,399+ command: Option<&str>,400+ err: &anyhow::Error,401+ suggestions: &[String],402+ ) {403+ if self.json {404+ let causes = err405+ .chain()406+ .skip(1)407+ .map(ToString::to_string)408+ .collect::<Vec<_>>();409+ self.write(&serde_json::json!({410+ "schema_version": 1,411+ "ok": false,412+ "command": command,413+ "error": {414+ "kind": kind,415+ "message": err.to_string(),416+ "causes": causes,417+ "suggestions": suggestions,418+ },419+ }));420+ } else {421+ eprintln!("error: {err:#}");422+ for suggestion in suggestions {423+ eprintln!("suggestion: {suggestion}");424+ }425+ }426+ }427+428+ fn write(&self, value: &serde_json::Value) {429+ use std::io::Write;430+431+ let stdout = std::io::stdout();432+ let mut stdout = stdout.lock();433+ serde_json::to_writer(&mut stdout, value).expect("JSON values serialize");434+ writeln!(stdout).expect("JSON output writes");435+ stdout.flush().expect("JSON output flushes");436+ }437+}438+439+async fn run(cli: Cli, output: Output) -> Result<()> {210440 let paths = Paths::discover()?;211441 212442 match cli.command {213- Command::Login { token } => login(&paths, &cli.server, token).await,214- Command::Profile(ProfileCommand::Create { handle }) => {215- create_profile(&paths, &cli.server, &handle).await216- }217- Command::Link(LinkCommand::Add {443+ Command::Login { token } => login(&paths, &cli.server, token, output).await,444+ Command::Link(LinkCommand::Set {218445 name,219446 url,220447 public,448+ share,221449 folder,222- }) => add_link(&paths, &cli.server, &name, &url, public, folder).await,450+ }) => {451+ set_link(452+ &paths,453+ &cli.server,454+ &name,455+ &url,456+ public,457+ share,458+ folder,459+ output,460+ )461+ .await462+ }223463 Command::Link(LinkCommand::Remove { name }) => {224- remove_link(&paths, &cli.server, &name).await464+ remove_link(&paths, &cli.server, &name, output).await225465 }226- Command::Unexpose { name } => unexpose(&paths, &cli.server, &name).await,227- Command::Expose {466+ Command::Service(ServiceCommand::Host {228467 port,229468 name,230469 share,231470 folder,232- } => expose(&paths, &cli.server, port, name, share, folder).await,233- Command::Connect { ticket, listen } => connect(&cli.server, &ticket, listen).await,234- Command::Theme(command) => theme(&paths, &cli.server, command).await,235- Command::Status => status(&paths),236- Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server).await,471+ }) => host_service(&paths, &cli.server, port, name, share, folder, output).await,472+ Command::Service(ServiceCommand::Remove { name }) => {473+ remove_service(&paths, &cli.server, &name, output).await474+ }475+ Command::Connect { ticket, listen } => connect(&cli.server, &ticket, listen, output).await,476+ Command::Theme(command) => theme(&paths, &cli.server, command, output).await,477+ Command::Status => status(&paths, output),478+ Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server, output).await,479+ Command::Daemon(DaemonCommand::Status) => daemon_status(&paths, output),237480 }238481 }239482 @@ -245,9 +488,10 @@ fn load_authenticated(paths: &Paths) -> Result<DaemonConfig> {
245488 Ok(config)246489 }247490 248-async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()> {491+async fn login(paths: &Paths, server: &str, token: Option<String>, output: Output) -> Result<()> {249492 let token = match token {250493 Some(token) => token,494+ None if output.json => bail!("TOKEN is required with --json"),251495 None => {252496 println!("Open {server}, create a machine credential, and paste it here.");253497 print!("token: ");@@ -289,59 +533,98 @@ async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()>
289533 .get("handle")290534 .and_then(|h| h.as_str())291535 .unwrap_or("(no handle yet)");292- println!("signed in as {handle}");293- println!(294- "server requires CLI {} or newer",295- server_config.minimum_cli_version296- );297- println!(298- "pinned control plane key {}",299- config.control_plane_key.unwrap()300- );301- Ok(())302-}303-304-async fn create_profile(paths: &Paths, server: &str, handle: &str) -> Result<()> {305- let config = load_authenticated(paths)?;306- let api = ControlPlane::new(server, config.machine_credential.clone());307- let response: HandleResponse = api308- .post(309- "/api/profile",310- &serde_json::json!({ "handle": handle.trim_start_matches('@') }),311- )312- .await?;313- println!("profile ready at {server}/@{}", response.handle);536+ let control_plane_key = config.control_plane_key.unwrap();537+ if output.json {538+ output.success(539+ "login",540+ serde_json::json!({541+ "handle": handle,542+ "server": server.trim_end_matches('/'),543+ "minimum_cli_version": server_config.minimum_cli_version,544+ "control_plane_key": control_plane_key,545+ }),546+ );547+ } else {548+ println!("signed in as {handle}");549+ println!(550+ "server requires CLI {} or newer",551+ server_config.minimum_cli_version552+ );553+ println!("pinned control plane key {control_plane_key}");554+ }314555 Ok(())315556 }316557 317-async fn add_link(558+559+async fn set_link(318560 paths: &Paths,319561 server: &str,320562 name: &str,321563 url: &str,322564 public: bool,565+ share: Vec<String>,323566 folder: Option<String>,567+ output: Output,324568 ) -> Result<()> {325- if !public {326- bail!("links are external URLs and are always public; pass --public to confirm");327- }569+ let visibility = if public {570+ Visibility::Public571+ } else if share.is_empty() {572+ Visibility::Private573+ } else {574+ Visibility::Shared575+ };576+ let share = normalized_handles(&share);328577 let config = load_authenticated(paths)?;329578 let api = ControlPlane::new(server, config.machine_credential.clone());330- let _: CreateResourceResponse = api579+ let update = warn_on_upsert(580+ &api,581+ "link",582+ name,583+ visibility,584+ Some(url),585+ folder.as_deref(),586+ &share,587+ )588+ .await?;589+ if !output.json {590+ if let Some(update) = &update {591+ update.print_human();592+ }593+ }594+ let response: CreateResourceResponse = api331595 .post(332596 "/api/resources",333597 &serde_json::json!({334598 "name": name,335599 "kind": "link",336- "visibility": "public",600+ "visibility": visibility_str(visibility),337601 "url": url,602+ "share_with": share,338603 "folder": folder,339604 }),340605 )341606 .await?;342- println!("added link {name} → {url}");343- if let Some(folder) = &folder {344- println!(" in {folder}");607+ if output.json {608+ output.success(609+ "link.set",610+ serde_json::json!({611+ "resource_id": response.resource_id,612+ "name": name,613+ "url": url,614+ "visibility": visibility_str(visibility),615+ "folder": folder,616+ "recipients": share,617+ "update": update,618+ }),619+ );620+ } else {621+ println!("set link {name} → {url} ({})", visibility_str(visibility));622+ if let Some(folder) = &folder {623+ println!(" in {folder}");624+ }625+ if !share.is_empty() {626+ println!(" invited {}", display_handles(&share));627+ }345628 }346629 Ok(())347630 }@@ -370,9 +653,17 @@ async fn remove_resource(paths: &Paths, server: &str, name: &str, kind: &str) ->
370653 Ok(resource)371654 }372655 373-async fn remove_link(paths: &Paths, server: &str, name: &str) -> Result<()> {656+async fn remove_link(paths: &Paths, server: &str, name: &str, output: Output) -> Result<()> {374657 let removed = remove_resource(paths, server, name, "link").await?;375- println!("removed link {name} → {}", removed.url.unwrap_or_default());658+ let url = removed.url.unwrap_or_default();659+ if output.json {660+ output.success(661+ "link.remove",662+ serde_json::json!({ "resource_id": removed.resource_id, "name": name, "url": url }),663+ );664+ } else {665+ println!("removed link {name} → {url}");666+ }376667 Ok(())377668 }378669 @@ -381,34 +672,47 @@ async fn remove_link(paths: &Paths, server: &str, name: &str) -> Result<()> {
381672 /// Both halves matter and in this order: the control plane forgets it, then this382673 /// machine does. A daemon that kept serving a resource the control plane has383674 /// deleted is harmless — no capability can be issued for it any more — but a384-/// config that still lists it would re-register the name on the next `expose`.385-async fn unexpose(paths: &Paths, server: &str, name: &str) -> Result<()> {386- remove_resource(paths, server, name, "service").await?;675+/// config that still lists it would re-register the name the next time it is hosted.676+async fn remove_service(paths: &Paths, server: &str, name: &str, output: Output) -> Result<()> {677+ let removed = remove_resource(paths, server, name, "service").await?;387678 388679 let mut config = paths.load_config()?;389680 let before = config.resources.len();390681 config.resources.retain(|r| r.name != name);391682 paths.save_config(&config)?;392683 393- println!("unexposed {name}");394- if config.resources.len() == before {395- println!(" (this machine was not serving it; removed from your profile)");684+ let was_hosted_here = config.resources.len() != before;685+ if output.json {686+ output.success(687+ "service.remove",688+ serde_json::json!({689+ "resource_id": removed.resource_id,690+ "name": name,691+ "was_hosted_here": was_hosted_here,692+ }),693+ );396694 } else {397- println!(" a running daemon will stop serving it automatically");695+ println!("removed service {name}");696+ if was_hosted_here {697+ println!(" a running daemon will stop serving it automatically");698+ } else {699+ println!(" (this machine was not serving it; removed from your profile)");700+ }398701 }399702 Ok(())400703 }401704 402-async fn expose(705+async fn host_service(403706 paths: &Paths,404707 server: &str,405708 port: u16,406709 name: Option<String>,407710 share: Vec<String>,408711 folder: Option<String>,712+ output: Output,409713 ) -> Result<()> {410714 if port == 0 {411- bail!("port 0 cannot be exposed");715+ bail!("port 0 cannot be hosted");412716 }413717 let name = name.unwrap_or_else(|| format!("port {port}"));414718 let folder = Some(folder.unwrap_or_else(|| "Services".to_string()));@@ -418,9 +722,25 @@ async fn expose(
418722 } else {419723 Visibility::Shared420724 };725+ let share = normalized_handles(&share);421726 422727 let config = load_authenticated(paths)?;423728 let api = ControlPlane::new(server, config.machine_credential.clone());729+ let update = warn_on_upsert(730+ &api,731+ "service",732+ &name,733+ visibility,734+ None,735+ folder.as_deref(),736+ &share,737+ )738+ .await?;739+ if !output.json {740+ if let Some(update) = &update {741+ update.print_human();742+ }743+ }424744 425745 let response: CreateResourceResponse = api426746 .post(@@ -430,7 +750,7 @@ async fn expose(
430750 "kind": "service",431751 "visibility": visibility_str(visibility),432752 // Deliberately absent: the local target never leaves this machine.433- "share_with": share.iter().map(|h| h.trim_start_matches('@')).collect::<Vec<_>>(),753+ "share_with": share,434754 "folder": folder,435755 }),436756 )@@ -443,7 +763,7 @@ async fn expose(
443763 444764 let mut config = paths.load_config()?;445765 config.resources.retain(|r| r.name != name);446- config.resources.push(ExposedResource {766+ config.resources.push(HostedService {447767 resource_id,448768 name: name.clone(),449769 target,@@ -451,19 +771,38 @@ async fn expose(
451771 });452772 paths.save_config(&config)?;453773 454- println!("exposed {name} → {target} ({})", visibility_str(visibility));455- if let Some(folder) = &folder {456- println!(" in {folder}");457- }458- if !share.is_empty() {459- println!(" invited {}", share.join(", "));774+ let daemon_running = paths.daemon_running()?;775+ if output.json {776+ output.success(777+ "service.host",778+ serde_json::json!({779+ "resource_id": resource_id.to_string(),780+ "name": name,781+ "target": target.to_string(),782+ "visibility": visibility_str(visibility),783+ "folder": folder,784+ "recipients": share,785+ "url": format!("{server}/s/{resource_id}"),786+ "daemon": daemon_state(daemon_running),787+ "update": update,788+ }),789+ );790+ } else {791+ println!("hosting {name} → {target} ({})", visibility_str(visibility));792+ if let Some(folder) = &folder {793+ println!(" in {folder}");794+ }795+ if !share.is_empty() {796+ println!(" invited {}", display_handles(&share));797+ }798+ println!(" {server}/s/{resource_id}");799+ println!();800+ print_daemon_status(paths, output)?;460801 }461- println!(" {server}/s/{resource_id}");462- println!("\nrun `devsite daemon run` to serve it; an existing daemon reloads automatically.");463802 Ok(())464803 }465804 466-async fn connect(server: &str, ticket: &str, listen: SocketAddr) -> Result<()> {805+async fn connect(server: &str, ticket: &str, listen: SocketAddr, output: Output) -> Result<()> {467806 if !listen.ip().is_loopback() {468807 bail!("--listen must be a loopback address; refusing to publish the tunnel on the LAN");469808 }@@ -485,19 +824,37 @@ async fn connect(server: &str, ticket: &str, listen: SocketAddr) -> Result<()> {
485824 .with_context(|| format!("binding local listener {listen}"))?;486825 let bound = listener.local_addr()?;487826 488- println!(489- "connected to {} ({server}/s/{})",490- redeemed.name, redeemed.resource_id491- );492- println!(" listening on {bound}");493- println!(" session expires at unix time {}", redeemed.expires_at);494- println!(" press Ctrl-C to stop");827+ if output.json {828+ output.event(829+ "connect",830+ "listening",831+ serde_json::json!({832+ "name": redeemed.name,833+ "resource_id": redeemed.resource_id,834+ "resource_url": format!("{server}/s/{}", redeemed.resource_id),835+ "listen": bound.to_string(),836+ "expires_at": redeemed.expires_at,837+ }),838+ );839+ } else {840+ println!(841+ "connected to {} ({server}/s/{})",842+ redeemed.name, redeemed.resource_id843+ );844+ println!(" listening on {bound}");845+ println!(" session expires at unix time {}", redeemed.expires_at);846+ println!(" press Ctrl-C to stop");847+ }495848 496849 loop {497850 let (local, peer) = tokio::select! {498851 accepted = listener.accept() => accepted.context("accepting local connection")?,499852 _ = tokio::signal::ctrl_c() => {500- println!("\nshutting down");853+ if output.json {854+ output.event("connect", "shutdown", serde_json::json!({}));855+ } else {856+ println!("\nshutting down");857+ }501858 if let Err(err) = api.delete("/api/tunnel/session").await {502859 tracing::warn!("could not revoke tunnel session during shutdown: {err:#}");503860 }@@ -549,14 +906,21 @@ async fn forward_connection(
549906 Ok(())550907 }551908 552-async fn theme(paths: &Paths, server: &str, command: ThemeCommand) -> Result<()> {909+async fn theme(paths: &Paths, server: &str, command: ThemeCommand, output: Output) -> Result<()> {553910 // The property list is public: it is the documentation, and it comes from554911 // the binary that enforces it rather than from a copy kept here.555912 if let ThemeCommand::Properties = command {556913 let api = ControlPlane::new(server, None);557914 let listing: ThemeProperties = api.get("/api/theme/properties").await?;558- for property in listing.properties {559- println!("{:<44} {}", property.name, property.accepts);915+ if output.json {916+ output.success(917+ "theme.properties",918+ serde_json::json!({ "properties": listing.properties }),919+ );920+ } else {921+ for property in listing.properties {922+ println!("{:<44} {}", property.name, property.accepts);923+ }560924 }561925 return Ok(());562926 }@@ -568,7 +932,9 @@ async fn theme(paths: &Paths, server: &str, command: ThemeCommand) -> Result<()>
568932 ThemeCommand::Properties => unreachable!("handled above, before authentication"),569933 ThemeCommand::Show => {570934 let theme: ThemeResponse = api.get("/api/theme").await?;571- if theme.css.trim().is_empty() {935+ if output.json {936+ output.success("theme.show", serde_json::json!({ "css": theme.css }));937+ } else if theme.css.trim().is_empty() {572938 println!("no theme set — run `devsite theme set <file>`");573939 } else {574940 print!("{}", theme.css);@@ -586,19 +952,162 @@ async fn theme(paths: &Paths, server: &str, command: ThemeCommand) -> Result<()>
586952 let saved: ThemeResponse = api587953 .put("/api/theme", &serde_json::json!({ "css": css }))588954 .await?;589- print!("{}", saved.css);590- println!("theme saved");955+ if output.json {956+ output.success("theme.set", serde_json::json!({ "css": saved.css }));957+ } else {958+ print!("{}", saved.css);959+ println!("theme saved");960+ }591961 }592962 ThemeCommand::Clear => {593963 let _: ThemeResponse = api594964 .put("/api/theme", &serde_json::json!({ "css": "" }))595965 .await?;596- println!("theme cleared");966+ if output.json {967+ output.success("theme.clear", serde_json::json!({ "css": "" }));968+ } else {969+ println!("theme cleared");970+ }597971 }598972 }599973 Ok(())600974 }601975 976+fn normalized_handles(handles: &[String]) -> Vec<String> {977+ let mut unique = std::collections::BTreeMap::new();978+ for handle in handles {979+ let handle = handle.trim_start_matches('@');980+ unique981+ .entry(handle.to_ascii_lowercase())982+ .or_insert_with(|| handle.to_string());983+ }984+ unique.into_values().collect()985+}986+987+fn display_handles(handles: &[String]) -> String {988+ handles989+ .iter()990+ .map(|handle| format!("@{handle}"))991+ .collect::<Vec<_>>()992+ .join(", ")993+}994+995+996+async fn warn_on_upsert(997+ api: &ControlPlane,998+ kind: &str,999+ name: &str,1000+ visibility: Visibility,1001+ url: Option<&str>,1002+ folder: Option<&str>,1003+ shares: &[String],1004+) -> Result<Option<UpsertWarning>> {1005+ let listing: ResourceListing = api.get("/api/resources").await?;1006+ let Some(existing) = listing1007+ .resources1008+ .into_iter()1009+ .find(|resource| resource.kind == kind && resource.name == name)1010+ else {1011+ return Ok(None);1012+ };1013+1014+ let desired_visibility = visibility_str(visibility);1015+ let mut changes = Vec::new();1016+ if existing.visibility != desired_visibility {1017+ changes.push(UpsertChange {1018+ field: "visibility",1019+ from: existing.visibility.clone(),1020+ to: desired_visibility.to_string(),1021+ });1022+ }1023+1024+ let existing_shares = existing1025+ .shares1026+ .iter()1027+ .map(|share| share.handle.to_ascii_lowercase())1028+ .collect::<std::collections::BTreeSet<_>>();1029+ let desired_shares = shares1030+ .iter()1031+ .map(|handle| handle.to_ascii_lowercase())1032+ .collect::<std::collections::BTreeSet<_>>();1033+ if existing_shares != desired_shares {1034+ let before = existing1035+ .shares1036+ .iter()1037+ .map(|share| share.handle.clone())1038+ .collect::<Vec<_>>();1039+ changes.push(UpsertChange {1040+ field: "recipients",1041+ from: if before.is_empty() {1042+ "none".to_string()1043+ } else {1044+ display_handles(&before)1045+ },1046+ to: if shares.is_empty() {1047+ "none".to_string()1048+ } else {1049+ display_handles(shares)1050+ },1051+ });1052+ }1053+1054+ let mut requires_fresh_approval = false;1055+ if existing.url.as_deref() != url {1056+ changes.push(UpsertChange {1057+ field: "destination",1058+ from: existing.url.as_deref().unwrap_or("none").to_string(),1059+ to: url.unwrap_or("none").to_string(),1060+ });1061+ if existing.visibility == "shared" || desired_visibility == "shared" {1062+ requires_fresh_approval = true;1063+ }1064+ }1065+ if existing.folder.as_deref() != folder {1066+ changes.push(UpsertChange {1067+ field: "folder",1068+ from: existing.folder.as_deref().unwrap_or("none").to_string(),1069+ to: folder.unwrap_or("none").to_string(),1070+ });1071+ }1072+ Ok(Some(UpsertWarning {1073+ kind: kind.to_string(),1074+ name: name.to_string(),1075+ changes,1076+ requires_fresh_approval,1077+ }))1078+}1079+1080+1081+struct UpsertWarning {1082+ kind: String,1083+ name: String,1084+ changes: Vec<UpsertChange>,1085+ requires_fresh_approval: bool,1086+}1087+1088+1089+struct UpsertChange {1090+ field: &'static str,1091+ from: String,1092+ to: String,1093+}1094+1095+impl UpsertWarning {1096+ fn print_human(&self) {1097+ eprintln!("warning: updating existing {} `{}`", self.kind, self.name);1098+ if self.changes.is_empty() {1099+ eprintln!(" no visibility, recipient, destination, or folder changes");1100+ } else {1101+ for change in &self.changes {1102+ eprintln!(" {}: {} → {}", change.field, change.from, change.to);1103+ }1104+ }1105+ if self.requires_fresh_approval {1106+ eprintln!(" recipients must approve the new destination again");1107+ }1108+ }1109+}1110+6021111 fn visibility_str(visibility: Visibility) -> &'static str {6031112 match visibility {6041113 Visibility::Public => "public",@@ -607,40 +1116,131 @@ fn visibility_str(visibility: Visibility) -> &'static str {
6071116 }6081117 }6091118 610-fn status(paths: &Paths) -> Result<()> {1119+fn status(paths: &Paths, output: Output) -> Result<()> {6111120 let config = paths.load_config()?;612- println!("config {}", paths.config().display());613- println!("identity {}", paths.identity().display());614- println!(615- "server {}",616- config.server_url.as_deref().unwrap_or("(not signed in)")617- );618- println!(619- "signing key {}",620- config621- .control_plane_key622- .as_deref()623- .unwrap_or("(not pinned)")624- );625-626- if config.resources.is_empty() {627- println!("\nno services exposed yet");628- return Ok(());629- }630- println!("\nexposed services:");631- for resource in &config.resources {1121+ let daemon_running = paths.daemon_running()?;1122+ if output.json {1123+ let services = config1124+ .resources1125+ .iter()1126+ .map(|resource| {1127+ serde_json::json!({1128+ "resource_id": resource.resource_id.to_string(),1129+ "name": resource.name,1130+ "visibility": visibility_str(resource.visibility),1131+ "target": resource.target.to_string(),1132+ })1133+ })1134+ .collect::<Vec<_>>();1135+ output.success(1136+ "status",1137+ serde_json::json!({1138+ "config_path": paths.config(),1139+ "identity_path": paths.identity(),1140+ "server": config.server_url,1141+ "control_plane_key": config.control_plane_key,1142+ "daemon": daemon_state(daemon_running),1143+ "services": services,1144+ }),1145+ );1146+ } else {1147+ println!("config {}", paths.config().display());1148+ println!("identity {}", paths.identity().display());1149+ println!(1150+ "server {}",1151+ config.server_url.as_deref().unwrap_or("(not signed in)")1152+ );1153+ println!(1154+ "signing key {}",1155+ config1156+ .control_plane_key1157+ .as_deref()1158+ .unwrap_or("(not pinned)")1159+ );6321160 println!(633- " {:<16} {:<10} {}",634- resource.name,635- visibility_str(resource.visibility),636- resource.target1161+ "daemon {}",1162+ if daemon_running { "running" } else { "stopped" }6371163 );1164+ if !daemon_running {1165+ print_daemon_start_hint();1166+ }1167+1168+ if config.resources.is_empty() {1169+ println!("\nno services hosted yet");1170+ return Ok(());1171+ }1172+ println!("\nhosted services:");1173+ for resource in &config.resources {1174+ println!(1175+ " {:<16} {:<10} {}",1176+ resource.name,1177+ visibility_str(resource.visibility),1178+ resource.target1179+ );1180+ }1181+ }1182+ Ok(())1183+}1184+1185+fn daemon_status(paths: &Paths, output: Output) -> Result<()> {1186+ let running = paths.daemon_running()?;1187+ if output.json {1188+ output.success("daemon.status", daemon_state(running));1189+ Ok(())1190+ } else {1191+ print_daemon_status(paths, output)1192+ }1193+}1194+1195+fn print_daemon_status(paths: &Paths, _output: Output) -> Result<()> {1196+ if paths.daemon_running()? {1197+ println!("daemon is running; config changes reload automatically");1198+ } else {1199+ println!("daemon is stopped; this service is not reachable yet");1200+ print_daemon_start_hint();6381201 }6391202 Ok(())6401203 }6411204 642-async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {1205+fn daemon_state(running: bool) -> serde_json::Value {1206+ serde_json::json!({1207+ "running": running,1208+ "start_hints": if running { Vec::new() } else { daemon_start_hints().to_vec() },1209+ })1210+}1211+1212+fn print_daemon_start_hint() {1213+ for hint in daemon_start_hints() {1214+ println!(" {hint}");1215+ }1216+}1217+1218+1219+fn daemon_start_hints() -> &'static [&'static str] {1220+ &[1221+ "start it with `brew services start devsite`",1222+ "or keep `devsite daemon run` alive with another service manager",1223+ ]1224+}1225+1226+1227+fn daemon_start_hints() -> &'static [&'static str] {1228+ &[1229+ "start it with `systemctl --user enable --now devsite.service`",1230+ "or keep `devsite daemon run` alive with another service manager",1231+ ]1232+}1233+1234+1235+fn daemon_start_hints() -> &'static [&'static str] {1236+ &["keep `devsite daemon run` alive with your service manager"]1237+}1238+1239+async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {6431240 let config = load_authenticated(paths)?;1241+ let _daemon_lock = paths1242+ .try_daemon_lock()?1243+ .context("a daemon is already running for this config directory")?;6441244 let server_url = config6451245 .server_url6461246 .clone()@@ -659,10 +1259,6 @@ async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {
6591259 .map(|u| u.to_string())6601260 .context("no relay was assigned; check network connectivity")?;6611261 662- println!("daemon online");663- println!(" endpoint {endpoint_id}");664- println!(" relay {relay}");665-6661262 // Told to the control plane once, not on a timer.6671263 //6681264 // The endpoint id is the public half of the key in DEVSITE_HOME/identity, so@@ -679,19 +1275,37 @@ async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {
6791275 .await6801276 .context("registering this daemon with the control plane")?;6811277 1278+ if output.json {1279+ output.event(1280+ "daemon.run",1281+ "online",1282+ serde_json::json!({ "endpoint_id": endpoint_id, "relay": relay }),1283+ );1284+ } else {1285+ println!("daemon online");1286+ println!(" endpoint {endpoint_id}");1287+ println!(" relay {relay}");1288+ }1289+6821290 let reload_paths = Paths {6831291 root: paths.root.clone(),6841292 };6851293 tokio::select! {6861294 result = Arc::clone(&daemon).serve() => result?,687- result = reload_config(reload_paths, Arc::clone(&daemon)) => result?,688- _ = tokio::signal::ctrl_c() => println!("\nshutting down"),1295+ result = reload_config(reload_paths, Arc::clone(&daemon), output) => result?,1296+ _ = tokio::signal::ctrl_c() => {1297+ if output.json {1298+ output.event("daemon.run", "shutdown", serde_json::json!({}));1299+ } else {1300+ println!("\nshutting down");1301+ }1302+ },6891303 }6901304 daemon.close().await;6911305 Ok(())6921306 }6931307 694-async fn reload_config(paths: Paths, daemon: Arc<Daemon>) -> Result<()> {1308+async fn reload_config(paths: Paths, daemon: Arc<Daemon>, output: Output) -> Result<()> {6951309 let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));6961310 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);6971311 let mut last_error = None;@@ -704,7 +1318,11 @@ async fn reload_config(paths: Paths, daemon: Arc<Daemon>) -> Result<()> {
7041318 Ok(config) => {7051319 last_error = None;7061320 if daemon.replace_resources(config.resources).await {707- println!("reloaded exposed services");1321+ if output.json {1322+ output.event("daemon.run", "reloaded", serde_json::json!({}));1323+ } else {1324+ println!("reloaded hosted services");1325+ }7081326 }7091327 }7101328 Err(err) => {@@ -0,0 +1,106 @@
1+use std::process::{Command, Output};2+use std::sync::atomic::{AtomicUsize, Ordering};3+4+static NEXT_HOME: AtomicUsize = AtomicUsize::new(0);5+6+fn devsite(args: &[&str]) -> Output {7+ let suffix = NEXT_HOME.fetch_add(1, Ordering::Relaxed);8+ let home =9+ std::env::temp_dir().join(format!("devsite-json-test-{}-{suffix}", std::process::id()));10+ let output = Command::new(env!("CARGO_BIN_EXE_devsite"))11+ .args(args)12+ .env("DEVSITE_HOME", &home)13+ .output()14+ .unwrap();15+ if home.exists() {16+ std::fs::remove_dir_all(home).unwrap();17+ }18+ output19+}20+21+fn json(output: &Output) -> serde_json::Value {22+ serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {23+ panic!(24+ "stdout was not one JSON value: {err}\n{}",25+ String::from_utf8_lossy(&output.stdout)26+ )27+ })28+}29+30+31+fn global_json_flag_structures_success() {32+ let output = devsite(&["--json", "status"]);33+ assert!(output.status.success());34+ let value = json(&output);35+ assert_eq!(value["schema_version"], 1);36+ assert_eq!(value["ok"], true);37+ assert_eq!(value["command"], "status");38+ assert_eq!(value["result"]["daemon"]["running"], false);39+ assert!(value["result"]["services"].is_array());40+ assert!(output.stderr.is_empty());41+}42+43+44+fn global_json_flag_works_after_a_subcommand() {45+ let output = devsite(&["daemon", "status", "--json"]);46+ assert!(output.status.success());47+ let value = json(&output);48+ assert_eq!(value["command"], "daemon.status");49+ assert_eq!(value["result"]["running"], false);50+}51+52+53+fn json_structures_runtime_errors() {54+ let output = devsite(&["--json", "service", "host", "0"]);55+ assert_eq!(output.status.code(), Some(1));56+ let value = json(&output);57+ assert_eq!(value["ok"], false);58+ assert_eq!(value["error"]["kind"], "runtime");59+ assert_eq!(value["error"]["message"], "port 0 cannot be hosted");60+ assert!(output.stderr.is_empty());61+}62+63+64+fn json_structures_usage_errors() {65+ let output = devsite(&["--json", "not-a-command"]);66+ assert_eq!(output.status.code(), Some(2));67+ let value = json(&output);68+ assert_eq!(value["ok"], false);69+ assert_eq!(value["error"]["kind"], "usage");70+ assert!(value["error"]["suggestions"][0]71+ .as_str()72+ .unwrap()73+ .contains("devsite --help"));74+ assert!(output.stderr.is_empty());75+}76+77+78+fn json_login_never_prompts() {79+ let output = devsite(&["--json", "login"]);80+ assert_eq!(output.status.code(), Some(1));81+ let value = json(&output);82+ assert_eq!(value["error"]["message"], "TOKEN is required with --json");83+ assert!(!String::from_utf8_lossy(&output.stdout).contains("token:"));84+}85+86+87+fn json_structures_command_help() {88+ let output = devsite(&["link", "set", "--json", "--help"]);89+ assert!(output.status.success());90+ let value = json(&output);91+ assert_eq!(value["command"], "help");92+ assert_eq!(value["result"]["command"], "link.set");93+ assert!(value["result"]["text"]94+ .as_str()95+ .unwrap()96+ .contains("Create or replace a named link"));97+}98+99+100+fn human_runtime_errors_end_with_a_recovery_suggestion() {101+ let output = devsite(&["service", "host", "0"]);102+ assert_eq!(output.status.code(), Some(1));103+ let stderr = String::from_utf8_lossy(&output.stderr);104+ assert!(stderr.contains("error: port 0 cannot be hosted"));105+ assert!(stderr.contains("suggestion: Run `devsite service host --help`"));106+}@@ -5,6 +5,7 @@
55 //! it is written exclusively by the local `devsite` CLI.66 77 use std::collections::HashMap;8+use std::fs::{File, OpenOptions, TryLockError};89 use std::net::SocketAddr;910 use std::path::{Path, PathBuf};1011 @@ -23,7 +24,7 @@ pub enum Visibility {
2324 }2425 2526 26-pub struct ExposedResource {27+pub struct HostedService {2728 pub resource_id: ResourceId,2829 pub name: String,2930 /// Fixed local TCP target. The peer chooses a resource, never an address.@@ -46,7 +47,7 @@ pub struct DaemonConfig {
4647 /// every request, which is the correct failure direction.4748 pub control_plane_key: Option<String>,4849 49- pub resources: Vec<ExposedResource>,50+ pub resources: Vec<HostedService>,5051 }5152 5253 impl DaemonConfig {@@ -78,6 +79,14 @@ pub struct Paths {
7879 pub root: PathBuf,7980 }8081 82+/// An exclusive claim that this config directory has one daemon serving it.83+///84+/// The operating system releases the lock when the process exits, including85+/// crashes, so there is no stale PID file to clean up or trust.86+pub struct DaemonLock {87+ _file: File,88+}89+8190 impl Paths {8291 pub fn discover() -> Result<Self> {8392 // DEVSITE_HOME keeps integration tests (and multiple daemons on one machine) from@@ -102,6 +111,35 @@ impl Paths {
102111 self.root.join("config.json")103112 }104113 114+ pub fn daemon_lock(&self) -> PathBuf {115+ self.root.join("daemon.lock")116+ }117+118+ /// Try to become the daemon for this config directory.119+ ///120+ /// `None` means another process currently owns the lock. Keeping the121+ /// returned guard alive keeps the claim; dropping it releases the claim.122+ pub fn try_daemon_lock(&self) -> Result<Option<DaemonLock>> {123+ std::fs::create_dir_all(&self.root)?;124+ let path = self.daemon_lock();125+ let file = open_lock_file(&path)?;126+ match file.try_lock() {127+ Ok(()) => Ok(Some(DaemonLock { _file: file })),128+ Err(TryLockError::WouldBlock) => Ok(None),129+ Err(TryLockError::Error(err)) => {130+ Err(err).with_context(|| format!("locking {}", path.display()))131+ }132+ }133+ }134+135+ /// Whether a daemon currently owns this config directory.136+ pub fn daemon_running(&self) -> Result<bool> {137+ if !self.daemon_lock().exists() {138+ return Ok(false);139+ }140+ Ok(self.try_daemon_lock()?.is_none())141+ }142+105143 pub fn load_config(&self) -> Result<DaemonConfig> {106144 let path = self.config();107145 if !path.exists() {@@ -141,6 +179,31 @@ impl Paths {
141179 }142180 }143181 182+183+fn open_lock_file(path: &Path) -> Result<File> {184+ use std::os::unix::fs::OpenOptionsExt;185+186+ OpenOptions::new()187+ .read(true)188+ .write(true)189+ .create(true)190+ .truncate(false)191+ .mode(0o600)192+ .open(path)193+ .with_context(|| format!("opening {}", path.display()))194+}195+196+197+fn open_lock_file(path: &Path) -> Result<File> {198+ OpenOptions::new()199+ .read(true)200+ .write(true)201+ .create(true)202+ .truncate(false)203+ .open(path)204+ .with_context(|| format!("opening {}", path.display()))205+}206+144207 /// Write a file only the owner can read.145208 ///146209 /// The mode is set before any bytes land on disk, so the secret is never briefly exposed@@ -171,6 +234,19 @@ fn write_private(path: &Path, contents: &[u8]) -> Result<()> {
171234 172235 mod tests {173236 use super::*;237+ use std::sync::atomic::{AtomicUsize, Ordering};238+239+ static NEXT_TEST_DIR: AtomicUsize = AtomicUsize::new(0);240+241+ fn test_paths() -> Paths {242+ let suffix = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed);243+ Paths {244+ root: std::env::temp_dir().join(format!(245+ "devsite-daemon-lock-{}-{suffix}",246+ std::process::id()247+ )),248+ }249+ }174250 175251 176252 fn reads_pre_machine_credential_configs() {@@ -180,4 +256,18 @@ mod tests {
180256 .unwrap();181257 assert_eq!(config.machine_credential.as_deref(), Some("old-token"));182258 }259+260+ 261+ fn daemon_lock_reports_only_a_live_holder() {262+ let paths = test_paths();263+ assert!(!paths.daemon_running().unwrap());264+265+ let lock = paths.try_daemon_lock().unwrap().unwrap();266+ assert!(paths.daemon_running().unwrap());267+ assert!(paths.try_daemon_lock().unwrap().is_none());268+269+ drop(lock);270+ assert!(!paths.daemon_running().unwrap());271+ std::fs::remove_dir_all(&paths.root).unwrap();272+ }183273 }@@ -21,7 +21,7 @@ use tokio::io::{copy, AsyncWriteExt};
2121 use tokio::net::TcpStream;2222 use tokio::sync::{Mutex, RwLock};2323 24-use crate::config::{DaemonConfig, ExposedResource};24+use crate::config::{DaemonConfig, HostedService};2525 use crate::verify::{Denied, ReplayGuard};2626 2727 pub fn now_secs() -> u64 {@@ -80,7 +80,7 @@ impl Daemon {
8080 /// Replace only the resource map while the endpoint keeps its identity and8181 /// open connections. This is intentionally platform-neutral: service8282 /// managers only need to keep `devsite daemon run` alive.83- pub async fn replace_resources(&self, resources: Vec<ExposedResource>) -> bool {83+ pub async fn replace_resources(&self, resources: Vec<HostedService>) -> bool {8484 let mut config = self.config.write().await;8585 if config.resources == resources {8686 return false;@@ -17,7 +17,7 @@
1717 use std::sync::Arc;1818 1919 use devsite_client::{ConnectError, ServiceStream, ViewerEndpoint};20-use devsite_daemon::config::{DaemonConfig, ExposedResource, Visibility};20+use devsite_daemon::config::{DaemonConfig, HostedService, Visibility};2121 use devsite_daemon::Daemon;2222 use devsite_proto::capability::{2323 CapabilityClaims, KeyBytes, Permission, SignedCapability, DEFAULT_LIFETIME_SECS,@@ -77,13 +77,13 @@ impl Harness {
7777 data_encoding::HEXLOWER.encode(signing_key.verifying_key().as_bytes()),7878 ),7979 resources: vec![80- ExposedResource {80+ HostedService {8181 resource_id: hermes,8282 name: "Hermes".into(),8383 target: spawn_service(HERMES).await,8484 visibility: Visibility::Private,8585 },86- ExposedResource {86+ HostedService {8787 resource_id: agent,8888 name: "Agent".into(),8989 target: spawn_service(AGENT).await,@@ -571,7 +571,7 @@ async fn create_resource(
571571 .map_err(ApiError::internal)?;572572 573573 // The share list this request names is the whole share list afterwards.574- // Re-running `expose --share @carol` means Carol is the invited recipient,574+ // Re-running `service host --share @carol` means Carol is the invited recipient,575575 // not Carol plus whoever was named the last time it ran.576576 db.set_shares(resource, &viewers)577577 .map_err(ApiError::internal)?;@@ -583,11 +583,8 @@ async fn create_resource(
583583 584584 fn validate_resource_visibility(kind: ResourceKind, visibility: Visibility) -> ApiResult<()> {585585 match (kind, visibility) {586- (ResourceKind::Link, Visibility::Public)586+ (ResourceKind::Link, Visibility::Public | Visibility::Private | Visibility::Shared)587587 | (ResourceKind::Service, Visibility::Private | Visibility::Shared) => {}588- (ResourceKind::Link, _) => {589- return Err(ApiError::bad_request("external links must be public"))590- }591588 (ResourceKind::Service, Visibility::Public) => {592589 return Err(ApiError::bad_request(593590 "TCP services must be private or shared with specific users",@@ -1386,9 +1383,10 @@ mod tests {
13861383 use super::*;13871384 13881385 1389- fn links_are_public_and_tcp_services_are_not() {1386+ fn links_support_every_visibility_and_tcp_services_are_not_public() {13901387 assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Public).is_ok());1391- assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Private).is_err());1388+ assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Private).is_ok());1389+ assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Shared).is_ok());13921390 assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Private).is_ok());13931391 assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Shared).is_ok());13941392 assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Public).is_err());@@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS profiles (
7272 const MIGRATIONS: &[&str] = &[7373 SCHEMA,7474 // JSON array of the resource ids a daemon is actually configured to serve, so a75- // resource can be reported reachable only if its owner's daemon still exposes it.75+ // resource can be reported reachable only if its owner's daemon still hosts it.7676 "ALTER TABLE daemons ADD COLUMN serving TEXT NOT NULL DEFAULT '[]';",7777 // Presence is gone, and with it the 15-second heartbeat that maintained these7878 // three columns. A daemon's endpoint id is derived from its secret key and@@ -519,7 +519,7 @@ impl Db {
519519 520520 /// Create a resource, or update the one that already has this owner, name and kind.521521 ///522- /// Re-running `devsite expose --name Hermes` is an ordinary thing to do — to change522+ /// Re-running `devsite service host --name Hermes` is an ordinary thing to do — to change523523 /// visibility, or to add a share. Inserting each time would leave a duplicate entry on524524 /// the profile pointing at a resource id no daemon serves any more. Keeping the id525525 /// stable also means capabilities already issued for it stay meaningful.@@ -626,7 +626,7 @@ impl Db {
626626 627627 /// Set exactly who is invited to a resource, replacing whoever was there.628628 ///629- /// Replacing rather than adding is the whole point. `devsite expose --share629+ /// Replacing rather than adding is the whole point. `devsite service host --share630630 /// @carol` reads as "offer this to Carol now", and an accumulating list631631 /// would leave everyone previously named still holding access. Existing632632 /// decisions are preserved; a routine sync cannot undo a recipient's decline.@@ -1089,7 +1089,7 @@ mod tests {
10891089 10901090 10911091 fn re_sharing_replaces_the_list_rather_than_adding_to_it() {1092- // The bug this exists to prevent: `expose --share @carol` reading as1092+ // The bug this exists to prevent: `service host --share @carol` reading as10931093 // "and Carol too". A share that can be granted and never revoked is not10941094 // a share, it is a one-way door.10951095 let (mut db, alice, bob) = seeded();@@ -1496,8 +1496,8 @@ mod tests {
14961496 }14971497 14981498 1499- fn re_exposing_a_service_keeps_its_id_and_updates_it() {1500- // `devsite expose --name Hermes` run twice must not leave two Hermes entries on1499+ fn rehosting_a_service_keeps_its_id_and_updates_it() {1500+ // `devsite service host --name Hermes` run twice must not leave two Hermes entries on15011501 // the profile, and the id must survive so already-issued capabilities still name15021502 // the same thing.15031503 let (mut db, alice, _) = seeded();@@ -1524,7 +1524,7 @@ mod tests {
15241524 )15251525 .unwrap();15261526 1527- assert_eq!(first, second, "re-exposing must reuse the resource id");1527+ assert_eq!(first, second, "rehosting must reuse the resource id");15281528 let owned = db.resources_owned_by(alice.id).unwrap();15291529 assert_eq!(owned.len(), 1, "expected exactly one Hermes, got {owned:?}");15301530 assert_eq!(@@ -0,0 +1,29 @@
1+# JSON output2+3+Every command accepts the global `--json` flag before or after its subcommands. Finite4+commands write exactly one JSON value to stdout:5+6+```json7+{"schema_version":1,"ok":true,"command":"daemon.status","result":{"running":true,"start_hints":[]}}8+```9+10+Every record carries `schema_version: 1`; consumers should reject versions they do not11+understand rather than guessing at a changed shape.12+13+Failures are JSON on stdout and retain meaningful exit codes: `1` for runtime failures and14+`2` for command-line usage errors.15+16+```json17+{"schema_version":1,"ok":false,"command":"service.host","error":{"kind":"runtime","message":"…","causes":[],"suggestions":["Run `devsite service host --help` for valid arguments."]}}18+```19+20+Help is structured too: `devsite link set --json --help` succeeds with command `help` and21+puts the complete command-specific help in `result.text`. Usage and runtime errors include22+recovery suggestions suitable for either display or direct agent consumption.23+24+`devsite connect --json` and `devsite daemon run --json` are resident processes. They emit25+one JSON value per line so consumers can process lifecycle events as NDJSON. Human logs and26+transport warnings remain on stderr and never contaminate JSON stdout.27+28+JSON mode is non-interactive. In particular, `devsite login --json` requires its token as an29+argument rather than prompting on stdin.@@ -95,10 +95,10 @@ is reached at its own address, while `service` is a TCP byte stream reached with
9595 two a thing is says nothing about what it is for.9696 9797 `data-visibility` is on the row: `public`, `private` or `shared`. It is written98-out in `.state` only where it is not `public`, and a link is always `public` —99-dev.site can hide a URL but cannot stop anyone holding it from opening it, so100-concealment is the only thing "private" could mean there and the word is not101-offered.98+out in `.state` only where it is not `public`. For a link, visibility controls99+who can discover the URL on dev.site; it cannot stop someone who already knows100+the destination from opening or forwarding it. Shared links require recipient101+approval, and changing their destination requires fresh approval.102102 103103 There is deliberately no reachability state to style: the page does not know104104 whether a daemon is running, and says so by saying nothing.@@ -118,7 +118,7 @@ rather than leaving a gap before the arrow. Where there is no hover to ask with,
118118 119119 ## Folders120120 121-A folder is a name on a site, not a thing of its own — `devsite link add --folder121+A folder is a name on a site, not a thing of its own — `devsite link set --folder122122 Games` files one, and leaving `--folder` off takes it out again. There is nothing123123 to create and nothing to delete, so a profile can never be left holding an empty124124 one, and renaming a folder is retagging what is in it.@@ -189,8 +189,8 @@ you visit cannot use its theme to disguise itself as another part of the site.
189189 ## Setting one190190 191191 From the CLI, and only from the CLI. The website is where a profile is read, not192-where it is written — links, exposures, sharing and themes are all set with193-`devsite`, and a theme is no more a browser concern than an exposure is. The one192+where it is written — links, hosted services, sharing and themes are all set with193+`devsite`, and a theme is no more a browser concern than a hosted service is. The one194194 exception is claiming a handle, which has to happen in the browser because that195195 is where signing in finishes.196196 @@ -0,0 +1,14 @@
1+# systemd user service2+3+Native Linux packages should install `devsite.service` to the system user-unit directory,4+normally `/usr/lib/systemd/user/`. The unit assumes the package installs the binary at5+`/usr/bin/devsite`; packages using another prefix should replace `ExecStart` accordingly.6+7+Users enable the daemon for their account after `devsite login`:8+9+```bash10+systemctl --user enable --now devsite.service11+```12+13+The foreground process remains `devsite daemon run`; the unit only supplies restart and14+login-session supervision.@@ -0,0 +1,14 @@
1+[Unit]2+Description=dev.site local service tunnel3+Documentation=https://dev.site4+Wants=network-online.target5+After=network-online.target6+7+[Service]8+Type=simple9+ExecStart=/usr/bin/devsite daemon run10+Restart=on-failure11+RestartSec=5s12+13+[Install]14+WantedBy=default.target@@ -237,9 +237,10 @@ function siteRow(item, { onClick, from } = {}) {
237237 + (from ? ` <small>from @${esc(from)}</small>` : '');238238 239239 if (item.kind === 'link') {240+ const note = item.visibility === 'public' ? '' : `${esc(item.visibility)} · `;240241 li.innerHTML =241242 `<a href="${esc(item.url)}" target="_blank" rel="ugc nofollow noopener noreferrer">${name}</a>` +242- `<small class="state">${linkHost(item)}</small>`;243+ `<small class="state">${note}${linkHost(item)}</small>`;243244 return li;244245 }245246