npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

basalt-cli

v0.14.0

Published

Publish markdown and get a shareable URL — turn a local .md into a branded, permissioned, versioned page on Basalt. Agent- and CI-friendly (non-interactive API key), with workspace/member/publisher-key management and pull / publish / list / status / whoam

Readme

basalt — publish markdown, get a shareable URL

Turn a local .md into a branded, permissioned, versioned page served at a URL on Basalt. Two commands, no build step, no local render — the server compiles.

npx basalt-cli@latest login              # sign in once, in the browser
npx basalt-cli@latest publish ./my-doc.md
# → new            my-vault/my-doc  https://app.basalt.cofoundy.ai/acme/my-vault/my-doc

That URL is live and versioned the moment the command returns. It is visible to your workspace, not the world — Basalt is permissioned by default. To hand it to someone outside:

npx basalt-cli@latest set-public my-vault/my-doc   # prints the same URL, now anon-readable

Re-run publish after editing and it republishes as a new version; unchanged files are a no-op, so it is safe in a loop or in CI.


For AI agents

Use this CLI when the content lives in files — a git-versioned vault, a repo, CI. Git stays the source of truth and publish syncs it diff-aware. Use the Basalt MCP instead when the content was born in the conversation and wants the review loop (comments, suggestions, agent-as-user permissions). Publishing the same doc twice from files? That's a mirror → CLI.

Non-interactive auth (CI, containers, headless agents)

Set one env var. BASALT_API_KEY always wins over any stored session, so it is the only thing a headless caller needs:

export BASALT_API_KEY=bslt_...           # a publisher key
npx basalt-cli@latest publish ./doc.md   # no browser, no TTY

Mint that key once, from a machine that is signed in:

basalt login
basalt keys create ci --scope my-vault/   # secret printed ONCE — copy it now

You do not mint a key for ordinary interactive use: after basalt login, your first publish provisions this device's key by itself and stores it. basalt logout revokes it.

basalt login from an agent harness

login adapts to its caller. With a terminal it blocks until you approve in the browser. Without one (an agent harness, CI) blocking would deadlock — the harness could never surface the URL — so it does not block:

basalt login            # no TTY → prints URL + code, exits 0
# show the URL to the human, then:
basalt login --wait     # run as a BACKGROUND job; exits the moment they approve

--wait resumes the in-flight request (~/.basalt/pending-login.json, 0600) — it never mints a second code, so the code the human is reading stays the code the CLI polls for. Its exit is the approval notification: no cron, no polling loop. Where background jobs aren't available, basalt login --check polls once (exit 0 signed in, 3 still pending). Use one or the other — a concurrent --wait and --check race for the server's one-shot token mint.

What the commands return

Add --json to any command for machine-readable output.

| Command | Returns | |---|---| | basalt onboard | Where you are on the setup ladder + the exact next command (alias: doctor) | | basalt publish <files…> | One line per file: <status> <project>/<slug> <absolute URL> plus [assets: …] / [links: …] tags. Status is new, modified, metadata-only, unchanged, skipped or error. | | basalt status <files…> | The same classification with no write — a dry classification of what publish would do | | basalt list [--project <p>] | Every doc the key can see, with its content sha | | basalt pull <project> <slug> | The canonical source MDX (--out - = stdout) | | basalt whoami | <key name> · key <id> · tenant <slug> · scope: <scope> | | basalt set-public <p>/<slug> | The canonical URL + whether it resolved public |

Exit codes: 0 ok · 1 error · 2 usage · 3 login still pending · 22 brand violation. --dry-run and --strict make publish a real pre-merge gate (see Link health and Asset staging).


Install

Zero-install via npx (above), or globally:

npm i -g basalt-cli     # bin: `basalt`

Env: BASALT_API_KEY (publisher key) · BASALT_SESSION_TOKEN / BASALT_TENANT (session) · BASALT_BASE_URL (point at a non-prod host). Stored credentials in ~/.basalt/credentials are namespaced by host, so signing into staging never overwrites your prod session (#422).

Commands

| Command | What it does | |---------|--------------| | basalt onboard [--json] [--print-workflow] | State-derived ladder + next step (alias: doctor). | | basalt login [--open] [--wait\|--no-wait\|--check] | Sign in via browser (device flow). | | basalt logout | Revoke this device's session and the key it provisioned. | | basalt sessions [revoke <jti>] | List / kill your CLI sessions (e.g. a lost laptop). | | basalt whoami | Identify the current key (name, tenant, scope, expiry). | | basalt list [--project <p>] | List docs the key can see, with content sha. | | basalt status <files…> [--project <p>] | Show what publish would do — no write. | | basalt pull <project> <slug> [--out <file>] | Fetch canonical source MDX (- = stdout). | | basalt publish <files…> [--project <p>] [--tenant <ws>] [--force] [--dry-run] [--strict] [--prune [--yes]] | Publish (server-compiled). --prune makes the checkout authoritative — see below. |

Publisher keys (require basalt login)

A publisher key is the service identity that publish/list/status/pull authenticate with. These commands need a login session instead — you cannot bootstrap a key with a key.

| Command | What it does | |---------|--------------| | basalt keys list [--tenant <slug>] | Every key in the workspace: status, id, name, scope, last used. Revoked keys are retained (audit trail). Owner/admin. | | basalt keys create <name> [--scope <prefix>] [--expires <iso>] | Mint a key; secret printed once. Any member — the key is bound to you, so it writes exactly what your grants allow (#419/#424). | | basalt keys rotate <key_id> | Mints a new secret and leaves the old valid for a zero-downtime cutover. Finish with revoke. Owner/admin. | | basalt keys revoke <key_id> | Instant 401 for that key. Row retained, flagged revoked. |

--scope is a prefix of the doc path {project}/{slug} — project space, not a workspace namespace (the #388 confusion). See basalt keys --help.

Workspaces & members (require basalt login)

These speak the session-gated /api/tenant/* routes with the cf_docs_session JWT that basalt login stores.

| Command | What it does | |---------|--------------| | basalt workspace list | List your workspaces (* = active). | | basalt workspace create <name> [--slug <s>] | Create a workspace (you become owner). | | basalt workspace switch <slug> | Set the active workspace. | | basalt workspace edit [--name <n>] [--avatar <f>] [--slug <target>] | Rename / re-avatar / re-slug. | | basalt workspace delete <slug> --yes | Delete a workspace (owner only). | | basalt workspace members [--tenant <slug>] | List a workspace's members. | | basalt workspace invite <email> [--role owner\|admin\|member] [--tenant <slug>] | Invite a member (owner/admin only; --role defaults to member). |

--tenant targets any workspace you belong to, so you can administer one workspace while another is active. Re-inviting the same email at the same role is idempotent.

--tenant is verified before any write. Server-side, cf_active_tenant is a preference hint: for a caller who is not a member of the named workspace it is silently ignored and the request falls back to their personal/host workspace. So the CLI resolves --tenant against your live membership first and fails loudly (not a member of workspace "…") rather than let an invite land in the wrong workspace.

Sharing (requires basalt login + write access on the doc)

| Command | What it does | |---------|--------------| | basalt set-public <project>/<slug> [--off] [--tenant <slug>] | Grant (or, with --off, revoke) anon-read on a doc and print its canonical URL. |

Public is a grant, never a frontmatter role — this speaks the same POST /api/tenant/grants/public route the ShareDialog UI uses. The printed isPublic reflects the RESOLVED state post-request (an ancestor public grant on the vault can keep a doc public even after --off removes its own grant). A missing doc, or one you can't see, reports as not-found with a next step (basalt list --project <p>); a doc you can see but can't write reports that you need write access — never a raw HTTP error.


Addressing — frontmatter v2 (metadata only, #289)

Frontmatter is metadata, never infrastructure. The CLI derives the address from the filesystem:

  • slug = the file path, relative to the enclosing vault.yaml, slashes preserved and each segment normalized (decisions/Channel Stack.md → decisions/channel-stack). No vault.yaml above it → the file is a depth-0 loose doc and the slug is its basename.
  • project = the space binding: --project wins, else vault.yaml:name, else the vault-root folder name.

Allowed frontmatter keys: title (optional — defaults to the first body H1), tags, author, created, chrome. The infra keys slug: / project: / role: / version: are IGNORED — publishing one emits an infra_key_ignored warning and it is dropped. pull strips them so a pull → edit → publish round-trip never re-injects them. Migrate an existing vault with scripts/strip-infra-frontmatter.ts <dir> (in the basalt repo; dry-run by default, --apply to write) — a textual strip that never touches the body, so a republish is sha_match (basalt status stays unchanged).

How publish decides what to do

Content-addressed skip. publish fetches the doc list once, then per file compares the local content sha (sha256 of the CRLF-normalized body — the exact digest the server stores) against the server's current_sha. An unchanged doc is a no-op; a frontmatter-only change publishes (cheap metadata upsert); --force always publishes.

Asset staging

Author a local image path — ![diagram](./assets/x.png) — and commit the image bytes next to the doc. On publish, each LOCAL image ref is content-hashed and uploaded to a shared, per-project namespace files/{project}/_assets/{sha16}-{name}, and the source POSTed to the server is rewritten to the resolved /files/… URL. The git .md is never mutated — its local path + committed bytes stay the source of truth; only what the server stores carries the resolved URLs.

  • Content-addressed → the same image referenced by many docs uploads once, and moving a doc to a new slug never breaks its images.
  • Bytes are uploaded verbatim (no transcoding); the sha16 prefix is a 64-bit content hash, collision-safe well past any vault's image count.
  • Re-publishing re-uses assets already in R2 (a HEAD probe skips the upload).
  • --dry-run validates that every local image ref resolves on disk and is a supported, in-size image — a missing/oversize/unsupported ref fails the run (exit 1), making it a real pre-merge check. Nothing is uploaded on --dry-run.

Supported: png · jpg/jpeg · gif · webp · svg, ≤ 10 MB each.

Link health

Each published doc reports its wikilink health next to its result: [links: 3 ok, 1 via-alias, 2 broken: no-existe, foo] (printed only for docs that carry internal links). --strict turns broken links into a failing run (exit 1) — a doc with an unresolved [[target]] fails CI, so a broken link is caught by the agent that published it, not by a human clicking a 404. Pair it with --force to re-check unchanged docs, or --dry-run --strict for a write-free pre-merge gate. The resolution set is the project's live slugs (the server feeds it — the CLI never hand-walks the graph).

--prune — make the checkout the source of truth

basalt publish <files…> --project <p> --prune compares what is LIVE in that space against the files in this run and reports every doc with no file behind it. It is a dry run by default: it lists, exits 0, and touches nothing. Adding --yes deletes them (a tombstone — version history and content are retained and restorable, exactly like basalt delete).

Publishing too much is noise; deleting too much is loss. Everything below follows from that asymmetry, and none of it is configurable:

  • --prune never implies --yes, and --dry-run wins over --yes.
  • An explicit --project is required. Prune is bounded to one space, so it can never cross a project border — without it, the doc list is the whole workspace and every doc in every other space would look like an orphan.
  • An argument that matched no file refuses the whole run — nothing published, nothing deleted. An unmatched glob (which bash passes through literally) is almost always a bad invocation, not an instruction to empty a space.
  • A doc that a retired URL redirects to is kept, not deleted, and reported as retained — deleting it would turn every surviving link to the old address into a 404. If the server has no alias surface (an older deployment), the run says alias_check: unavailable and warns; it never reads that as "there are none".
  • A failed publish in the same run cancels the prune.

--json emits {published: […], prune: {…}} — pruned and retained each carry a reason, and summary.matched counts the live docs that DID have a file, so "deleted 0" can be told apart from "never looked".

basalt publish docs/*.md --project handbook --prune          # what would go
basalt publish docs/*.md --project handbook --prune --yes    # make it so

Design — thin over HTTP

The server compiles. This binary never compiles MDX and never enumerates a vault tree — it reads a file (and, for the space binding, the enclosing vault.yaml), computes content shas, and speaks the publisher API. (The old local-compile CLI drifted into a second render world and was deleted; a lint-invariant, cli-no-local-mdx, keeps this one honest.)

Build from source

Standalone package (the repo is not a pnpm workspace):

cd cli
pnpm install
pnpm build           # → dist/, bin `basalt`
pnpm test            # vitest
pnpm test --coverage # ≥85% gate

The content-sha vectors in test/fixtures/sha-vectors.json are cross-asserted server-side (tests/sha-parity.test.ts) so CLI and server can never drift.

MIT · Cofoundy · issues