@kelviq/cli
v0.1.3
Published
Pricing-as-code CLI for Kelviq — pull, push, and promote your catalog as typed TypeScript
Maintainers
Readme
Kelviq CLI
Pricing-as-code for Kelviq — a CLI that lets you define products, features, and plans as typed TypeScript instead of clicking through a dashboard, and sync that definition against a real Kelviq environment.
Author or pull a typed kelviq.config.ts, inspect what environment you're
pointed at, then preview and apply changes with kelviq push — a dry-run
diff, confirmation, ordered writes, and optional --publish/--prune —
against a live environment. kelviq promote runs that same reconciliation
sandbox → production in one command.
Install
npm install -g @kelviq/cli
kelviq --helpCommands
Everything else (catalog browse commands, browser-handoff login) is not built yet — see "What's not here yet" below.
| Command | What it does |
|---|---|
| kelviq init | Writes a starter kelviq.config.ts to the current directory. |
| kelviq login | Pastes a Kelviq Server API key from the dashboard and stores it locally. |
| kelviq logout | Removes a stored API key. |
| kelviq env | Shows which environment and keys are configured — no live validation. --json for scripting. |
| kelviq pull | Fetches the remote catalog and writes it to kelviq.config.ts. |
| kelviq push | Diffs kelviq.config.ts against an environment, confirms, and applies drafts-only writes. --dry-run --json for scripting. |
| kelviq promote | Diffs the sandbox catalog against production and applies the same way — no local config file involved. |
kelviq init # write kelviq.config.ts in the current directory
kelviq login # paste a sandbox key (default env)
kelviq login --prod # paste a production key
kelviq logout --all # remove both stored keys
kelviq env # show configured environment + key status
kelviq pull # pull the sandbox catalog into kelviq.config.ts
kelviq pull --prod --force # pull production, overwriting an existing file
kelviq push --dry-run # preview what push would change, no writes
kelviq push --yes # push to sandbox non-interactively
kelviq promote --dry-run # preview sandbox -> productionRun kelviq push --help / kelviq promote --help for the full flag set:
--publish, --migrate-features, --migrate-pricing, --prune,
--allow-prod-prune, --verbose, --json.
kelviq login prints the dashboard's API-keys URL, deep-linked to the
environment mode matching the target (sandbox by default, production with
--prod), and tries to open it in your default browser, then prompts for
the key on stdin (an interactive terminal masks each character as * while
you type or paste, then reports how many characters it received). Kelviq has no
key-introspection endpoint, so neither login nor env can confirm a key
is actually valid or show which org it belongs to — env reports only what's
configured locally.
kelviq.config.ts
kelviq init writes this starter file:
// kelviq.config.ts — pricing-as-code for Kelviq.
// Defines products, features, and plans as typed, versioned TypeScript.
// Sync this file with your Kelviq account using `kelviq pull` / `kelviq push`.
import { product, feature, plan } from '@kelviq/cli/config';
export const app = product({
identifier: 'my-app',
name: 'My App',
taxCode: 'saas',
});
export const seats = feature({
identifier: 'seats',
name: 'Seats',
type: 'CUSTOMIZABLE',
});
export const pro = plan({
identifier: 'pro',
name: 'Pro',
product: 'my-app',
entitlements: [{ feature: 'seats', value: 5 }],
prices: [
{
priceType: 'PAID',
currency: 'USD',
// Full charge shape: see Kelviq docs → api-reference/plans/update-plan-prices
},
],
});Every cross-reference in the format (plan.product, entitlement.feature)
is a stable identifier slug, never a UUID — the API sometimes wants UUIDs
(plan creation takes a product UUID; entitlement attachment takes feature
UUIDs), but that resolution is sync's job, not something you write by hand.
The plan lifecycle (draft → publish) is deliberately absent from this
format: it describes desired state, not deployment state.
Fields like taxCode, type, priceType, taxBehavior, and reset are
open enums: known values (like the ones above) get editor autocomplete, but
the API may accept additional values — some require enabling from the
Kelviq dashboard first. The CLI passes any value it doesn't recognize
through unchanged; the server validates it on push.
kelviq pull generates this same shape from a live environment's catalog
instead of you writing it by hand — the two are meant to be interchangeable
starting points.
Environments
Every Kelviq org has a sandbox environment; the CLI targets it by default.
Pass --prod on login/pull/push to target production instead
(promote always targets production — no flag needed). Keys are stored
per-environment
(~/.config/kelviq/config.json, mode 0600, created via kelviq login) or
supplied via KELVIQ_SANDBOX_SERVER_API_KEY / KELVIQ_SERVER_API_KEY
(env vars win over the stored file). There is deliberately no environment
where an unqualified command touches production by accident.
Sandbox and production also talk to different API hosts:
| Environment | Default host | Override env var |
|---|---|---|
| Sandbox | https://sandboxapi.kelviq.com/api/v1 | KELVIQ_SANDBOX_BASE_URL |
| Production | https://api.kelviq.com/api/v1 | KELVIQ_BASE_URL |
The two overrides are independent — setting one doesn't affect the other.
kelviq env prints both resolved URLs.
Scripting: --json output
kelviq env --json and kelviq push --dry-run --json / kelviq promote
--dry-run --json each emit a single JSON document on stdout and nothing
else — safe to pipe into jq or parse in CI. This is a public, versioned
contract: the field names below are exact, and changing them is a breaking
change.
kelviq env --json
{
"environment": "sandbox",
"sandboxUrl": "https://sandboxapi.kelviq.com/api/v1",
"prodUrl": "https://api.kelviq.com/api/v1",
"sandboxKey": "set",
"prodKey": "missing"
}sandboxKey/prodKey are always the literal string "set" or "missing"
— never any part of the actual key value. environment is always the
literal "sandbox" — env has no --prod flag of its own; it always
reports both keys' status together.
kelviq push --dry-run --json / kelviq promote --dry-run --json
--json is valid only together with --dry-run — it never mixes with
the interactive confirmation prompts or an actual write. Passing it without
--dry-run exits 1 with a one-line explanation before any fetch happens.
Colors and the fetch spinner (see below) are forced off in --json mode
regardless of FORCE_COLOR/TTY, and stderr carries nothing but a fatal
error.
{
"environment": "sandbox",
"direction": "push",
"changes": true,
"summary": {
"creates": 1,
"updates": 0,
"priceReplaces": 0,
"entitlementChanges": 0,
"archives": 0,
"skipped": 0,
"remoteOnly": 0,
"warnings": 0
},
"operations": {
"products": { "create": [], "patch": [] },
"features": { "create": [] },
"plans": { "create": [], "patch": [] },
"entitlements": { "add": [], "update": [], "delete": [] },
"prices": { "bulkReplace": [] }
},
"prune": null,
"publish": null,
"featureDrift": [],
"errors": [],
"warnings": []
}environment:"sandbox"or"production"— the write target.promotealways reports"production"(its only possible target);directionis what distinguishes the two commands ("push"or"promote").changes: whether a REAL run of this same invocation (i.e. without--dry-run) would write or archive anything. The exit code is always0on a successful render, whether or notchangesis true — check this field in a script, not the process exit code, e.g.test "$(kelviq push --dry-run --json | jq -r .changes)" = "false".operations: the exact op groupspush/promotecompute internally (products/features/plans/entitlements/prices), serialized as-is — a stable, documented contract, never reshaped for display. A same-run pending reference (e.g. a plan referencing a product this same run also creates, before it has a real UUID) serializes as its placeholder object verbatim, e.g.{ "pendingProductIdentifier": "my-app" }or{ "pendingFeatureIdentifier": "seats" }— never resolved or dropped.prune:nullunless run with--prune; otherwise the full computed archive/skip plan:{ "archive": { "plans": [...], "features": [...] }, "skipped": { "plans": [...], "features": [...] } }.summary.skipped(the count of remote-only resources that matched but carry nomanaged-by: kelviq-climarker) is always populated regardless of--prune;pruneitself andsummary.archivesare not.publish:nullunless run with--publish --dry-run; otherwise{ "plans": [...identifiers], "updateFeatures": boolean, "updatePricing": boolean }— the plans that WOULD be published this run.featureDrift/errors: the same hard-abort data the text renderer shows before refusing to proceed — empty arrays on a normal run.warnings: the raw, unaggregated list (every dropped-price-field warning individually) — never collapsed the way the text renderer's "Warnings (N):" section groups them per plan by default.--verboseis a text-mode-only concept; JSON output always gives you the raw list.
On a load/fetch/feature-drift error, the command exits non-zero and emits a smaller, separate document instead of the shape above:
{ "error": "push failed: Missing KELVIQ_SANDBOX_SERVER_API_KEY. ...", "featureDrift": [] }featureDrift is populated only for an actual feature-drift abort (the same
condition that aborts a normal run before any write); every other error (a
missing key, a network failure, an unresolved config reference) reports
featureDrift: [].
Push and promote
push deploys a kelviq.config.ts to an environment; promote does the
same sandbox → production, reusing the exact same diff/apply/publish engine
with no local config file involved. Both default to a dry-run-first,
confirm-before-write posture: --dry-run previews with zero writes,
non-interactive sessions require --yes, and production pruning
(--prune) has its own dedicated gate (--allow-prod-prune or a typed
confirmation) that --yes alone never satisfies. New plans are created as
drafts and stay unpublished unless you pass --publish, price changes
replace a plan's full price list rather than patching individual entries,
and --prune only ever archives resources carrying the CLI's own
managed-by: kelviq-cli marker — nothing unmanaged is touched.
What's not here yet
- Catalog browse commands (list/show products, features, plans without a
full
pull) — not built yet. - Browser-handoff login (
whoami, org display) — needs pairing-flow and key-introspection endpoints that don't exist on the platform yet;loginis paste-key only for now.
Development
The full push/promote reconciliation design is documented in
DESIGN-push.md.
Run from a checkout instead of the published package:
npm install
npm run build
node dist/cli.js --helpOr link it onto your PATH for a real kelviq command during development:
npm link
kelviq --helpnpm run dev # run the CLI from source (tsx), e.g. `npm run dev -- pull`
npm test # run the test suite (vitest)
npm run lint # eslint
npm run typecheck # tsc --noEmit, no output
npm run snapshot # refresh src/generated/openapi.json from the live docs URL
npm run build # snapshot + tsc + chmod dist/cli.jsnpm run snapshot (and therefore npm run build, and therefore
prepublishOnly) fetches https://docs.kelviq.com/api-reference/openapi.json
by default and writes it to src/generated/openapi.json — no sibling
checkout required, so build works from any machine with network access.
The response is validated (parses as JSON, has openapi/paths keys, meets
a minimum size) before it overwrites the existing snapshot; a degenerate
response leaves the snapshot untouched and exits non-zero.
For offline or pre-deploy work, npm run snapshot -- --from-local (or
KELVIQ_SPEC_SOURCE=local npm run snapshot) falls back to copying
../docs/api-reference/openapi.json from a sibling checkout of the docs
repo, failing fast if that checkout is absent. Prefer the default (live)
source when possible — a local checkout can sit on a stale branch, so a
"no drift" result against it proves nothing about the deployed spec.
Publish via npm run publish:stable (or npm run publish:beta for a
--tag beta prerelease). scripts/publish.sh guards the release: it checks
you're on a clean main working tree and logged in to npm as kelviq, runs
lint and typecheck, prints a summary, and asks for interactive confirmation
before calling npm publish. It does not run tests or build itself —
prepublishOnly (npm run snapshot && npm test && npm run build) does that
automatically at publish time against a freshly fetched live OpenAPI spec. A
successful stable publish also creates an annotated v<version> git tag and
prints (without running) the follow-up commands to push it.
