From 5baa456150175662cf99b97f703ce0502a29f5c0 Mon Sep 17 00:00:00 2001 From: Kyle Tse Date: Sat, 3 Oct 2026 00:14:21 +0000 Subject: [PATCH] feat(pro): free upgrade preview with counts and the Pro link instead of a bare pro_required --- CHANGELOG.md | 1 + crates/lockdocs/src/main.rs | 26 +++++++- crates/lockdocs/src/mcp.rs | 48 +++++++++++++- crates/lockdocs/src/preview.rs | 113 +++++++++++++++++++++++++++++++++ crates/lockdocs/src/pro.rs | 4 ++ docs/pro.md | 17 ++++- 6 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 crates/lockdocs/src/preview.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b39639c..8c62c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- **Free upgrade preview.** Without a Pro licence, `lockdocs upgrade ` (and `docs --upgrade-to`, and the `docs` tool's `upgrade_to`) now answers with a short local preview instead of only "Pro required": how many of the APIs your project calls were removed, renamed, re-signed or deprecated, across how many call sites, plus one sample, then the Pro price and link. The full list stays Pro. The MCP answer still carries `structuredContent.pro_required`, the CLI still exits 3, and the preview reads the same installed, cached or opted-in files as the report (no new downloads). - **`lockdocs licence buy`.** Built on mcp-kit 0.6.0. It opens the Pro page; in-terminal purchase turns on when checkout is live. ## 0.6.0 diff --git a/crates/lockdocs/src/main.rs b/crates/lockdocs/src/main.rs index 6b88d4a..3fadaf5 100644 --- a/crates/lockdocs/src/main.rs +++ b/crates/lockdocs/src/main.rs @@ -1,4 +1,5 @@ mod mcp; +mod preview; mod pro; mod setup; mod tools; @@ -169,7 +170,30 @@ fn run() -> Result<()> { } } Err(required) => { - eprintln!("{required}"); + let json = args.on("json"); + let (pkg, target) = if cmd == "upgrade" { + (args.positional.first().map(String::as_str), upgrade_target) + } else { + (args.pkg(), upgrade_target) + }; + match (pkg, target) { + (Some(pkg), Some(target)) => { + // A local, honest preview: counts only, same download opt-ins as the report. + let engine = Engine::new(&args.root(), args.opts()); + match engine.upgrade_report(pkg, target, args.tokens()) { + Ok(answer) => { + let p = preview::build(&answer.json, &required, pro::PRICE); + if json { + println!("{}", serde_json::to_string_pretty(&p.json).unwrap_or_default()); + } else { + println!("{}", p.text); + } + } + Err(e) => eprintln!("{e}\n{required}"), + } + } + _ => eprintln!("{required}"), + } std::process::exit(3); } } diff --git a/crates/lockdocs/src/mcp.rs b/crates/lockdocs/src/mcp.rs index 0f638b7..ec8be92 100644 --- a/crates/lockdocs/src/mcp.rs +++ b/crates/lockdocs/src/mcp.rs @@ -1,6 +1,6 @@ //! The MCP server: lockdocs' tools on mcp-kit (rmcp over stdio). -use crate::{pro, tools}; +use crate::{preview, pro, tools}; use lockdocs_core::query::{Options, Workspace}; use mcp_kit::licence::{self, LicencePolicy}; use mcp_kit::rmcp::model::{CallToolResult, ContentBlock}; @@ -31,6 +31,27 @@ impl Lockdocs { client: &call.client_roots, }) } + + /// The free answer to an upgrade call: a local preview (counts and a sample), + /// still carrying `pro_required`. If the preview cannot be computed (target + /// files not available, unknown package) the reason is appended to the plain + /// Pro answer. + fn preview(&self, name: &str, args: &Value, call: &Call, required: &licence::ProRequired) -> CallToolResult { + let mut result = licence::required_result(required); + match self.root(args, call).and_then(|root| tools::call(&self.ws, &self.opts, name, args, &root)) { + Ok(answer) => { + let p = preview::build(&answer.json, required, pro::PRICE); + let body = if args.get("format").and_then(|v| v.as_str()) == Some("json") { + serde_json::to_string_pretty(&p.json).unwrap_or_default() + } else { + p.text + }; + result.content = vec![ContentBlock::text(body)]; + } + Err(e) => result.content = vec![ContentBlock::text(format!("{required}\nPreview unavailable: {e}"))], + } + result + } } impl App for Lockdocs { @@ -65,7 +86,7 @@ impl App for Lockdocs { if tools::is_upgrade(name, args) { match pro::require(&self.policy, pro::UPGRADE_REPORT) { Ok(licence) => note = pro::renewal_note(&self.policy, &licence), - Err(required) => return licence::required_result(&required), + Err(required) => return self.preview(name, args, call, &required), } } match self.call(name, args, call) { @@ -208,6 +229,29 @@ mod tests { assert_eq!(r["structuredContent"]["pro_required"]["product"], "lockdocs"); assert_eq!(r["structuredContent"]["pro_required"]["feature"], pro::UPGRADE_REPORT); assert!(!text(&r).contains("RENAMED")); + // ...whose text is a local preview: counts and a sample, not the list. + let pv = text(&r); + assert!( + pv.contains("tiny-schema 1.0.0 -> 2.0.0: 3 of the APIs your project calls change (1 removed, 1 renamed, 1 re-signed) across 3 call sites"), + "{pv}" + ); + assert!(pv.contains("US$120/seat/yr") && pv.contains("https://example.com/pro"), "{pv}"); + assert!(!pv.contains("was:") && !pv.contains("now:"), "{pv}"); + // JSON form keeps the marker and the counts. + let pj = call( + &a, + "docs", + json!({"query": "x", "package": "tiny-schema", "upgrade_to": "2.0.0", "root": root, "format": "json"}), + ); + assert!(pj["structuredContent"]["pro_required"].is_object(), "{pj}"); + let pj: Value = serde_json::from_str(&text(&pj)).unwrap(); + assert_eq!(pj["call_sites"], 3); + // Preview needs the same target files as the report; without them the answer is the plain Pro one. + let r2 = call(&a, "docs", json!({"query": "x", "package": "nope", "upgrade_to": "2.0.0", "root": root})); + assert!( + r2["structuredContent"]["pro_required"].is_object() && text(&r2).contains("Preview unavailable"), + "{r2}" + ); // Tokens that must not unlock: wrong product, wrong plan, expired, signed by another key, junk. let other = SigningKey::from_bytes(&[8; 32]); diff --git a/crates/lockdocs/src/preview.rs b/crates/lockdocs/src/preview.rs new file mode 100644 index 0000000..1e45caa --- /dev/null +++ b/crates/lockdocs/src/preview.rs @@ -0,0 +1,113 @@ +//! The free answer to an upgrade request: what the full report would cover, +//! counted from the same local report, without the list. The licence gate stays +//! in `pro.rs`; this only shapes text, so it makes no network calls of its own. + +use mcp_kit::licence::ProRequired; +use serde_json::{json, Value}; + +pub struct Preview { + pub text: String, + pub json: Value, +} + +/// Build the preview from the report JSON (`upgrade::report`'s `json`). +pub fn build(report: &Value, required: &ProRequired, price: &str) -> Preview { + let affected = report["affected"].as_array().cloned().unwrap_or_default(); + let count = |kind: &str| affected.iter().filter(|a| a["change"] == kind).count(); + let (removed, renamed, resigned, deprecated) = (count("removed"), count("renamed"), count("signature_changed"), count("deprecated")); + let sites: usize = affected.iter().map(|a| a["call_sites"].as_array().map_or(0, Vec::len)).sum(); + let head = format!( + "{} {} -> {}", + report["package"].as_str().unwrap_or(""), + report["from"].as_str().unwrap_or(""), + report["to"].as_str().unwrap_or("") + ); + let pro = format!( + "The full report with each change, its replacement and your call sites is {} {} ({price}/seat/yr): {}", + required.product, required.tier, required.url + ); + let text = if affected.is_empty() { + format!( + "{head}: none of the APIs your project calls change ({} other API changes exist). {pro}", + report["unaffected_changes"] + ) + } else { + let parts: Vec = [(removed, "removed"), (renamed, "renamed"), (resigned, "re-signed"), (deprecated, "deprecated")] + .iter() + .filter(|(n, _)| *n > 0) + .map(|(n, w)| format!("{n} {w}")) + .collect(); + let first = &affected[0]; + let site = first["call_sites"][0]["path"] + .as_str() + .map(|p| format!(" at {p}:{}", first["call_sites"][0]["line"])) + .unwrap_or_default(); + format!( + "{head}: {} of the APIs your project calls change ({}) across {sites} call sites. Sample: {} {}{site}. {pro}", + affected.len(), + parts.join(", "), + first["change"].as_str().unwrap_or("").replace('_', " "), + first["symbol"].as_str().unwrap_or("") + ) + }; + let json = json!({ + "pro_required": {"feature": required.feature, "product": required.product, "tier": required.tier, "url": required.url}, + "package": report["package"], "from": report["from"], "to": report["to"], + "affected": affected.len(), + "by_change": {"removed": removed, "renamed": renamed, "signature_changed": resigned, "deprecated": deprecated}, + "call_sites": sites, + "unaffected_changes": report["unaffected_changes"], + "text": text, + }); + Preview { text, json } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn required() -> ProRequired { + ProRequired { + feature: "Upgrade report".into(), + product: "lockdocs".into(), + tier: "Pro".into(), + url: "https://example.com/pro".into(), + } + } + + #[test] + fn counts_by_change_and_names_the_price_and_link() { + let r = json!({"package": "next", "from": "14.2.3", "to": "15.0.0", "unaffected_changes": 4, "affected": [ + {"change": "removed", "symbol": "next.a", "call_sites": [{"path": "a.ts", "line": 3}, {"path": "b.ts", "line": 9}]}, + {"change": "renamed", "symbol": "next.b", "call_sites": [{"path": "a.ts", "line": 5}]}, + {"change": "signature_changed", "symbol": "next.c", "call_sites": [{"path": "c.ts", "line": 1}]}, + ]}); + let p = build(&r, &required(), "US$120"); + assert_eq!( + p.text, + "next 14.2.3 -> 15.0.0: 3 of the APIs your project calls change (1 removed, 1 renamed, 1 re-signed) across 4 call sites. Sample: removed next.a at a.ts:3. The full report with each change, its replacement and your call sites is lockdocs Pro (US$120/seat/yr): https://example.com/pro" + ); + assert_eq!(p.json["call_sites"], 4); + assert_eq!(p.json["pro_required"]["url"], "https://example.com/pro"); + } + + #[test] + fn nothing_affected_says_so() { + let r = json!({"package": "p", "from": "1.0.0", "to": "2.0.0", "unaffected_changes": 2, "affected": []}); + let p = build(&r, &required(), "US$120"); + assert!( + p.text.contains("none of the APIs your project calls change (2 other API changes exist)"), + "{}", + p.text + ); + } + + #[test] + fn price_matches_the_pricing_page() { + // Skipped when the docs are absent (the packaged crate). + let cfg = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/.vitepress/config.ts"); + if let Ok(c) = std::fs::read_to_string(cfg) { + assert!(c.contains(&format!("price: '{}'", crate::pro::PRICE)), "docs price differs from pro::PRICE"); + } + } +} diff --git a/crates/lockdocs/src/pro.rs b/crates/lockdocs/src/pro.rs index 2d67755..360f81c 100644 --- a/crates/lockdocs/src/pro.rs +++ b/crates/lockdocs/src/pro.rs @@ -13,6 +13,10 @@ pub const KEY_PLACEHOLDER: &str = "PLACEHOLDER-lockdocs-pro-issuer-public-key-no #[allow(dead_code)] pub const CHECKOUT_BASE: &str = "https://buy.sylphx.com"; +/// Yearly price per seat, quoted in the free upgrade preview. The pricing page +/// reads the same value from `docs/.vitepress/config.ts`; a test keeps them equal. +pub const PRICE: &str = "US$120"; + /// The lockdocs Pro licence policy. /// /// TODO(Services S1): replace `KEY_PLACEHOLDER` with the issued lockdocs-pro diff --git a/docs/pro.md b/docs/pro.md index 1d8d4b1..bc31824 100644 --- a/docs/pro.md +++ b/docs/pro.md @@ -19,6 +19,19 @@ sections that apply. lockdocs upgrade next 15.0.0 ``` +Not on Pro yet? The same command still answers, locally and free: it tells you +how many of the APIs your project calls change between the two versions (removed, +renamed, re-signed, deprecated), across how many call sites, and shows one as a +sample. Pro adds the full list: each change, its replacement, your call sites and +the migration sections. + +```text +next 14.2.3 -> 15.0.0: 7 of the APIs your project calls change (3 removed, 2 renamed, 2 deprecated) across 12 call sites. Sample: removed next.foo at app/page.tsx:8. The full report ... is lockdocs Pro (US$120/seat/yr): +``` + +The preview downloads nothing the report would not: the target version comes +from your installed or cached files, or from a fetch you turned on. + Agents ask for the same report through the `docs` tool's `upgrade_to` argument. Reading another version's docs and its migration guide stays free. @@ -43,7 +56,7 @@ offline and stores it for you), or set `LOCKDOCS_LICENCE_TOKEN` in CI. Check it with `lockdocs licence status`, which shows where the token was read and warns "expires in N days" during the last 30 days; the upgrade report adds a renewal line then too. Without a valid licence `lockdocs upgrade` exits 3, and an agent -calling `upgrade_to` gets a normal answer saying the report is part of lockdocs -Pro, with the link. +calling `upgrade_to` gets a normal answer with the preview above and the Pro +link, marked `pro_required` so the agent can tell you. Everything free in lockdocs stays free, under MIT.