Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- **Free upgrade preview.** Without a Pro licence, `lockdocs upgrade <pkg> <ver>` (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
Expand Down
26 changes: 25 additions & 1 deletion crates/lockdocs/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod mcp;
mod preview;
mod pro;
mod setup;
mod tools;
Expand Down Expand Up @@ -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);
}
}
Expand Down
48 changes: 46 additions & 2 deletions crates/lockdocs/src/mcp.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down
113 changes: 113 additions & 0 deletions crates/lockdocs/src/preview.rs
Original file line number Diff line number Diff line change
@@ -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<String> = [(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");
}
}
}
4 changes: 4 additions & 0 deletions crates/lockdocs/src/pro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions docs/pro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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): <this page>
```

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.

Expand All @@ -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.
Loading