+54
-5
@@ -24,3 +24,3 @@ #!/usr/bin/env node | ||
| import { realpathSync } from "node:fs"; | ||
| import { disp, search, find, installSnippets, pretty, slugify } from "./tashan.mjs"; | ||
| import { disp, search, find, installSnippets, pretty, slugify, advisoriesOf, installScriptOf } from "./tashan.mjs"; | ||
| import { tokensOf } from "./doctor.mjs"; | ||
@@ -79,4 +79,13 @@ import { configLocations, skillLocations, collect, resolve, assess, summarize } from "./doctor.mjs"; | ||
| out.push("listed in OSV's malicious-packages database — this package IS the attack. Do not install it. Tell the user plainly and stop."); | ||
| else if (c.sec_advisory_count) | ||
| out.push(`${c.sec_advisory_count} known advisor${c.sec_advisory_count === 1 ? "y" : "ies"} against the current release (${(c.sec_max_severity || "unrated").toLowerCase()}) — see the capability page for which`); | ||
| else if (c.sec_advisory_count) { | ||
| // NAME THEM WHEN WE HAVE THEM. This line used to end "see the capability page for which" on every | ||
| // surface — the paywall speaking, since the detail was stripped from everything the CLI could | ||
| // read and pointing elsewhere was the only honest option left. sec_advisories is public now, so | ||
| // the ids go in the risk line itself; find_capability has no detail block under it, and an agent | ||
| // that can quote an id can act. The pointer stays as the fallback for the slim board record, | ||
| // which still carries the count but not the advisories. | ||
| const ids = advisoriesOf(c).map((a) => a.id).filter(Boolean); | ||
| out.push(`${c.sec_advisory_count} known advisor${c.sec_advisory_count === 1 ? "y" : "ies"} against the current release (${(c.sec_max_severity || "unrated").toLowerCase()})` | ||
| + (ids.length ? ` — ${ids.join(", ")}` : " — see the capability page for which")); | ||
| } | ||
| if (c.sec_install_script) | ||
@@ -299,2 +308,25 @@ out.push("runs a script at install time — arbitrary code executes on npm install, before any tool is called"); | ||
| } | ||
| // THE DETAIL ITSELF, NOT A DESCRIPTION OF WHERE IT IS KEPT. | ||
| // This block used to say "which advisory and the version that fixes it" and "the exact command it | ||
| // runs at install time" were behind a Pro licence, then hand the agent a licensed URL to fetch — | ||
| // while the website printed both to any visitor. So the agent was told to warn and forbidden to | ||
| // act, over facts a browser gives away, and a model told a fix exists but not what it is will | ||
| // either hedge or invent one. The pipeline stopped redacting these (build.py's redact_paid), so | ||
| // they arrive in the public lookup.json, and the agent now gets them the way a reader does. | ||
| const advs = advisoriesOf(c); | ||
| const script = installScriptOf(c); | ||
| if (advs.length) { | ||
| L.push("", "Advisories against the version you would install today:"); | ||
| for (const a of advs) { | ||
| L.push(` ${a.id} ${(a.severity || "unrated").toLowerCase()}${a.summary ? ` — ${a.summary}` : ""}`); | ||
| // The fixing version is the only actionable line here. "No fix published" has to be said out | ||
| // loud rather than omitted, or the agent reads silence as "already fixed, install away". | ||
| L.push(a.fixed ? ` fixed in ${a.fixed} — pin at or above it, or pick another capability` | ||
| : " no fixed version published — treat the current release as affected"); | ||
| } | ||
| } | ||
| if (script) { | ||
| L.push("", `Runs this at install time, before any tool is called: ${script}`); | ||
| L.push("Show the user the command itself if they are deciding whether to install."); | ||
| } | ||
| L.push("", `Details: ${SITE}/capability/${c.slug || slugify(c.id)}`); | ||
@@ -312,4 +344,21 @@ L.push("A tashan score measures adoption and maintenance, not security. We do not read its code."); | ||
| if (name === "check_capability") { | ||
| const all = await rows(); | ||
| return renderCheck(find(all, String(args.name || "")), args.name); | ||
| // THE BOARD IS THE RANKING, NOT THE RECORD, and checking one named capability is a lookup | ||
| // question, not a ranking question. index.json carries 1,592 ranked rows, slimmed for the | ||
| // website's first paint: no sec_advisories, no sec_max_severity, no sec_permissions, and | ||
| // sec_install_script flattened to a bare 1. lookup.json carries all 5,951, in full. | ||
| // | ||
| // Checking the board alone was wrong in both directions. Every row we know an advisory about is | ||
| // OFF the board — low scores never rank, and the two dependency-confusion canaries are dropped | ||
| // from it on purpose — so an agent asking about mcp-server-taskwarrior, which has a command | ||
| // injection advisory, was told "not in the tashan index, unmeasured, not necessarily bad". And | ||
| // for the rows that did match, the slim record had none of the detail to relay. | ||
| // | ||
| // So: match the board (find() does fuzzy name/package matching, which the keyed lookup cannot), | ||
| // overlay the full public record, and fall back to the lookup when the board has never heard of | ||
| // it. Same lookup.json find_capability and audit_config already fetch, same cache, no licence. | ||
| const q = String(args.name || ""); | ||
| const [all, lk] = [await rows(), await lookup().catch(() => null)]; | ||
| const c = find(all, q); | ||
| const rec = lk ? resolve({ id: c ? c.id : q }, lk) : null; | ||
| return renderCheck(c || rec ? { ...(c || {}), ...(rec || {}) } : null, q); | ||
| } | ||
@@ -316,0 +365,0 @@ if (name === "audit_config") { |
+1
-1
| { | ||
| "name": "tashan-cli", | ||
| "version": "0.1.1", | ||
| "version": "0.1.2", | ||
| "description": "The measured layer for AI capabilities — search, rank, and install MCP servers & agent skills from your terminal.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+167
-40
@@ -9,3 +9,5 @@ #!/usr/bin/env node | ||
| // npx tashan-cli doctor audit the config you actually have — dead, deprecated, risky | ||
| // npx tashan-cli activate <key> store your Pro licence on this machine (once, not per shell) | ||
| // npx tashan-cli login sign in — prints a code, opens the browser, approve, done | ||
| // npx tashan-cli logout release this machine's seat | ||
| // npx tashan-cli activate <key> non-interactive sign-in for CI, where there is no browser | ||
| // npx tashan-cli account open your account in the browser, already signed in | ||
@@ -101,2 +103,94 @@ // npx tashan-cli mcp run as an MCP server, so your AGENT can ask before installing | ||
| // ---- device-authorisation login -------------------------------------------------------------- | ||
| // `tashan activate <key>` asked every customer to go to their email, find a licence key, and paste | ||
| // it into a terminal — once per machine, forever. That is the worst moment in the product and it | ||
| // lands immediately after someone pays. This is the flow they already know from `gh auth login`, | ||
| // `wrangler login` and `stripe login`: ask for a code, approve it in a browser, the terminal picks | ||
| // up the credential. The key is typed at most once in a lifetime, in a browser, where paste is one | ||
| // keystroke. `activate` stays for scripts and CI, where a browser is not a thing. | ||
| export async function deviceLogin({ open = true, fetchImpl = fetch, sleep = (ms) => new Promise(r => setTimeout(r, ms)) } = {}) { | ||
| const post = async (body) => { | ||
| try { | ||
| const r = await fetchImpl(SITE + "/api/device", { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify(body || {}), | ||
| }); | ||
| return { status: r.status, json: await r.json().catch(() => null) }; | ||
| } catch { | ||
| return { status: 0, json: null }; | ||
| } | ||
| }; | ||
| const started = await post({}); | ||
| if (started.status !== 200 || !started.json || !started.json.device_code) { | ||
| process.stderr.write("\n " + red("Could not start sign-in.") + | ||
| dim("\n " + (started.status === 503 ? "Sign-in is temporarily unavailable." : "Check your connection.") + | ||
| "\n You can still use `tashan activate <key>` with the key from your purchase email.\n")); | ||
| return 1; | ||
| } | ||
| const { device_code, user_code, verify_url_complete, verify_url, interval, expires_in } = started.json; | ||
| const url = verify_url_complete || verify_url; | ||
| process.stdout.write("\n " + bold("Your code: ") + jade(user_code) + "\n" + | ||
| dim(" Approve it at " + url) + "\n"); | ||
| if (open && openBrowser(url)) process.stdout.write(dim(" (opened in your browser)") + "\n"); | ||
| process.stdout.write(dim("\n waiting…") + "\n"); | ||
| const deadline = Date.now() + (Number(expires_in) || 600) * 1000; | ||
| let wait = Math.max(2, Number(interval) || 3) * 1000; | ||
| while (Date.now() < deadline) { | ||
| await sleep(wait); | ||
| const r = await post({ device_code }); | ||
| const st = r.json && r.json.status; | ||
| if (st === "slow_down") { wait += 1000; continue; } // RFC 8628 §3.5 — back off, do not give up | ||
| if (st === "pending" || r.status === 0) continue; // offline blips are not a denial | ||
| if (st === "denied") { | ||
| process.stderr.write("\n " + red("Denied in the browser.") + dim("\n Nothing was stored.\n")); | ||
| return 1; | ||
| } | ||
| if (st === "ok" && r.json.key) return storeKey(r.json.key); | ||
| break; | ||
| } | ||
| process.stderr.write("\n " + red("That code expired.") + dim("\n Run `tashan login` again.\n")); | ||
| return 1; | ||
| } | ||
| // Register with Polar, then write the file — the same order `activate` uses, and for the same | ||
| // reason: Polar owns the device limit, so a key stored locally that Polar never activated looks | ||
| // fine here and fails everywhere else. | ||
| async function storeKey(key) { | ||
| const label = `${hostname()} \u00b7 ${platform()}`; | ||
| const { status, json } = await polar("/activate", { key, label }); | ||
| if (status === 0) { | ||
| process.stderr.write("\n " + red("Signed in, but could not reach Polar to register this machine.") + | ||
| dim("\n Nothing was saved. Try again when you are online.\n")); | ||
| return 1; | ||
| } | ||
| if (status === 403) { | ||
| process.stderr.write("\n " + red("Every device slot on this licence is in use.") + | ||
| dim("\n Release one at " + PORTAL + ", or run `tashan logout` on a machine you no longer use.\n")); | ||
| return 1; | ||
| } | ||
| if (status !== 200 || !json || !json.id) { | ||
| process.stderr.write("\n " + red("The licence was rejected when registering this machine.") + | ||
| dim("\n Check your plan at " + PORTAL + "\n")); | ||
| return 1; | ||
| } | ||
| try { | ||
| mkdirSync(dirname(keyPath()), { recursive: true }); | ||
| writeFileSync(keyPath(), JSON.stringify({ key, activation_id: json.id, label }, null, 2) + "\n", { mode: 0o600 }); | ||
| chmodSync(keyPath(), 0o600); | ||
| } catch (e) { | ||
| await polar("/deactivate", { key, activation_id: json.id }); | ||
| process.stderr.write("\n " + red("Could not write " + keyPath()) + dim("\n " + e.message + "\n")); | ||
| return 1; | ||
| } | ||
| process.stdout.write("\n " + jade("Pro is active on this machine.") + | ||
| dim(`\n registered as "${label}" \u2014 tashan account shows every device`) + | ||
| dim(`\n stored in ${keyPath()} \u00b7 every shell, every project, no re-export`) + | ||
| dim("\n\n tashan doctor") + "\n"); | ||
| return 0; | ||
| } | ||
| const SITE = process.env.TASHAN_SITE || "https://tashan.sh"; | ||
@@ -122,2 +216,27 @@ const DATA_URL = SITE + "/data/index.json"; | ||
| // ---- the security audit's DETAIL, read off the public row --------------------------------------- | ||
| // Both of these used to arrive from /api/security behind a licence header. build.py's redact_paid() | ||
| // no longer strips sec_advisories and no longer flattens sec_install_script to a boolean, so the | ||
| // detail rides in the public lookup.json the CLI already downloads. Free on the website, free here. | ||
| // Exported (and shared with mcp.mjs) so the terminal, the agent and the page cannot drift apart. | ||
| /** The advisories on a row: [{ id, severity, summary, fixed }]. Never throws — a malformed field | ||
| * must not take down an audit someone is running precisely because they are already worried. */ | ||
| export function advisoriesOf(row) { | ||
| try { | ||
| const list = JSON.parse((row && row.sec_advisories) || "[]"); | ||
| return Array.isArray(list) ? list : []; | ||
| } catch { return []; } | ||
| } | ||
| /** The literal command a package runs at install time, or null. | ||
| * THE BOARD DOES NOT CARRY IT. index.json slims sec_install_script to the number 1 (build.py's | ||
| * _slim) because the board only needs the FACT and the command can be 300 characters; only | ||
| * lookup.json has the string. A truthiness test would print "install runs: 1" on every board-sourced | ||
| * row — worse than silence — so this checks the type, not the truth. */ | ||
| export function installScriptOf(row) { | ||
| const s = row && row.sec_install_script; | ||
| return typeof s === "string" && s.trim() && s !== "1" ? s : null; | ||
| } | ||
| export function search(rows, q) { | ||
@@ -284,13 +403,11 @@ q = (q || "").toLowerCase().trim(); | ||
| // The audit's paid half, on the surface that ADVERTISES it. doctor's free output says | ||
| // "N findings have detail behind a licence — which advisory and the version that fixes it, | ||
| // what the install script runs"; for a long time nothing printed any of that, on any surface. | ||
| // Fetching it here is what makes that sentence true in the place a customer reads it. | ||
| if (r.row.sec_advisory_count || r.row.sec_install_script) { | ||
| try { | ||
| const sr = await fetch(`${base}/api/security?id=${encodeURIComponent(r.row.id)}`, | ||
| { headers: auth }); | ||
| if (sr.ok) r.secDetail = await sr.json(); | ||
| } catch { /* offline: the free finding is already printed and still correct */ } | ||
| } | ||
| // THE SECURITY AUDIT USED TO BE FETCHED HERE, from /api/security with the licence header, and it | ||
| // is gone on purpose. build.py's redact_paid() stopped stripping sec_advisories and stopped | ||
| // reducing sec_install_script to a boolean, so the advisory id, its severity, the version that | ||
| // fixes it and the literal install command now ship in the PUBLIC lookup.json — the same file | ||
| // doctor already downloads, and the same facts the website prints to anyone with a browser. | ||
| // Keeping the fetch meant the identical fact was free on the web and $6/mo in the terminal: one | ||
| // product contradicting itself depending on which surface you happened to be standing on. | ||
| // renderDoctor now reads the detail straight off `row`, with no key, no request and no branch. | ||
| // What stays behind this function is TIME — /api/history above — never the current state. | ||
| } | ||
@@ -352,4 +469,6 @@ return results; | ||
| ${jade("tashan doctor")} audit the config you already have — dead, deprecated, risky | ||
| ${jade("tashan activate")} <key> register this machine — once, not once per shell | ||
| ${jade("tashan login")} sign in — approve a code in the browser, once per machine | ||
| ${jade("tashan logout")} release this machine's seat | ||
| ${jade("tashan account")} open your account in the browser, already signed in | ||
| ${dim("tashan activate <key>")} ${dim("non-interactive, for CI — needs the key")} | ||
| ${dim("Pro names the replacement for anything dead in your config · $6/mo · " + SITE + "/pricing")} | ||
@@ -398,3 +517,3 @@ | ||
| if (!rows.length) out += " " + jade("+") + " " + dim("nothing deprecated, archived or abandoned.") + "\n"; | ||
| for (const { item, row, assessment, alts, secDetail } of rows) { | ||
| for (const { item, row, assessment, alts } of rows) { | ||
| const t = row && row.tashan_score != null ? String(Math.round(row.tashan_score)) : "—"; | ||
@@ -407,18 +526,16 @@ out += " " + (MARK[assessment.level] || " ") + " " + bold(pretty(item.name).padEnd(28).slice(0, 28)) + | ||
| } | ||
| // The paid half, directly under the finding it explains. doctor's free output promises exactly | ||
| // this — "which advisory and the version that fixes it, what the install script runs" — and for | ||
| // a long time no surface printed any of it, which made the upsell a claim about nothing. | ||
| if (secDetail) { | ||
| for (const a of secDetail.advisories || []) { | ||
| out += " " + jade("→ ") + bold(a.id) + | ||
| dim(` ${(a.severity || "").toLowerCase()}`) + | ||
| (a.fixed ? dim(" · fixed in ") + jade(a.fixed) : dim(" · no fix published")) + "\n"; | ||
| } | ||
| if (secDetail.install_script) { | ||
| out += " " + jade("→ ") + dim("install runs: ") + secDetail.install_script + "\n"; | ||
| } | ||
| if ((secDetail.permissions || []).length > 1) { | ||
| out += " " + jade("→ ") + dim("can reach: ") + secDetail.permissions.join(", ") + "\n"; | ||
| } | ||
| // WHICH advisory, and what the install script actually runs — directly under the finding that | ||
| // names it, for everyone, with no key and no request. This used to come from /api/security with | ||
| // a licence header while the website printed the identical facts to any visitor: the same | ||
| // product answering the same question two ways depending on the surface. Now it is read straight | ||
| // off `row`, which for doctor is the public lookup record. | ||
| // sec_permissions is deliberately NOT repeated here — assess() already emits "can reach: …" from | ||
| // the same row, free, and printing it twice would read as two separate findings. | ||
| for (const adv of advisoriesOf(row)) { | ||
| out += " " + jade("→ ") + bold(adv.id) + | ||
| dim(` ${(adv.severity || "").toLowerCase()}`) + | ||
| (adv.fixed ? dim(" · fixed in ") + jade(adv.fixed) : dim(" · no fix published")) + "\n"; | ||
| } | ||
| const script = installScriptOf(row); | ||
| if (script) out += " " + jade("→ ") + dim("install runs: ") + script + "\n"; | ||
| if (alts && alts.length) { | ||
@@ -448,9 +565,13 @@ if (pro) { | ||
| if (!pro && keyState === null) { | ||
| const withDetail = rows.filter((r) => r.row && | ||
| (r.row.sec_advisory_count || r.row.sec_install_script || (r.alts && r.alts.length))).length; | ||
| if (withDetail) { | ||
| out += "\n " + dim(`${withDetail} finding${withDetail === 1 ? " has" : "s have"} detail behind a licence — `) + | ||
| dim("which advisory and the version that fixes it, what the install script runs, ") + | ||
| dim("and the replacement to move to.") + "\n" + | ||
| " " + jade("tashan Pro") + dim(" $6/mo · " + SITE + "/pricing") + "\n"; | ||
| // THE OFFER NAMES ONLY WHAT IS ACTUALLY WITHHELD. It used to count advisories and install scripts | ||
| // as well, and promise "which advisory and the version that fixes it" behind the licence — copy | ||
| // that is now false twice over: that detail is printed above, free, and pitching a reader | ||
| // something already on their screen is the fastest way to lose them. A licence buys the | ||
| // replacement to move to, and the history that says whether a thing is dying. Nothing else. | ||
| const replaceable = rows.filter((r) => r.row && r.alts && r.alts.length).length; | ||
| if (replaceable) { | ||
| out += "\n " + dim(`${replaceable} of these ${replaceable === 1 ? "has" : "have"} a measured replacement behind a licence — `) + | ||
| dim("the one to move to, and whether anything else here is on the way down.") + "\n" + | ||
| " " + jade("tashan Pro") + dim(" $6/mo · " + SITE + "/pricing") + | ||
| dim(" · already bought? ") + jade("tashan login") + "\n"; | ||
| } | ||
@@ -518,3 +639,9 @@ } | ||
| } | ||
| if (cmd === "activate") { | ||
| if (cmd === "login") return deviceLogin({ open: !a["no-browser"] }); | ||
| // `logout` is what everyone types. It was only ever spelled `activate --forget`, which is a flag | ||
| // on a different verb — nobody guesses it, so nobody released a seat and device slots leaked. | ||
| if (cmd === "logout") a.forget = true; | ||
| if (cmd === "activate" || cmd === "logout") { | ||
| // --forget must RELEASE the seat at Polar, not just delete our file. Deleting locally while the | ||
@@ -548,4 +675,4 @@ // activation stays registered burns a device slot the customer cannot get back without support. | ||
| : bold("This machine is not activated.") + | ||
| dim("\n tashan activate <key>") + | ||
| dim("\n Your key is in your purchase email · tashan account opens your plan and invoices")) + "\n"); | ||
| dim("\n tashan login") + dim(" approve it in the browser — no key to copy") + | ||
| dim("\n tashan activate <key> if you are in CI and have no browser")) + "\n"); | ||
| return lic ? 0 : 1; | ||
@@ -599,3 +726,3 @@ } | ||
| process.stdout.write("\n " + bold("No licence on this machine.") + | ||
| dim("\n tashan activate <key> the key is in your purchase email") + | ||
| dim("\n tashan login sign in — no key to copy") + | ||
| dim("\n " + SITE + "/pricing what Pro adds · the index stays free") + "\n"); | ||
@@ -602,0 +729,0 @@ return 1; |
93126
13.58%1516
12.3%