Add resource inventory, server-validated plans, state-aware diagnostics, semantic endpoint identity filenames, and a portable CLI skill.
- commit
- c887797af395ff8634f9985dcd5586ca3eac6a80
- parent
- f3b0050da0869418c029da9cd88e8d70b034e979
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 16 | +- |
| M | crates/ | 1130 | +++++++++++++++++++++++++++++- |
| M | crates/ | 32 | + |
| M | crates/ | 43 | +- |
| M | crates/ | 382 | +++++++++- |
| M | docs/ | 25 | + |
| A | skills/ | 64 | ++ |
@@ -144,6 +144,20 @@ prompts. Command-specific `--help` is available as structured JSON, and failures
144144 recovery suggestion. The complete stdout and exit-code contract is in145145 [`docs/json-output.md`](docs/json-output.md).146146 147+Inspect remote resources together with this machine's hosting state, validate a mutation148+without applying it, or diagnose configuration and control-plane drift:149+150+```bash151+devsite resources list --json152+devsite service host 5432 --name postgres --share @bob --plan --json153+devsite doctor --json154+```155+156+`--dry-run` is an alias for `--plan`. Plans make no remote or local changes. A portable157+agent skill that teaches these intent-based workflows through the CLI's JSON contract lives158+at [`skills/devsite-cli/SKILL.md`](skills/devsite-cli/SKILL.md); it has no dependency on a159+particular model or agent harness.160+147161 Homebrew installs can supervise it at login on macOS or Linux:148162 149163 ```bash@@ -157,7 +171,7 @@ systemctl --user enable --now devsite.service
157171 ```158172 159173 The endpoint identity persists in the devsite config directory, with its Ed25519 public key160-written to `identity.pub`. The daemon registers that public endpoint id with the control174+written to `devsite-endpoint.pub`. The daemon registers that public endpoint id with the control161175 plane once at startup, publishes its address through Iroh, and reloads service targets and162176 authorizations every two seconds. Adding or removing a service does not require restarting163177 it.@@ -54,6 +54,9 @@ enum Command {
5454 /// Manage ordinary external links.5555 5656 Link(LinkCommand),57+ /// Inspect resources stored on the control plane.58+ 59+ Resources(ResourcesCommand),5760 /// Host local TCP services.5861 5962 Service(ServiceCommand),@@ -70,6 +73,8 @@ enum Command {
7073 Theme(ThemeCommand),7174 /// Show what this machine is configured to serve.7275 Status,76+ /// Diagnose local configuration, control-plane compatibility, and resource drift.77+ Doctor,7378 /// Daemon control.7479 7580 Daemon(DaemonCommand),@@ -95,14 +100,26 @@ enum LinkCommand {
95100 /// whatever folder it was in.96101 97102 folder: Option<String>,103+ /// Validate and show the exact change without applying it.104+ 105+ plan: bool,98106 },99107 /// Take a link off your profile.100108 Remove {101109 /// Name previously passed to `link set`.102110 name: String,111+ /// Show what would be removed without applying it.112+ 113+ plan: bool,103114 },104115 }105116 117+118+enum ResourcesCommand {119+ /// List owned links and services with share and local-hosting state.120+ List,121+}122+106123 107124 enum ServiceCommand {108125 /// Host a loopback TCP port as a named service.@@ -118,11 +135,17 @@ enum ServiceCommand {
118135 /// Presentation folder. TCP services default to `Services`.119136 120137 folder: Option<String>,138+ /// Validate and show the exact change without applying it.139+ 140+ plan: bool,121141 },122142 /// Stop hosting a service and take it off your profile.123143 Remove {124144 /// Name previously passed to `service host`.125145 name: String,146+ /// Show what would be removed without applying it.147+ 148+ plan: bool,126149 },127150 }128151 @@ -158,6 +181,7 @@ impl Command {
158181 Self::Login { .. } => "login",159182 Self::Link(LinkCommand::Set { .. }) => "link.set",160183 Self::Link(LinkCommand::Remove { .. }) => "link.remove",184+ Self::Resources(ResourcesCommand::List) => "resources.list",161185 Self::Service(ServiceCommand::Host { .. }) => "service.host",162186 Self::Service(ServiceCommand::Remove { .. }) => "service.remove",163187 Self::Connect { .. } => "connect",@@ -166,6 +190,7 @@ impl Command {
166190 Self::Theme(ThemeCommand::Clear) => "theme.clear",167191 Self::Theme(ThemeCommand::Properties) => "theme.properties",168192 Self::Status => "status",193+ Self::Doctor => "doctor",169194 Self::Daemon(DaemonCommand::Run) => "daemon.run",170195 Self::Daemon(DaemonCommand::Status) => "daemon.status",171196 }@@ -194,7 +219,15 @@ fn help_target(args: &[std::ffi::OsString]) -> Option<String> {
194219 let top = words.first()?.as_str();195220 if !matches!(196221 top,197- "login" | "link" | "service" | "connect" | "theme" | "status" | "daemon"222+ "login"223+ | "link"224+ | "resources"225+ | "service"226+ | "connect"227+ | "theme"228+ | "status"229+ | "doctor"230+ | "daemon"198231 ) {199232 return None;200233 }@@ -202,6 +235,7 @@ fn help_target(args: &[std::ffi::OsString]) -> Option<String> {
202235 let child_is_command = matches!(203236 (top, child),204237 ("link", Some("set" | "remove"))238+ | ("resources", Some("list"))205239 | ("service", Some("host" | "remove"))206240 | ("theme", Some("show" | "set" | "clear" | "properties"))207241 | ("daemon", Some("run" | "status"))@@ -258,11 +292,32 @@ struct PubKeyResponse {
258292 struct PublicConfig {259293 api_version: u32,260294 minimum_cli_version: String,295+ server_version: String,296+ daemon_protocol: String,261297 }262298 263299 264300 struct CreateResourceResponse {265301 resource_id: String,302+ 303+ plan: Option<serde_json::Value>,304+}305+306+307+struct ResourcePlanResponse {308+ plan: serde_json::Value,309+}310+311+312+struct MeResponse {313+ handle: Option<String>,314+}315+316+317+struct DaemonInfo {318+ credential_endpoint_id: String,319+ registered_endpoint_id: Option<String>,320+ registration_matches_credential: bool,266321 }267322 268323 @@ -301,7 +356,7 @@ struct ResourceListing {
301356 resources: Vec<Resource>,302357 }303358 304-359+305360 struct Resource {306361 resource_id: String,307362 name: String,@@ -314,9 +369,10 @@ struct Resource {
314369 shares: Vec<ResourceShare>,315370 }316371 317-372+318373 struct ResourceShare {319374 handle: String,375+ status: String,320376 }321377 322378 @@ -468,20 +524,26 @@ async fn run(cli: Cli, output: Output) -> Result<()> {
468524 public,469525 share,470526 folder,471- }) => set_link(&paths, &name, &url, public, share, folder, output).await,472- Command::Link(LinkCommand::Remove { name }) => remove_link(&paths, &name, output).await,527+ plan,528+ }) => set_link(&paths, &name, &url, public, share, folder, plan, output).await,529+ Command::Link(LinkCommand::Remove { name, plan }) => {530+ remove_link(&paths, &name, plan, output).await531+ }532+ Command::Resources(ResourcesCommand::List) => resources_list(&paths, output).await,473533 Command::Service(ServiceCommand::Host {474534 port,475535 name,476536 share,477537 folder,478- }) => host_service(&paths, port, name, share, folder, output).await,479- Command::Service(ServiceCommand::Remove { name }) => {480- remove_service(&paths, &name, output).await538+ plan,539+ }) => host_service(&paths, port, name, share, folder, plan, output).await,540+ Command::Service(ServiceCommand::Remove { name, plan }) => {541+ remove_service(&paths, &name, plan, output).await481542 }482543 Command::Connect { ticket, listen } => connect(&cli.server, &ticket, listen, output).await,483544 Command::Theme(command) => theme(&paths, &cli.server, command, output).await,484545 Command::Status => status(&paths, output),546+ Command::Doctor => doctor(&paths, output).await,485547 Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server, output).await,486548 Command::Daemon(DaemonCommand::Status) => daemon_status(&paths, output),487549 }@@ -625,6 +687,7 @@ async fn set_link(
625687 public: bool,626688 share: Vec<String>,627689 folder: Option<String>,690+ plan: bool,628691 output: Output,629692 ) -> Result<()> {630693 let visibility = if public {@@ -638,6 +701,26 @@ async fn set_link(
638701 let config = load_authenticated(paths)?;639702 let server = enrolled_server(&config)?;640703 let api = ControlPlane::new(server, config.machine_credential.clone());704+ let payload = serde_json::json!({705+ "name": name,706+ "kind": "link",707+ "visibility": visibility_str(visibility),708+ "url": url,709+ "share_with": share,710+ "folder": folder,711+ });712+ if plan {713+ let planned: ResourcePlanResponse = api.post("/api/resources/plan", &payload).await?;714+ if output.json {715+ output.success(716+ "link.set",717+ serde_json::json!({ "applied": false, "plan": planned.plan }),718+ );719+ } else {720+ print_resource_plan(&planned.plan);721+ }722+ return Ok(());723+ }641724 let update = warn_on_upsert(642725 &api,643726 "link",@@ -653,19 +736,7 @@ async fn set_link(
653736 update.print_human();654737 }655738 }656- let response: CreateResourceResponse = api657- .post(658- "/api/resources",659- &serde_json::json!({660- "name": name,661- "kind": "link",662- "visibility": visibility_str(visibility),663- "url": url,664- "share_with": share,665- "folder": folder,666- }),667- )668- .await?;739+ let response: CreateResourceResponse = api.post("/api/resources", &payload).await?;669740 if output.json {670741 output.success(671742 "link.set",@@ -677,6 +748,8 @@ async fn set_link(
677748 "folder": folder,678749 "recipients": share,679750 "update": update,751+ "applied": true,752+ "plan": response.plan,680753 }),681754 );682755 } else {@@ -697,7 +770,12 @@ async fn set_link(
697770 /// everything by id, and an endpoint that deleted by name would have to invent a698771 /// rule for a link and a service sharing one. Listing first also means the "no699772 /// such thing" case is answered with the names that do exist.700-async fn remove_resource(paths: &Paths, name: &str, kind: &str) -> Result<Resource> {773+async fn remove_resource(774+ paths: &Paths,775+ name: &str,776+ kind: &str,777+ apply: bool,778+) -> Result<Option<Resource>> {701779 let config = load_authenticated(paths)?;702780 let server = enrolled_server(&config)?;703781 let api = ControlPlane::new(server, config.machine_credential.clone());@@ -708,21 +786,42 @@ async fn remove_resource(paths: &Paths, name: &str, kind: &str) -> Result<Resour
708786 .into_iter()709787 .find(|r| r.name == name && r.kind == kind);710788 711- let Some(resource) = found else {712- bail!("you have no {kind} called `{name}`");713- };714- api.delete(&format!("/api/resources/{}", resource.resource_id))715- .await?;716- Ok(resource)789+ if apply {790+ if let Some(resource) = &found {791+ api.delete(&format!("/api/resources/{}", resource.resource_id))792+ .await?;793+ }794+ }795+ Ok(found)717796 }718797 719-async fn remove_link(paths: &Paths, name: &str, output: Output) -> Result<()> {720- let removed = remove_resource(paths, name, "link").await?;798+async fn remove_link(paths: &Paths, name: &str, plan: bool, output: Output) -> Result<()> {799+ let removed = remove_resource(paths, name, "link", !plan)800+ .await?801+ .with_context(|| format!("you have no link called `{name}`"))?;802+ let removal_plan = resource_removal_plan(&removed, None);803+ if plan {804+ if output.json {805+ output.success(806+ "link.remove",807+ serde_json::json!({ "applied": false, "plan": removal_plan }),808+ );809+ } else {810+ print_resource_plan(&removal_plan);811+ }812+ return Ok(());813+ }721814 let url = removed.url.unwrap_or_default();722815 if output.json {723816 output.success(724817 "link.remove",725- serde_json::json!({ "resource_id": removed.resource_id, "name": name, "url": url }),818+ serde_json::json!({819+ "resource_id": removed.resource_id,820+ "name": name,821+ "url": url,822+ "applied": true,823+ "plan": removal_plan,824+ }),726825 );727826 } else {728827 println!("removed link {name} → {url}");@@ -736,22 +835,55 @@ async fn remove_link(paths: &Paths, name: &str, output: Output) -> Result<()> {
736835 /// machine does. A daemon that kept serving a resource the control plane has737836 /// deleted is harmless — no capability can be issued for it any more — but a738837 /// config that still lists it would re-register the name the next time it is hosted.739-async fn remove_service(paths: &Paths, name: &str, output: Output) -> Result<()> {740- let removed = remove_resource(paths, name, "service").await?;741-838+async fn remove_service(paths: &Paths, name: &str, plan: bool, output: Output) -> Result<()> {742839 let mut config = paths.load_config()?;840+ let local = config841+ .resources842+ .iter()843+ .find(|resource| resource.name == name);844+ let removed = remove_resource(paths, name, "service", !plan).await?;845+ if removed.is_none() && local.is_none() {846+ bail!("you have no service called `{name}`");847+ }848+ let removal_plan = match &removed {849+ Some(resource) => resource_removal_plan(resource, local.map(|service| service.target)),850+ None => serde_json::json!({851+ "operation": "delete",852+ "target": { "resource_id": null, "kind": "service", "name": name },853+ "changes": [],854+ "recipient_changes": [],855+ "effects": [{ "code": "local_service_mapping_removed", "handles": [] }],856+ "local_only": true,857+ }),858+ };859+ if plan {860+ if output.json {861+ output.success(862+ "service.remove",863+ serde_json::json!({ "applied": false, "plan": removal_plan }),864+ );865+ } else {866+ print_resource_plan(&removal_plan);867+ }868+ return Ok(());869+ }870+743871 let before = config.resources.len();744872 config.resources.retain(|r| r.name != name);745873 paths.save_config(&config)?;746874 747875 let was_hosted_here = config.resources.len() != before;876+ let remote_already_absent = removed.is_none();748877 if output.json {749878 output.success(750879 "service.remove",751880 serde_json::json!({752- "resource_id": removed.resource_id,881+ "resource_id": removed.as_ref().map(|resource| resource.resource_id.as_str()),753882 "name": name,754883 "was_hosted_here": was_hosted_here,884+ "remote_already_absent": remote_already_absent,885+ "applied": true,886+ "plan": removal_plan,755887 }),756888 );757889 } else {@@ -771,6 +903,7 @@ async fn host_service(
771903 name: Option<String>,772904 share: Vec<String>,773905 folder: Option<String>,906+ plan: bool,774907 output: Output,775908 ) -> Result<()> {776909 if port == 0 {@@ -789,6 +922,33 @@ async fn host_service(
789922 let config = load_authenticated(paths)?;790923 let server = enrolled_server(&config)?;791924 let api = ControlPlane::new(server, config.machine_credential.clone());925+ let payload = serde_json::json!({926+ "name": &name,927+ "kind": "service",928+ "visibility": visibility_str(visibility),929+ // Deliberately absent: the local target never leaves this machine.930+ "share_with": share,931+ "folder": folder,932+ });933+ if plan {934+ let planned: ResourcePlanResponse = api.post("/api/resources/plan", &payload).await?;935+ let planned = plan_with_local_changes(936+ planned.plan,937+ serde_json::json!([{938+ "operation": "upsert_hosted_service",939+ "target": target.to_string(),940+ }]),941+ );942+ if output.json {943+ output.success(944+ "service.host",945+ serde_json::json!({ "applied": false, "plan": planned }),946+ );947+ } else {948+ print_resource_plan(&planned);949+ }950+ return Ok(());951+ }792952 let update = warn_on_upsert(793953 &api,794954 "service",@@ -805,19 +965,16 @@ async fn host_service(
805965 }806966 }807967 808- let response: CreateResourceResponse = api809- .post(810- "/api/resources",811- &serde_json::json!({812- "name": &name,813- "kind": "service",814- "visibility": visibility_str(visibility),815- // Deliberately absent: the local target never leaves this machine.816- "share_with": share,817- "folder": folder,818- }),968+ let response: CreateResourceResponse = api.post("/api/resources", &payload).await?;969+ let applied_plan = response.plan.map(|plan| {970+ plan_with_local_changes(971+ plan,972+ serde_json::json!([{973+ "operation": "upsert_hosted_service",974+ "target": target.to_string(),975+ }]),819976 )820- .await?;977+ });821978 822979 let resource_id = response823980 .resource_id@@ -848,6 +1005,8 @@ async fn host_service(
8481005 "url": format!("{server}/s/{resource_id}"),8491006 "daemon": daemon_state(daemon_running),8501007 "update": update,1008+ "applied": true,1009+ "plan": applied_plan,8511010 }),8521011 );8531012 } else {@@ -1056,6 +1215,213 @@ fn display_handles(handles: &[String]) -> String {
10561215 .join(", ")10571216 }10581217 1218+fn plan_with_local_changes(1219+ mut plan: serde_json::Value,1220+ local_changes: serde_json::Value,1221+) -> serde_json::Value {1222+ if let Some(object) = plan.as_object_mut() {1223+ object.insert("local_changes".to_string(), local_changes);1224+ }1225+ plan1226+}1227+1228+fn resource_removal_plan(1229+ resource: &Resource,1230+ local_target: Option<SocketAddr>,1231+) -> serde_json::Value {1232+ let recipient_changes = resource1233+ .shares1234+ .iter()1235+ .map(|recipient| {1236+ serde_json::json!({1237+ "handle": recipient.handle,1238+ "from": recipient.status,1239+ "to": null,1240+ "reason": "resource_removed",1241+ })1242+ })1243+ .collect::<Vec<_>>();1244+ let accepted = resource1245+ .shares1246+ .iter()1247+ .filter(|recipient| recipient.status == "accepted")1248+ .map(|recipient| recipient.handle.clone())1249+ .collect::<Vec<_>>();1250+ let pending = resource1251+ .shares1252+ .iter()1253+ .filter(|recipient| recipient.status == "pending")1254+ .map(|recipient| recipient.handle.clone())1255+ .collect::<Vec<_>>();1256+ let mut effects = vec![serde_json::json!({1257+ "code": "profile_entry_removed",1258+ "handles": [],1259+ })];1260+ if !accepted.is_empty() {1261+ effects.push(serde_json::json!({1262+ "code": "accepted_access_revoked",1263+ "handles": accepted,1264+ }));1265+ }1266+ if !pending.is_empty() {1267+ effects.push(serde_json::json!({1268+ "code": "pending_invitations_withdrawn",1269+ "handles": pending,1270+ }));1271+ }1272+ if local_target.is_some() {1273+ effects.push(serde_json::json!({1274+ "code": "local_service_mapping_removed",1275+ "handles": [],1276+ }));1277+ }1278+ serde_json::json!({1279+ "operation": "delete",1280+ "target": {1281+ "resource_id": resource.resource_id,1282+ "kind": resource.kind,1283+ "name": resource.name,1284+ },1285+ "changes": [],1286+ "recipient_changes": recipient_changes,1287+ "effects": effects,1288+ "local_changes": local_target.map(|target| vec![serde_json::json!({1289+ "operation": "remove_hosted_service",1290+ "target": target.to_string(),1291+ })]).unwrap_or_default(),1292+ })1293+}1294+1295+fn print_resource_plan(plan: &serde_json::Value) {1296+ let operation = plan["operation"].as_str().unwrap_or("change");1297+ let kind = plan["target"]["kind"].as_str().unwrap_or("resource");1298+ let name = plan["target"]["name"].as_str().unwrap_or("(unknown)");1299+ println!("plan: {operation} {kind} `{name}`");1300+ if let Some(changes) = plan["changes"].as_array() {1301+ for change in changes {1302+ println!(1303+ " {}: {} → {}",1304+ change["field"].as_str().unwrap_or("field"),1305+ change["from"],1306+ change["to"]1307+ );1308+ }1309+ }1310+ if let Some(effects) = plan["effects"].as_array() {1311+ for effect in effects {1312+ println!(" effect: {}", effect["code"].as_str().unwrap_or("change"));1313+ }1314+ }1315+ println!("no changes applied");1316+}1317+1318+async fn resources_list(paths: &Paths, output: Output) -> Result<()> {1319+ let config = load_authenticated(paths)?;1320+ let server = enrolled_server(&config)?.to_string();1321+ let api = ControlPlane::new(&server, config.machine_credential.clone());1322+ let listing: ResourceListing = api.get("/api/resources").await?;1323+ let daemon_running = paths.daemon_running()?;1324+ let remote_ids = listing1325+ .resources1326+ .iter()1327+ .map(|resource| resource.resource_id.clone())1328+ .collect::<HashSet<_>>();1329+ let resources = listing1330+ .resources1331+ .iter()1332+ .map(|resource| {1333+ let local = config1334+ .resources1335+ .iter()1336+ .find(|hosted| hosted.resource_id.to_string() == resource.resource_id);1337+ let mut issues = Vec::new();1338+ if let Some(hosted) = local {1339+ if visibility_str(hosted.visibility) != resource.visibility {1340+ issues.push(serde_json::json!({1341+ "code": "visibility_mismatch",1342+ "local": visibility_str(hosted.visibility),1343+ "remote": resource.visibility,1344+ }));1345+ }1346+ if resource.kind != "service" {1347+ issues.push(serde_json::json!({ "code": "remote_kind_mismatch" }));1348+ }1349+ }1350+ let state = match (resource.kind.as_str(), local, daemon_running) {1351+ ("link", _, _) => "profile_entry",1352+ ("service", Some(_), true) => "serving_here",1353+ ("service", Some(_), false) => "configured_here",1354+ ("service", None, _) => "not_configured_here",1355+ _ => "profile_entry",1356+ };1357+ serde_json::json!({1358+ "resource_id": resource.resource_id,1359+ "name": resource.name,1360+ "kind": resource.kind,1361+ "visibility": resource.visibility,1362+ "url": resource.url,1363+ "folder": resource.folder,1364+ "recipients": resource.shares,1365+ "local": local.map(|hosted| serde_json::json!({1366+ "configured": true,1367+ "target": hosted.target.to_string(),1368+ })),1369+ "state": state,1370+ "issues": issues,1371+ })1372+ })1373+ .collect::<Vec<_>>();1374+ let local_only_services = config1375+ .resources1376+ .iter()1377+ .filter(|hosted| !remote_ids.contains(&hosted.resource_id.to_string()))1378+ .map(|hosted| {1379+ serde_json::json!({1380+ "resource_id": hosted.resource_id.to_string(),1381+ "name": hosted.name,1382+ "kind": "service",1383+ "visibility": visibility_str(hosted.visibility),1384+ "target": hosted.target.to_string(),1385+ "state": "local_only",1386+ "issues": [{ "code": "missing_remote_resource" }],1387+ })1388+ })1389+ .collect::<Vec<_>>();1390+1391+ if output.json {1392+ output.success(1393+ "resources.list",1394+ serde_json::json!({1395+ "server": server,1396+ "daemon": daemon_state(daemon_running),1397+ "resources": resources,1398+ "local_only_services": local_only_services,1399+ }),1400+ );1401+ } else {1402+ if resources.is_empty() {1403+ println!("no remote resources");1404+ }1405+ for resource in &resources {1406+ println!(1407+ "{:<8} {:<18} {:<18} {}",1408+ resource["kind"].as_str().unwrap_or("resource"),1409+ resource["name"].as_str().unwrap_or(""),1410+ resource["visibility"].as_str().unwrap_or(""),1411+ resource["state"].as_str().unwrap_or("")1412+ );1413+ }1414+ for resource in &local_only_services {1415+ println!(1416+ "service {:<18} {:<18} local_only",1417+ resource["name"].as_str().unwrap_or(""),1418+ resource["visibility"].as_str().unwrap_or("")1419+ );1420+ }1421+ }1422+ Ok(())1423+}1424+10591425 10601426 async fn warn_on_upsert(10611427 api: &ControlPlane,@@ -1180,9 +1546,641 @@ fn visibility_str(visibility: Visibility) -> &'static str {
11801546 }11811547 }11821548 1549+fn push_doctor_check(1550+ checks: &mut Vec<serde_json::Value>,1551+ counts: &mut [u64; 4],1552+ id: &str,1553+ status: &str,1554+ message: impl Into<String>,1555+) {1556+ let index = match status {1557+ "pass" => 0,1558+ "warning" => 1,1559+ "failure" => 2,1560+ _ => 3,1561+ };1562+ counts[index] += 1;1563+ checks.push(serde_json::json!({1564+ "id": id,1565+ "status": status,1566+ "message": message.into(),1567+ }));1568+}1569+1570+1571+fn private_file_permissions(path: &std::path::Path) -> Result<bool> {1572+ use std::os::unix::fs::MetadataExt;1573+1574+ Ok(std::fs::metadata(path)?.mode() & 0o077 == 0)1575+}1576+1577+1578+fn private_file_permissions(_path: &std::path::Path) -> Result<bool> {1579+ Ok(true)1580+}1581+1582+async fn doctor(paths: &Paths, output: Output) -> Result<()> {1583+ let mut checks = Vec::new();1584+ let mut actions = Vec::new();1585+ let mut counts = [0_u64; 4];1586+1587+ let config_exists = paths.config().exists();1588+ let config = match paths.load_config() {1589+ Ok(config) => {1590+ push_doctor_check(1591+ &mut checks,1592+ &mut counts,1593+ "config.readable",1594+ if config_exists { "pass" } else { "skipped" },1595+ if config_exists {1596+ format!("Loaded {}.", paths.config().display())1597+ } else {1598+ "No config file exists yet.".to_string()1599+ },1600+ );1601+ config1602+ }1603+ Err(err) => {1604+ push_doctor_check(1605+ &mut checks,1606+ &mut counts,1607+ "config.readable",1608+ "failure",1609+ format!("{err:#}"),1610+ );1611+ DaemonConfig::default()1612+ }1613+ };1614+ if paths.config().exists() {1615+ match private_file_permissions(&paths.config()) {1616+ Ok(true) => push_doctor_check(1617+ &mut checks,1618+ &mut counts,1619+ "config.permissions",1620+ "pass",1621+ "The credential-bearing config is private.",1622+ ),1623+ Ok(false) => push_doctor_check(1624+ &mut checks,1625+ &mut counts,1626+ "config.permissions",1627+ "failure",1628+ "The credential-bearing config is readable by another user.",1629+ ),1630+ Err(err) => push_doctor_check(1631+ &mut checks,1632+ &mut counts,1633+ "config.permissions",1634+ "failure",1635+ format!("{err:#}"),1636+ ),1637+ }1638+ } else {1639+ push_doctor_check(1640+ &mut checks,1641+ &mut counts,1642+ "config.permissions",1643+ "skipped",1644+ "No config file exists yet.",1645+ );1646+ }1647+1648+ let identity = match std::fs::read(paths.identity()) {1649+ Ok(raw) => {1650+ let parsed = raw1651+ .as_slice()1652+ .try_into()1653+ .map(iroh::SecretKey::from_bytes)1654+ .map_err(|_| anyhow::anyhow!("endpoint key is not 32 bytes"));1655+ match parsed {1656+ Ok(key) => {1657+ push_doctor_check(1658+ &mut checks,1659+ &mut counts,1660+ "identity.private_key",1661+ "pass",1662+ "The endpoint key is readable and valid.",1663+ );1664+ Some(key)1665+ }1666+ Err(err) => {1667+ push_doctor_check(1668+ &mut checks,1669+ &mut counts,1670+ "identity.private_key",1671+ "failure",1672+ err.to_string(),1673+ );1674+ None1675+ }1676+ }1677+ }1678+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {1679+ let legacy = paths.root.join("identity.key");1680+ let message = if legacy.exists() {1681+ "A legacy identity.key exists and will migrate on the next login or daemon start."1682+ } else {1683+ "No endpoint identity exists yet."1684+ };1685+ push_doctor_check(1686+ &mut checks,1687+ &mut counts,1688+ "identity.private_key",1689+ if config.machine_credential.is_some() {1690+ "failure"1691+ } else {1692+ "skipped"1693+ },1694+ message,1695+ );1696+ None1697+ }1698+ Err(err) => {1699+ push_doctor_check(1700+ &mut checks,1701+ &mut counts,1702+ "identity.private_key",1703+ "failure",1704+ format!("{err:#}"),1705+ );1706+ None1707+ }1708+ };1709+ if paths.identity().exists() {1710+ match private_file_permissions(&paths.identity()) {1711+ Ok(true) => push_doctor_check(1712+ &mut checks,1713+ &mut counts,1714+ "identity.permissions",1715+ "pass",1716+ "The endpoint key is private.",1717+ ),1718+ Ok(false) => push_doctor_check(1719+ &mut checks,1720+ &mut counts,1721+ "identity.permissions",1722+ "failure",1723+ "The endpoint key is readable by another user.",1724+ ),1725+ Err(err) => push_doctor_check(1726+ &mut checks,1727+ &mut counts,1728+ "identity.permissions",1729+ "failure",1730+ format!("{err:#}"),1731+ ),1732+ }1733+ }1734+1735+ let endpoint_id = identity.as_ref().map(|key| key.public().to_string());1736+ match (&endpoint_id, std::fs::read_to_string(paths.identity_public())) {1737+ (Some(expected), Ok(actual)) if actual.trim() == expected => push_doctor_check(1738+ &mut checks,1739+ &mut counts,1740+ "identity.public_key",1741+ "pass",1742+ "devsite-endpoint.pub matches the endpoint key.",1743+ ),1744+ (Some(_), Ok(_)) => push_doctor_check(1745+ &mut checks,1746+ &mut counts,1747+ "identity.public_key",1748+ "warning",1749+ "devsite-endpoint.pub does not match the endpoint key; login or daemon startup will rewrite it.",1750+ ),1751+ (Some(_), Err(_)) => push_doctor_check(1752+ &mut checks,1753+ &mut counts,1754+ "identity.public_key",1755+ "warning",1756+ "devsite-endpoint.pub is missing; login or daemon startup will create it.",1757+ ),1758+ (None, _) => push_doctor_check(1759+ &mut checks,1760+ &mut counts,1761+ "identity.public_key",1762+ "skipped",1763+ "No valid endpoint key is available for comparison.",1764+ ),1765+ }1766+1767+ let daemon_running = paths.daemon_running()?;1768+ let daemon_status = if daemon_running || config.resources.is_empty() {1769+ "pass"1770+ } else {1771+ "warning"1772+ };1773+ push_doctor_check(1774+ &mut checks,1775+ &mut counts,1776+ "daemon.local",1777+ daemon_status,1778+ if daemon_running {1779+ "The local daemon is running.".to_string()1780+ } else if config.resources.is_empty() {1781+ "The local daemon is stopped and no services are configured.".to_string()1782+ } else {1783+ format!(1784+ "The local daemon is stopped while {} service(s) are configured.",1785+ config.resources.len()1786+ )1787+ },1788+ );1789+ if !daemon_running && !config.resources.is_empty() {1790+ actions.push(serde_json::json!({1791+ "id": "start-daemon",1792+ "priority": 1,1793+ "reason": "configured_services_not_served",1794+ "commands": daemon_start_commands(),1795+ "mutates": "local_process",1796+ "requires_user_input": false,1797+ }));1798+ }1799+1800+ let Some(server) = config.server_url.as_deref() else {1801+ push_doctor_check(1802+ &mut checks,1803+ &mut counts,1804+ "server.reachable",1805+ "skipped",1806+ "No control plane is configured.",1807+ );1808+ actions.push(serde_json::json!({1809+ "id": "login",1810+ "priority": 1,1811+ "reason": "not_enrolled",1812+ "commands": [["devsite", "login", "TICKET"]],1813+ "mutates": "local_and_remote_identity",1814+ "requires_user_input": true,1815+ }));1816+ return write_doctor_report(output, checks, counts, actions);1817+ };1818+ let public = ControlPlane::new(server, None);1819+ let public_config: PublicConfig = match public.get::<PublicConfig>("/api/config").await {1820+ Ok(config) => {1821+ push_doctor_check(1822+ &mut checks,1823+ &mut counts,1824+ "server.reachable",1825+ "pass",1826+ format!("Reached {server} (server {}).", config.server_version),1827+ );1828+ config1829+ }1830+ Err(err) => {1831+ push_doctor_check(1832+ &mut checks,1833+ &mut counts,1834+ "server.reachable",1835+ "failure",1836+ format!("{err:#}"),1837+ );1838+ return write_doctor_report(output, checks, counts, actions);1839+ }1840+ };1841+ push_doctor_check(1842+ &mut checks,1843+ &mut counts,1844+ "server.api_compatible",1845+ if public_config.api_version == 3 {1846+ "pass"1847+ } else {1848+ "failure"1849+ },1850+ format!(1851+ "Server API version is {} (CLI expects 3).",1852+ public_config.api_version1853+ ),1854+ );1855+ let cli_compatible = validate_cli_version(1856+ &public_config.minimum_cli_version,1857+ env!("CARGO_PKG_VERSION"),1858+ );1859+ let cli_is_compatible = cli_compatible.is_ok();1860+ let cli_compatibility_message = match &cli_compatible {1861+ Ok(()) => format!(1862+ "CLI {} meets the server minimum {}.",1863+ env!("CARGO_PKG_VERSION"),1864+ public_config.minimum_cli_version1865+ ),1866+ Err(err) => err.to_string(),1867+ };1868+ push_doctor_check(1869+ &mut checks,1870+ &mut counts,1871+ "server.cli_compatible",1872+ if cli_is_compatible { "pass" } else { "failure" },1873+ cli_compatibility_message,1874+ );1875+ if !cli_is_compatible {1876+ actions.push(serde_json::json!({1877+ "id": "upgrade-cli",1878+ "priority": 1,1879+ "reason": "cli_below_server_minimum",1880+ "commands": upgrade_commands(),1881+ "mutates": "installed_binary",1882+ "requires_user_input": false,1883+ }));1884+ }1885+ let expected_protocol = String::from_utf8_lossy(devsite_proto::ALPN);1886+ push_doctor_check(1887+ &mut checks,1888+ &mut counts,1889+ "server.daemon_protocol",1890+ if public_config.daemon_protocol == expected_protocol {1891+ "pass"1892+ } else {1893+ "failure"1894+ },1895+ format!(1896+ "Server daemon protocol is {}.",1897+ public_config.daemon_protocol1898+ ),1899+ );1900+1901+ match public.get::<PubKeyResponse>("/api/pubkey").await {1902+ Ok(remote) => {1903+ let matches = config.control_plane_key.as_deref() == Some(remote.public_key.as_str());1904+ push_doctor_check(1905+ &mut checks,1906+ &mut counts,1907+ "server.signing_key_matches",1908+ if matches { "pass" } else { "failure" },1909+ if matches {1910+ "The server signing key matches the key pinned at login."1911+ } else {1912+ "The server signing key differs from the key pinned at login; verify the control-plane origin before logging in again."1913+ },1914+ );1915+ if !matches {1916+ actions.push(serde_json::json!({1917+ "id": "verify-signing-key-change",1918+ "priority": 1,1919+ "reason": "server_signing_key_changed",1920+ "commands": [["devsite", "login", "TICKET"]],1921+ "mutates": "pinned_trust_and_machine_credential",1922+ "requires_user_input": true,1923+ }));1924+ }1925+ }1926+ Err(err) => push_doctor_check(1927+ &mut checks,1928+ &mut counts,1929+ "server.signing_key_matches",1930+ "failure",1931+ format!("{err:#}"),1932+ ),1933+ }1934+ match config.verifying_key() {1935+ Ok(_) => push_doctor_check(1936+ &mut checks,1937+ &mut counts,1938+ "config.signing_key",1939+ "pass",1940+ "The pinned signing key is valid Ed25519 material.",1941+ ),1942+ Err(err) => push_doctor_check(1943+ &mut checks,1944+ &mut counts,1945+ "config.signing_key",1946+ "failure",1947+ format!("{err:#}"),1948+ ),1949+ }1950+1951+ let Some(credential) = config.machine_credential.clone() else {1952+ push_doctor_check(1953+ &mut checks,1954+ &mut counts,1955+ "auth.credential_valid",1956+ "failure",1957+ "No machine credential is configured.",1958+ );1959+ actions.push(serde_json::json!({1960+ "id": "login",1961+ "priority": 1,1962+ "reason": "machine_credential_missing",1963+ "commands": [["devsite", "login", "TICKET"]],1964+ "mutates": "local_and_remote_identity",1965+ "requires_user_input": true,1966+ }));1967+ return write_doctor_report(output, checks, counts, actions);1968+ };1969+ let api = ControlPlane::new(server, Some(credential));1970+ match api.get::<MeResponse>("/api/me").await {1971+ Ok(me) => push_doctor_check(1972+ &mut checks,1973+ &mut counts,1974+ "auth.credential_valid",1975+ "pass",1976+ format!(1977+ "The machine credential is valid for @{}.",1978+ me.handle.as_deref().unwrap_or("(no handle)")1979+ ),1980+ ),1981+ Err(err) => {1982+ push_doctor_check(1983+ &mut checks,1984+ &mut counts,1985+ "auth.credential_valid",1986+ "failure",1987+ format!("{err:#}"),1988+ );1989+ actions.push(serde_json::json!({1990+ "id": "login",1991+ "priority": 1,1992+ "reason": "machine_credential_invalid",1993+ "commands": [["devsite", "login", "TICKET"]],1994+ "mutates": "local_and_remote_identity",1995+ "requires_user_input": true,1996+ }));1997+ return write_doctor_report(output, checks, counts, actions);1998+ }1999+ }2000+2001+ match api.get::<DaemonInfo>("/api/daemon").await {2002+ Ok(info) => {2003+ let bound_matches =2004+ endpoint_id.as_deref() == Some(info.credential_endpoint_id.as_str());2005+ push_doctor_check(2006+ &mut checks,2007+ &mut counts,2008+ "identity.credential_binding",2009+ if bound_matches { "pass" } else { "failure" },2010+ if bound_matches {2011+ "The machine credential is bound to this endpoint identity."2012+ } else {2013+ "The machine credential is bound to a different endpoint identity."2014+ },2015+ );2016+ let registered_matches_local =2017+ info.registered_endpoint_id.as_deref() == endpoint_id.as_deref();2018+ let registration_status = if info.registered_endpoint_id.is_none() {2019+ if config.resources.is_empty() {2020+ "pass"2021+ } else {2022+ "warning"2023+ }2024+ } else if registered_matches_local {2025+ "pass"2026+ } else if daemon_running {2027+ "failure"2028+ } else {2029+ "warning"2030+ };2031+ push_doctor_check(2032+ &mut checks,2033+ &mut counts,2034+ "daemon.registration",2035+ registration_status,2036+ match info.registered_endpoint_id {2037+ None => "No daemon endpoint has been registered yet.".to_string(),2038+ Some(ref registered) if Some(registered.as_str()) == endpoint_id.as_deref() => {2039+ "The account is registered to this endpoint identity.".to_string()2040+ }2041+ Some(registered) if info.registration_matches_credential => format!(2042+ "The account is registered to the credential endpoint ({registered}), but that is not this machine's local identity."2043+ ),2044+ Some(registered) => format!(2045+ "The account is registered to another endpoint identity ({registered})."2046+ ),2047+ },2048+ );2049+ if bound_matches && !registered_matches_local && !config.resources.is_empty() {2050+ actions.push(serde_json::json!({2051+ "id": "register-this-endpoint",2052+ "priority": 2,2053+ "reason": "account_registered_to_another_endpoint",2054+ "commands": daemon_start_commands(),2055+ "mutates": "account_daemon_registration",2056+ "requires_user_input": true,2057+ }));2058+ }2059+ }2060+ Err(err) => push_doctor_check(2061+ &mut checks,2062+ &mut counts,2063+ "identity.credential_binding",2064+ "failure",2065+ format!("{err:#}"),2066+ ),2067+ }2068+2069+ match api.get::<ResourceListing>("/api/resources").await {2070+ Ok(listing) => {2071+ let remote = listing2072+ .resources2073+ .iter()2074+ .map(|resource| resource.resource_id.as_str())2075+ .collect::<HashSet<_>>();2076+ let local_only = config2077+ .resources2078+ .iter()2079+ .filter(|resource| !remote.contains(resource.resource_id.to_string().as_str()))2080+ .map(|resource| resource.name.clone())2081+ .collect::<Vec<_>>();2082+ let visibility_mismatches = config2083+ .resources2084+ .iter()2085+ .filter_map(|local| {2086+ listing2087+ .resources2088+ .iter()2089+ .find(|remote| remote.resource_id == local.resource_id.to_string())2090+ .filter(|remote| remote.visibility != visibility_str(local.visibility))2091+ .map(|_| local.name.clone())2092+ })2093+ .collect::<Vec<_>>();2094+ let reconciled = local_only.is_empty() && visibility_mismatches.is_empty();2095+ push_doctor_check(2096+ &mut checks,2097+ &mut counts,2098+ "resources.reconciled",2099+ if reconciled { "pass" } else { "warning" },2100+ if reconciled {2101+ "Every local service mapping has a remote resource.".to_string()2102+ } else {2103+ format!(2104+ "Local-only services: {}; visibility mismatches: {}.",2105+ if local_only.is_empty() {2106+ "none".to_string()2107+ } else {2108+ local_only.join(", ")2109+ },2110+ if visibility_mismatches.is_empty() {2111+ "none".to_string()2112+ } else {2113+ visibility_mismatches.join(", ")2114+ }2115+ )2116+ },2117+ );2118+ for name in local_only {2119+ actions.push(serde_json::json!({2120+ "id": format!("remove-local-only-{name}"),2121+ "priority": 2,2122+ "reason": "missing_remote_resource",2123+ "commands": [["devsite", "service", "remove", name]],2124+ "mutates": "local_config",2125+ "requires_user_input": false,2126+ }));2127+ }2128+ }2129+ Err(err) => push_doctor_check(2130+ &mut checks,2131+ &mut counts,2132+ "resources.reconciled",2133+ "failure",2134+ format!("{err:#}"),2135+ ),2136+ }2137+2138+ write_doctor_report(output, checks, counts, actions)2139+}2140+2141+fn write_doctor_report(2142+ output: Output,2143+ checks: Vec<serde_json::Value>,2144+ counts: [u64; 4],2145+ actions: Vec<serde_json::Value>,2146+) -> Result<()> {2147+ let report = serde_json::json!({2148+ "healthy": counts[2] == 0,2149+ "summary": {2150+ "pass": counts[0],2151+ "warning": counts[1],2152+ "failure": counts[2],2153+ "skipped": counts[3],2154+ },2155+ "checks": checks,2156+ "actions": actions,2157+ });2158+ if output.json {2159+ output.success("doctor", report);2160+ } else {2161+ println!(2162+ "doctor: {} pass, {} warning, {} failure, {} skipped",2163+ counts[0], counts[1], counts[2], counts[3]2164+ );2165+ for check in report["checks"].as_array().unwrap_or(&Vec::new()) {2166+ println!(2167+ " {:<7} {:<32} {}",2168+ check["status"].as_str().unwrap_or("unknown"),2169+ check["id"].as_str().unwrap_or("check"),2170+ check["message"].as_str().unwrap_or("")2171+ );2172+ }2173+ }2174+ Ok(())2175+}2176+11832177 fn status(paths: &Paths, output: Output) -> Result<()> {11842178 let config = paths.load_config()?;11852179 let daemon_running = paths.daemon_running()?;2180+ let endpoint_id = std::fs::read_to_string(paths.identity_public())2181+ .ok()2182+ .map(|value| value.trim().to_string())2183+ .filter(|value| !value.is_empty());11862184 if output.json {11872185 let services = config11882186 .resources@@ -1201,6 +2199,13 @@ fn status(paths: &Paths, output: Output) -> Result<()> {
12012199 serde_json::json!({12022200 "config_path": paths.config(),12032201 "identity_path": paths.identity(),2202+ "identity": {2203+ "public_path": paths.identity_public(),2204+ "endpoint_id": endpoint_id,2205+ },2206+ "authentication": {2207+ "configured": config.machine_credential.is_some(),2208+ },12042209 "server": config.server_url,12052210 "control_plane_key": config.control_plane_key,12062211 "daemon": daemon_state(daemon_running),@@ -1300,6 +2305,37 @@ fn daemon_start_hints() -> &'static [&'static str] {
13002305 &["keep `devsite daemon run` alive with your service manager"]13012306 }13022307 2308+2309+fn daemon_start_commands() -> serde_json::Value {2310+ serde_json::json!([2311+ ["brew", "services", "start", "devsite"],2312+ ["devsite", "daemon", "run"]2313+ ])2314+}2315+2316+2317+fn daemon_start_commands() -> serde_json::Value {2318+ serde_json::json!([2319+ ["systemctl", "--user", "enable", "--now", "devsite.service"],2320+ ["devsite", "daemon", "run"]2321+ ])2322+}2323+2324+2325+fn daemon_start_commands() -> serde_json::Value {2326+ serde_json::json!([["devsite", "daemon", "run"]])2327+}2328+2329+2330+fn upgrade_commands() -> serde_json::Value {2331+ serde_json::json!([["brew", "upgrade", "devsite"]])2332+}2333+2334+2335+fn upgrade_commands() -> serde_json::Value {2336+ serde_json::json!([])2337+}2338+13032339 async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {13042340 let config = load_authenticated(paths)?;13052341 let _daemon_lock = paths@@ -37,6 +37,14 @@ fn global_json_flag_structures_success() {
3737 assert_eq!(value["command"], "status");3838 assert_eq!(value["result"]["daemon"]["running"], false);3939 assert!(value["result"]["services"].is_array());40+ assert!(value["result"]["identity_path"]41+ .as_str()42+ .unwrap()43+ .ends_with("devsite-endpoint.key"));44+ assert!(value["result"]["identity"]["public_path"]45+ .as_str()46+ .unwrap()47+ .ends_with("devsite-endpoint.pub"));4048 assert!(output.stderr.is_empty());4149 }4250 @@ -104,3 +112,27 @@ fn human_runtime_errors_end_with_a_recovery_suggestion() {
104112 assert!(stderr.contains("error: port 0 cannot be hosted"));105113 assert!(stderr.contains("suggestion: Run `devsite service host --help`"));106114 }115+116+117+fn doctor_reports_state_and_actions_without_enrollment() {118+ let output = devsite(&["doctor", "--json"]);119+ assert!(output.status.success());120+ let value = json(&output);121+ assert_eq!(value["command"], "doctor");122+ assert_eq!(value["result"]["healthy"], true);123+ assert!(value["result"]["checks"].is_array());124+ assert_eq!(value["result"]["actions"][0]["id"], "login");125+}126+127+128+fn resources_and_plan_flags_are_discoverable_in_structured_help() {129+ let resources = devsite(&["resources", "list", "--json", "--help"]);130+ assert!(resources.status.success());131+ assert_eq!(json(&resources)["result"]["command"], "resources.list");132+133+ let plan = devsite(&["link", "set", "--json", "--help"]);134+ assert!(plan.status.success());135+ let text = json(&plan)["result"]["text"].as_str().unwrap().to_string();136+ assert!(text.contains("--plan"));137+ assert!(text.contains("--dry-run"));138+}@@ -103,13 +103,13 @@ impl Paths {
103103 }104104 105105 pub fn identity(&self) -> PathBuf {106- self.root.join("identity.key")106+ self.root.join("devsite-endpoint.key")107107 }108108 109109 /// Public half of [`Self::identity`]. Public Ed25519 material follows the110110 /// conventional `.pub` naming used by the rest of the CLI handoff files.111111 pub fn identity_public(&self) -> PathBuf {112- self.root.join("identity.pub")112+ self.root.join("devsite-endpoint.pub")113113 }114114 115115 pub fn config(&self) -> PathBuf {@@ -166,6 +166,7 @@ impl Paths {
166166 /// This key *is* the daemon's identity — every capability ever issued names it as the167167 /// audience — so it must survive restarts and must not be world-readable.168168 pub fn load_or_create_identity(&self) -> Result<SecretKey> {169+ self.migrate_legacy_identity_files()?;169170 let path = self.identity();170171 let key = if path.exists() {171172 let raw =@@ -190,6 +191,22 @@ impl Paths {
190191 }191192 Ok(key)192193 }194+195+ /// Move endpoint identity files written by CLI versions before the filenames196+ /// identified their purpose. Existing destination files always win.197+ fn migrate_legacy_identity_files(&self) -> Result<()> {198+ for (legacy, current) in [199+ (self.root.join("identity.key"), self.identity()),200+ (self.root.join("identity.pub"), self.identity_public()),201+ ] {202+ if legacy.exists() && !current.exists() {203+ std::fs::rename(&legacy, ¤t).with_context(|| {204+ format!("moving {} to {}", legacy.display(), current.display())205+ })?;206+ }207+ }208+ Ok(())209+ }193210 }194211 195212 @@ -287,4 +304,26 @@ mod tests {
287304 288305 std::fs::remove_dir_all(&paths.root).unwrap();289306 }307+308+ 309+ fn legacy_identity_filenames_migrate_without_changing_the_key() {310+ let paths = test_paths();311+ std::fs::create_dir_all(&paths.root).unwrap();312+ let original = SecretKey::generate();313+ std::fs::write(paths.root.join("identity.key"), original.to_bytes()).unwrap();314+ std::fs::write(315+ paths.root.join("identity.pub"),316+ format!("{}\n", original.public()),317+ )318+ .unwrap();319+320+ let loaded = paths.load_or_create_identity().unwrap();321+322+ assert_eq!(loaded.to_bytes(), original.to_bytes());323+ assert!(paths.identity().exists());324+ assert!(paths.identity_public().exists());325+ assert!(!paths.root.join("identity.key").exists());326+ assert!(!paths.root.join("identity.pub").exists());327+ std::fs::remove_dir_all(&paths.root).unwrap();328+ }290329 }@@ -244,6 +244,49 @@ pub struct CreateResourceRequest {
244244 245245 pub struct CreateResourceResponse {246246 pub resource_id: String,247+ pub plan: ResourcePlan,248+}249+250+251+pub struct ResourcePlanResponse {252+ pub plan: ResourcePlan,253+}254+255+256+pub struct ResourcePlan {257+ pub operation: &'static str,258+ pub target: ResourcePlanTarget,259+ pub changes: Vec<ResourcePlanChange>,260+ pub recipient_changes: Vec<RecipientPlanChange>,261+ pub effects: Vec<ResourcePlanEffect>,262+}263+264+265+pub struct ResourcePlanTarget {266+ pub resource_id: Option<String>,267+ pub kind: &'static str,268+ pub name: String,269+}270+271+272+pub struct ResourcePlanChange {273+ pub field: &'static str,274+ pub from: serde_json::Value,275+ pub to: serde_json::Value,276+}277+278+279+pub struct RecipientPlanChange {280+ pub handle: String,281+ pub from: Option<&'static str>,282+ pub to: Option<&'static str>,283+ pub reason: &'static str,284+}285+286+287+pub struct ResourcePlanEffect {288+ pub code: &'static str,289+ pub handles: Vec<String>,247290 }248291 249292 @@ -351,6 +394,7 @@ pub fn router(state: Shared) -> Router {
351394 .route("/api/me", get(me))352395 .route("/api/profile", post(claim_handle))353396 .route("/api/resources", post(create_resource).get(list_resources))397+ .route("/api/resources/plan", post(plan_resource))354398 .route("/api/resources/{id}", delete(delete_resource))355399 .route("/api/resources/{id}/shares", put(set_resource_shares))356400 .route("/api/share-invitations", get(list_share_invitations))@@ -362,7 +406,7 @@ pub fn router(state: Shared) -> Router {
362406 "/api/share-invitations/{id}",363407 delete(decline_share_invitation),364408 )365- .route("/api/daemon", put(register_daemon))409+ .route("/api/daemon", get(daemon_info).put(register_daemon))366410 .route("/api/daemon/authorizations", get(daemon_authorizations))367411 .route("/api/machine/enroll", put(enroll_machine))368412 .route(@@ -523,8 +567,66 @@ async fn create_resource(
523567 ) -> ApiResult<impl IntoResponse> {524568 let owner = require_account(&state, &headers)?;525569 check_rate(&state, owner, RateClass::Mutation)?;526- let name = validate_resource_name(&body.name)?;570+ let mut db = state.db.lock().unwrap();571+ let mut prepared = prepare_resource(&db, owner, &body)?;572+573+ let resource = db574+ .create_resource(575+ owner,576+ &prepared.name,577+ prepared.kind,578+ prepared.visibility,579+ prepared.url.as_deref(),580+ prepared.folder.as_deref(),581+ now_secs(),582+ )583+ .map_err(ApiError::internal)?;584+585+ // The share list this request names is the whole share list afterwards.586+ // Re-running `service host --share @carol` means Carol is the invited recipient,587+ // not Carol plus whoever was named the last time it ran.588+ db.set_shares(resource, &prepared.viewers)589+ .map_err(ApiError::internal)?;590+ prepared.plan.target.resource_id = Some(resource.to_string());591+592+ Ok(Json(CreateResourceResponse {593+ resource_id: resource.to_string(),594+ plan: prepared.plan,595+ }))596+}597+598+async fn plan_resource(599+ State(state): State<Shared>,600+ headers: HeaderMap,601+ Json(body): Json<CreateResourceRequest>,602+) -> ApiResult<impl IntoResponse> {603+ let owner = require_account(&state, &headers)?;604+ // Planning resolves account handles and therefore uses the same bounded605+ // rate class as applying a mutation.606+ check_rate(&state, owner, RateClass::Mutation)?;607+ let db = state.db.lock().unwrap();608+ let prepared = prepare_resource(&db, owner, &body)?;609+ Ok(Json(ResourcePlanResponse {610+ plan: prepared.plan,611+ }))612+}527613 614+struct PreparedResource {615+ name: String,616+ kind: ResourceKind,617+ visibility: Visibility,618+ url: Option<String>,619+ folder: Option<String>,620+ viewers: Vec<AccountId>,621+ plan: ResourcePlan,622+}623+624+fn prepare_resource(625+ db: &Db,626+ owner: AccountId,627+ body: &CreateResourceRequest,628+) -> ApiResult<PreparedResource> {629+ let name = validate_resource_name(&body.name)?.to_string();528630 let kind = match body.kind.as_str() {529631 "link" => ResourceKind::Link,530632 "service" => ResourceKind::Service,@@ -553,13 +655,15 @@ async fn create_resource(
553655 }554656 ResourceKind::Service => None,555657 };556-557658 let folder = validate_folder(body.folder.as_deref())?;558659 559- let mut db = state.db.lock().unwrap();560-660+ let existing = db661+ .resources_owned_by(owner)662+ .map_err(ApiError::internal)?663+ .into_iter()664+ .find(|resource| resource.name == name && resource.kind == kind);561665 if !db562- .resource_named(owner, name, kind)666+ .resource_named(owner, &name, kind)563667 .map_err(ApiError::internal)?564668 && db.resource_count(owner).map_err(ApiError::internal)? >= MAX_RESOURCES565669 {@@ -568,32 +672,164 @@ async fn create_resource(
568672 )));569673 }570674 571- // Resolve every share target *before* creating anything. Creating first and failing572- // partway leaves an orphaned resource on the profile that the owner's daemon knows573- // nothing about — it would advertise itself and then refuse every request.574- let viewers = resolve_share_accounts(&db, owner, &body.share_with)?;675+ // Resolve every share target before either planning or creating anything.676+ let viewers = resolve_share_accounts(db, owner, &body.share_with)?;677+ let desired_handles = viewers678+ .iter()679+ .map(|viewer| {680+ db.account_by_id(*viewer)681+ .map_err(ApiError::internal)?682+ .and_then(|account| account.handle)683+ .ok_or_else(|| ApiError::bad_request("a share recipient no longer has a handle"))684+ })685+ .collect::<ApiResult<Vec<_>>>()?;686+ let plan = build_resource_plan(687+ db,688+ existing.as_ref(),689+ &name,690+ kind,691+ visibility,692+ url.as_deref(),693+ folder.as_deref(),694+ &desired_handles,695+ )?;696+697+ Ok(PreparedResource {698+ name,699+ kind,700+ visibility,701+ url,702+ folder,703+ viewers,704+ plan,705+ })706+}707+708+709+fn build_resource_plan(710+ db: &Db,711+ existing: Option<&crate::db::Resource>,712+ name: &str,713+ kind: ResourceKind,714+ visibility: Visibility,715+ url: Option<&str>,716+ folder: Option<&str>,717+ desired_handles: &[String],718+) -> ApiResult<ResourcePlan> {719+ let mut changes = Vec::new();720+ let current_visibility = existing.map(|resource| resource.visibility.as_str());721+ let current_url = existing.and_then(|resource| resource.url.as_deref());722+ let current_folder = existing.and_then(|resource| resource.folder.as_deref());723+ for (field, from, to) in [724+ (725+ "visibility",726+ serde_json::to_value(current_visibility).unwrap(),727+ serde_json::to_value(Some(visibility.as_str())).unwrap(),728+ ),729+ (730+ "url",731+ serde_json::to_value(current_url).unwrap(),732+ serde_json::to_value(url).unwrap(),733+ ),734+ (735+ "folder",736+ serde_json::to_value(current_folder).unwrap(),737+ serde_json::to_value(folder).unwrap(),738+ ),739+ ] {740+ if from != to {741+ changes.push(ResourcePlanChange { field, from, to });742+ }743+ }575744 576- let resource = db577- .create_resource(578- owner,579- name,580- kind,581- visibility,582- url.as_deref(),583- folder.as_deref(),584- now_secs(),585- )586- .map_err(ApiError::internal)?;745+ let existing_recipients = match existing {746+ Some(resource) => db747+ .share_recipients(resource.id)748+ .map_err(ApiError::internal)?,749+ None => Vec::new(),750+ };751+ let desired = desired_handles752+ .iter()753+ .map(|handle| handle.to_ascii_lowercase())754+ .collect::<HashSet<_>>();755+ let existing_names = existing_recipients756+ .iter()757+ .map(|recipient| recipient.handle.to_ascii_lowercase())758+ .collect::<HashSet<_>>();759+ let destination_changed =760+ kind == ResourceKind::Link && existing.is_some() && current_url != url;761+ let mut recipient_changes = Vec::new();762+ let mut reapproval = Vec::new();763+ let mut access_revoked = Vec::new();764+ let mut invitations_withdrawn = Vec::new();765+ let mut invited = Vec::new();766+767+ for recipient in &existing_recipients {768+ let wanted = desired.contains(&recipient.handle.to_ascii_lowercase());769+ if !wanted {770+ recipient_changes.push(RecipientPlanChange {771+ handle: recipient.handle.clone(),772+ from: Some(recipient.status.as_str()),773+ to: None,774+ reason: "recipient_removed",775+ });776+ match recipient.status {777+ ShareStatus::Accepted => access_revoked.push(recipient.handle.clone()),778+ ShareStatus::Pending => invitations_withdrawn.push(recipient.handle.clone()),779+ ShareStatus::Declined => {}780+ }781+ } else if destination_changed && recipient.status == ShareStatus::Accepted {782+ recipient_changes.push(RecipientPlanChange {783+ handle: recipient.handle.clone(),784+ from: Some("accepted"),785+ to: Some("pending"),786+ reason: "destination_changed",787+ });788+ reapproval.push(recipient.handle.clone());789+ }790+ }791+ for handle in desired_handles {792+ if !existing_names.contains(&handle.to_ascii_lowercase()) {793+ recipient_changes.push(RecipientPlanChange {794+ handle: handle.clone(),795+ from: None,796+ to: Some("pending"),797+ reason: "recipient_added",798+ });799+ invited.push(handle.clone());800+ }801+ }587802 588- // The share list this request names is the whole share list afterwards.589- // Re-running `service host --share @carol` means Carol is the invited recipient,590- // not Carol plus whoever was named the last time it ran.591- db.set_shares(resource, &viewers)592- .map_err(ApiError::internal)?;803+ let mut effects = Vec::new();804+ for (code, handles) in [805+ ("recipient_reapproval_required", reapproval),806+ ("accepted_access_revoked", access_revoked),807+ ("pending_invitations_withdrawn", invitations_withdrawn),808+ ("recipients_invited", invited),809+ ] {810+ if !handles.is_empty() {811+ effects.push(ResourcePlanEffect { code, handles });812+ }813+ }593814 594- Ok(Json(CreateResourceResponse {595- resource_id: resource.to_string(),596- }))815+ let operation = if existing.is_none() {816+ "create"817+ } else if changes.is_empty() && recipient_changes.is_empty() {818+ "noop"819+ } else {820+ "update"821+ };822+ Ok(ResourcePlan {823+ operation,824+ target: ResourcePlanTarget {825+ resource_id: existing.map(|resource| resource.id.to_string()),826+ kind: kind.as_str(),827+ name: name.to_string(),828+ },829+ changes,830+ recipient_changes,831+ effects,832+ })597833 }598834 599835 fn validate_resource_visibility(kind: ResourceKind, visibility: Visibility) -> ApiResult<()> {@@ -874,6 +1110,27 @@ async fn register_daemon(
8741110 Ok(StatusCode::NO_CONTENT)8751111 }8761112 1113+/// Report endpoint identities without implying daemon liveness. Registration is1114+/// durable and records only which endpoint the account currently addresses.1115+async fn daemon_info(1116+ State(state): State<Shared>,1117+ headers: HeaderMap,1118+) -> ApiResult<impl IntoResponse> {1119+ let machine = require_machine(&state, &headers)?;1120+ let registered_endpoint_id = state1121+ .db1122+ .lock()1123+ .unwrap()1124+ .daemon_endpoint(machine.account.id)1125+ .map_err(ApiError::internal)?;1126+ Ok(Json(serde_json::json!({1127+ "credential_endpoint_id": machine.endpoint_id,1128+ "registration_matches_credential": registered_endpoint_id.as_deref()1129+ == Some(machine.endpoint_id.as_str()),1130+ "registered_endpoint_id": registered_endpoint_id,1131+ })))1132+}1133+8771134 async fn daemon_authorizations(8781135 State(state): State<Shared>,8791136 headers: HeaderMap,@@ -1628,4 +1885,71 @@ mod tests {
16281885 headers.insert(header::AUTHORIZATION, "Bearer cli-token".parse().unwrap());16291886 assert_eq!(bearer_token(&headers).as_deref(), Some("cli-token"));16301887 }1888+1889+ 1890+ fn planning_a_resource_validates_without_writing() {1891+ let db = Db::open(":memory:").unwrap();1892+ let alice = db.upsert_account("test", "alice", 1).unwrap();1893+ let body = CreateResourceRequest {1894+ name: "docs".into(),1895+ kind: "link".into(),1896+ visibility: "public".into(),1897+ url: Some("https://example.com/docs".into()),1898+ share_with: Vec::new(),1899+ folder: Some("Projects".into()),1900+ };1901+1902+ let prepared = prepare_resource(&db, alice.id, &body).unwrap();1903+1904+ assert_eq!(prepared.plan.operation, "create");1905+ assert_eq!(db.resource_count(alice.id).unwrap(), 0);1906+ assert_eq!(prepared.plan.target.resource_id, None);1907+ }1908+1909+ 1910+ fn changing_a_shared_link_destination_plans_reapproval() {1911+ let mut db = Db::open(":memory:").unwrap();1912+ let alice = db.upsert_account("test", "alice", 1).unwrap();1913+ let bob = db.upsert_account("test", "bob", 1).unwrap();1914+ db.set_handle(bob.id, "bob").unwrap();1915+ let resource = db1916+ .create_resource(1917+ alice.id,1918+ "staging",1919+ ResourceKind::Link,1920+ Visibility::Shared,1921+ Some("https://old.example.com/"),1922+ None,1923+ 1,1924+ )1925+ .unwrap();1926+ db.set_shares(resource, &[bob.id]).unwrap();1927+ assert!(db.accept_share(bob.id, resource).unwrap());1928+ let body = CreateResourceRequest {1929+ name: "staging".into(),1930+ kind: "link".into(),1931+ visibility: "shared".into(),1932+ url: Some("https://new.example.com/".into()),1933+ share_with: vec!["bob".into()],1934+ folder: None,1935+ };1936+1937+ let prepared = prepare_resource(&db, alice.id, &body).unwrap();1938+1939+ assert_eq!(prepared.plan.operation, "update");1940+ assert!(prepared.plan.recipient_changes.iter().any(|change| {1941+ change.handle == "bob"1942+ && change.from == Some("accepted")1943+ && change.to == Some("pending")1944+ && change.reason == "destination_changed"1945+ }));1946+ assert!(prepared.plan.effects.iter().any(|effect| {1947+ effect.code == "recipient_reapproval_required" && effect.handles == ["bob"]1948+ }));1949+ assert_eq!(1950+ db.share_recipients(resource).unwrap()[0].status,1951+ ShareStatus::Accepted,1952+ "planning must not mutate recipient state"1953+ );1954+ }16311955 }@@ -27,3 +27,28 @@ transport warnings remain on stderr and never contaminate JSON stdout.
2727 2828 JSON mode is non-interactive. In particular, `devsite login --json` requires its ticket as an2929 argument rather than prompting on stdin.30+31+## Inventory, plans, and diagnostics32+33+`devsite resources list --json` joins the control plane's owned resources with this34+machine's hosted-service config. Service states distinguish `serving_here`,35+`configured_here`, and `not_configured_here`; `local_only_services` identifies local36+entries whose remote resource is already absent.37+38+Resource mutations accept `--plan` (`--dry-run` is an alias):39+40+```console41+devsite link set --name docs --url https://example.com --public --plan --json42+devsite service remove postgres --plan --json43+```44+45+Plans return `applied: false` with validated field, recipient, effect, and local-config46+changes. They never write remote or local state. Applied mutations retain their existing47+result fields and add `applied: true` plus the authoritative plan the server applied.48+49+`devsite doctor --json` returns a report rather than treating an unhealthy installation as50+a command failure. The envelope remains `ok: true` when the report was produced; inspect51+`result.healthy`, `result.checks`, and `result.actions`. Doctor checks endpoint identity,52+file permissions, server/API compatibility, the pinned signing key, credential binding,53+daemon registration, and remote/local resource drift. It does not open connections to54+hosted services.@@ -0,0 +1,64 @@
1+---2+name: devsite-cli3+description: Operate and explain the devsite command-line interface. Use when a user asks to publish or change a dev.site profile link, share or stop sharing a local TCP service, connect to a service shared with them, inspect hosted resources, check devsite setup, manage the foreground daemon, customize profile presentation, or troubleshoot devsite CLI behavior. Also use for intent such as “share localhost,” “expose my local database,” “publish my docs,” or “what can dev.site do?”4+---5+6+# devsite CLI7+8+Use the installed CLI as the source of truth. This skill is portable: use any shell/process and JSON parser available in the current harness; do not require vendor-specific tools, paths, metadata, or callbacks.9+10+## JSON contract11+12+- Prefer `devsite --json …` for automation. A finite command writes exactly one JSON object to stdout; check `schema_version`, then `ok` before using `result`.13+- On failure, read `error.message`, `error.causes`, and `error.suggestions`; present or follow the relevant recovery action instead of guessing.14+- Inspect command syntax with `devsite <command> --json --help` whenever arguments or available subcommands are uncertain. Structured help succeeds with `command: "help"` and `result.text`.15+- Treat stdout as the machine-readable channel. Do not attempt to parse human diagnostics from stderr as JSON.16+- JSON mode is non-interactive. Supply values such as an enrollment or connection ticket only when the user provides them; do not echo tickets, machine credentials, or endpoint private keys in reports.17+18+## Begin with state19+20+- Use `devsite --json status` for local configuration, enrolled server, endpoint public identity, daemon liveness, and locally configured services. It is a local check.21+- Use `devsite --json resources list` to inventory owned remote links/services together with local hosting state and share status. Use it before updating or removing an ambiguously named resource, and to reconcile local and remote state.22+- Use `devsite --json doctor` when setup, authentication, daemon registration, compatibility, identity files, or resource drift may be wrong. `result.healthy` is the health outcome; an otherwise successful doctor invocation may legitimately report warnings or failures. Prioritize `result.actions` by `priority`; treat an action with `requires_user_input: true` as a request for missing user authority or material.23+24+## Plan before changing access or deleting25+26+Use `--plan` before every upsert or removal. `--dry-run` is an alias. A plan validates the intended change without writing local configuration, changing the daemon, or mutating the control plane.27+28+```sh29+devsite --json link set --name docs --url https://example.com/docs --public --plan30+devsite --json service host 5432 --name postgres --share @alice --plan31+devsite --json link remove docs --plan32+devsite --json service remove postgres --plan33+```34+35+- Explain the planned target, visibility, recipients, destination/folder, and effects before applying it. Pay particular attention to effects that revoke access, withdraw invitations, make something public, or require a recipient to re-approve a changed link destination.36+- Require a clear target for removals. Obtain confirmation before deleting or materially broadening/narrowing access unless the user's request already explicitly confirms that exact action.37+- Apply the same command without `--plan` only after the plan is understood and authorized. A plan is not a transaction; refresh or re-plan if the state may have changed before apply.38+39+## Map user intent to workflows40+41+| Intent | Safe workflow |42+| --- | --- |43+| Publish a profile link | Plan `link set --name NAME --url HTTPS_URL`; use `--public` for public access, `--share @HANDLE` for invitation-based sharing, or neither for private. Apply after review. |44+| Host a local TCP service | Plan `service host PORT --name NAME`; a service always targets loopback on the host. Add repeatable `--share @HANDLE` only when sharing is intended. Apply, then ensure the daemon is running. |45+| Stop serving a service | Inspect `resources list`, plan `service remove NAME`, explain remote-access and local-mapping effects, then apply after confirmation. |46+| Remove a link | Inspect `resources list`, plan `link remove NAME`, explain access/profile effects, then apply after confirmation. |47+| Reach a shared service | Obtain a user-provided, short-lived single-use connection ticket from the approved profile entry. Run `connect TICKET` and wait for its ready event; use `--listen 127.0.0.1:PORT` only for a deliberate loopback port. |48+| Enroll this machine | Obtain a user-provided single-use machine ticket and run `login TICKET`. This creates or uses the local endpoint identity and stores a revocable machine credential. |49+| Diagnose availability | Run `doctor`; use its checks and proposed actions. For a configured but stopped host, start `daemon run` under the harness’s normal long-running-process supervision. |50+| Change profile presentation | Discover exact syntax with `theme --json --help`; use `theme properties` before producing a theme declaration file, then `theme set FILE` or `theme clear` as authorized. |51+52+Folders are display grouping, not authorization boundaries. A public profile entry is public; shared entries require the invitee to accept the share. The control plane manages identity and permissions, while service traffic is a peer-to-peer TCP connection to the owner’s local loopback target.53+54+## Manage resident commands55+56+`devsite --json connect …` and `devsite --json daemon run` are resident processes. Their stdout is NDJSON: parse one JSON object per line and preserve process lifetime. `connect` is ready on its `listening` event; `daemon run` is ready on its `online` event. Continue to surface later error or `shutdown` events. Do not treat the initial process spawn as success and do not block forever without reporting observed state.57+58+Run the daemon only when hosting services. Running a daemon can register this endpoint as the account’s active daemon, so inspect `doctor` when another machine may already be hosting.59+60+## Explain, do not obscure61+62+For discovery requests, lead with the user’s likely outcome, then offer the smallest relevant workflow. Explain that devsite publishes normal links and hosts private TCP byte streams; it is not an HTTP reverse proxy and it does not expose arbitrary non-loopback targets. Prefer small, reversible steps and always report the resulting resource name/ID, visibility, recipients, local listening address, or next action from JSON.63+64+Never claim that a service is reachable merely because its profile resource exists: hosting requires a running daemon, and connecting requires an accepted share and a valid ticket.