emporix
v0.7.0
Published
Unofficial CLI for the Emporix Commerce Engine: schema-as-code, environment cloning and TypeScript generation. Not affiliated with Emporix GmbH.
Maintainers
Readme
emporix
Unofficial. This is a community project and is not affiliated with, endorsed by, or supported by Emporix GmbH. "Emporix" is their trademark; the name is used here only to describe what this tool talks to. For official tooling see the Emporix Terraform provider and the developer portal.
Command-line tooling for the Emporix Commerce Engine: mirror a tenant into git, generate TypeScript from mixin schemas, clone environments, and catch breaking changes before they reach production.
npm install -g emporix
emporix login
emporix pull
emporix types generateWhy this exists
Emporix already ships a Terraform provider that covers configuration-as-code well. This CLI deliberately does not compete with it. It fills the gaps Terraform structurally cannot:
| Gap | What this does |
| --- | --- |
| No TypeScript types for mixin schemas | emporix types generate emits interfaces, enums and Zod schemas |
| Every entity write must repeat the versioned schema URL in metadata.mixins | the generated withMixin() helper injects it |
| terraform plan shows what changed, not whether it breaks stored data | push classifies each schema change and refuses breaking ones |
| No way to mirror a live tenant | emporix clone --from prod --to staging |
| Entities silently pin outdated schema versions | emporix doctor finds the drift, emporix repair fixes it |
Commands
Authentication
Tenant creation itself is Developer-Portal-only. Create the tenant and an API key there, then:
emporix login # interactive
emporix login --profile staging --tenant winestg \
--client-id … --client-secret … # scripted
emporix profiles # list them, * marks the active one
emporix use staging
emporix whoami # tenant, credential source, granted scopesCredentials live in ~/.emporix/config.json (mode 0600), in plain text — the
same trade-off gh and npm make. In CI, set EMPORIX_TENANT,
EMPORIX_CLIENT_ID and EMPORIX_CLIENT_SECRET instead: they take priority and
never touch disk. Setting only some of them is an error rather than a silent
fallback to a local profile, so a misconfigured job cannot write to the wrong
tenant.
Configuration as code
emporix pull # mirror the tenant into ./emporix
emporix validate # lint the directory, no API call
emporix diff # how does the directory differ from the tenant?
emporix push --dry-run # what would change
emporix push # apply, after confirmation
emporix push --prune # also delete what's not in the directorypull writes one JSON file per item, with server-managed fields (version,
createdAt, metadata.url) stripped and keys sorted — so a re-pull produces no
git diff, and a diff means the tenant actually changed.
Covered resources: currency, country, schema, custom-entity,
custom-entity-instance, site, tax, payment-mode, shipping-zone,
shipping-method, delivery-time, tenant-configuration, webhook. Filter
with --include / --exclude.
Nested collections are expanded across their parents and written to matching directories, so a clone reproduces them completely:
emporix/shipping-methods/main/eu/dhl.json # site → zone → method
emporix/custom-entity-instances/supplier/acme.json
``` Services a tenant hasn't
enabled return 403 and are skipped with a warning rather than failing the run.
**Secured tenant configurations are never pulled.** Values flagged `secured`
hold credentials, so writing them to disk would commit secrets to the repository
and pushing them would overwrite the target tenant's own. They are reported and
left in place.
**Reads are checked for completeness.** Emporix offset pagination has no stable
ordering, so pages overlap and skip — reading 249 countries can yield anywhere
between 188 and 249 distinct rows from one run to the next. A short read written
to disk would then be deleted from the target by `push --prune`. The CLI
de-duplicates by natural key, asks the service for `X-Total-Count`, re-reads when
it comes up short, and fails rather than returning an incomplete collection.
### Checking before you push
`validate` lints the local directory without credentials and without a single
API call — unknown attribute types, duplicate keys, `ENUM` without values,
nested items whose parent is missing. These are the mistakes the platform would
otherwise report as a 400 halfway through a push, after part of it landed.
```bash
emporix validate
✗ schema/broken — a: unknown type NOPE (expected TEXT, NUMBER, …)
✗ schema/broken — a: duplicate attribute key
✗ shipping-method/ghost/nowhere/x — references site `ghost`, which is not in this directorydiff is the read-only counterpart, and answers a different question from
push --dry-run: not "what would I write" but "are these two the same".
emporix diff # local directory vs the active tenant
emporix diff --from prod --to staging # one tenant against another
emporix diff --exit-code # exit 1 on any differenceComparing emporix → winestore
schema
≠ differs: productCustom (Product Custom Fields)
attributes
0 only in emporix, 1 differing, 0 only in winestore, 7 identical
⚠ 1 of the differences would be a breaking schema change if applied to winestore.With --exit-code it makes a good nightly job: fail when staging has drifted
away from what the repository says it should be.
Breaking-change detection
push and clone compare each schema against the target and classify the
difference. Removing an attribute, narrowing a type, adding a required field,
dropping an enum value, or turning a plain field localized all invalidate data
that already exists — so they abort the run:
⚠ Breaking schema changes detected
These invalidate data that already exists in the target tenant.
productCustom
! vintage — attribute removed — values on existing entities are dropped
! sweetness — enum value(s) removed: off-dry — entities holding them fail validation
✗ Refusing to push 2 breaking schema change(s).
Review the list above. Pass --allow-breaking once you have a migration plan.Additive changes (new optional attributes, new enum values, renamed labels) pass
without ceremony. When a change really is breaking, emporix migrate is the way
through it rather than --allow-breaking.
Type generation
emporix types generate # → src/emporix.gen.ts
emporix types generate --zod # also emit Zod schemas
emporix types generate --offline # from ./emporix, no credentials needed
emporix types generate --check # non-zero exit if stale (for CI)The output is one self-contained file — no runtime dependency on this package. Nested objects and array elements get their own exported interfaces, so they can be imported and used on their own:
export interface ProductCustomOrigin {
country: string
region?: string
}
export interface ProductCustom {
/** Manufacturer */
manufacturer: string
/** Vintage */
vintage?: number | null
/** Sweetness */
sweetness?: "dry" | "off-dry" | "sweet"
/** Tasting notes */
tastingNotes?: LocalizedValue<string>
/** Origin */
origin?: ProductCustomOrigin
}
export const MIXIN_URLS = {
"productCustom": "https://res.cloudinary.com/…/productCustom_v3.json",
} as constPlus the helper that removes the single most error-prone part of the API:
import { withMixin } from "./emporix.gen.js"
await createProduct({
name: "Château X",
...withMixin("productCustom", { manufacturer: "Vino", vintage: 2019 }),
})withMixin writes both mixins.productCustom and
metadata.mixins.productCustom with the current versioned schema URL. Miss
either half and the platform rejects the write; the URL changes every time the
schema is updated, which is why it is generated rather than hand-written.
Cloning an environment
emporix clone --from prod --to staging --dry-run
emporix clone --from prod --to staging --include schema,site,taxReads directly from the source tenant and writes to the target — no local checkout needed. Resources are applied in dependency order (sites before the shipping zones nested under them). The same breaking-change guard applies.
Diagnostics
emporix doctor- Scope gaps — which resources this API key cannot write, and the exact scope to add.
- Mixin version drift — entities pinned to
_v2while the schema is at_v3. Updating a schema bumps the URL but does not rewrite entities, so a tenant silently ends up validating different rows against different versions. - Orphaned references — entities pointing at schemas that no longer exist.
⚠ 1/3 sampled product entities pin an outdated `productCustom` schema
pinned: _v2 · current: _v3
→ Run `emporix repair` to repoint them at the current schema version.
✗ 1 product entities reference schema `legacyAttrs`, which no longer existsAPI versions
Several Emporix services select their API generation with an X-Version header
rather than a path segment, and the response shape depends on it — a product
read without X-Version: v2 comes back without classificationMixins. The CLI
sends v2 for product, category, country and customer-segment.
Emporix has been dropping the requirement service by service, and states that
clients which keep sending the header continue to work, so sending it is the
safe default. Override per service when your tenant differs — null suppresses
the header entirely:
{ "apiVersions": { "product": "v2", "country": null } }emporix doctor lists what is in effect, because the header is otherwise
invisible.
Entity collections
migrate, repair and doctor walk these collections: PRODUCT, CATEGORY,
CUSTOMER, SITE, ORDER, QUOTE, RETURN, COUPON, PRICE_LIST, MEDIA,
VENDOR, COMPANY, LOCATION.
Five more exist as schema types but cannot be walked. The CLI names the reason rather than reporting them as unknown:
| Collection | Why not |
| --- | --- |
| CART | cannot be listed without customerId and sessionId |
| AVAILABILITY | cannot be listed without site |
| CART_ITEM, ORDER_ENTRY, CUSTOMER_ADDRESS | no collection of their own — migrate the parent document instead |
If your tenant exposes one the CLI does not know, or on a different path, add it
to emporix.json instead of waiting for a release:
{
"entityEndpoints": {
"LOCATION": { "list": "/vendor/{tenant}/locations", "item": "/vendor/{tenant}/locations/{id}" }
}
}Migrating data
A breaking schema change needs the stored values transformed before the schema can move. A migration is a plain JavaScript file — Node runs it directly, so there is no build step:
emporix migrate generate --schema productCustom --field vintage
emporix migrate run migrations/2026-08-23-productCustom-vintage.mjs
emporix migrate run migrations/2026-08-23-productCustom-vintage.mjs --applyexport default {
entityType: "PRODUCT",
schema: "productCustom",
migrate(values, entity) {
if (typeof values.vintage !== "number") return null // null = leave alone
return { ...values, vintage: String(values.vintage) }
},
}Scans up to --limit entities (default 1000) rather than an unbounded
catalogue. Plans by default and prints a before/after for each entity. migrate
also repoints each entity at the current schema URL in the same write, so a run does
not leave behind the drift doctor would flag. Re-running is safe: a transform
that has already been applied returns an unchanged value and is counted as
unaffected.
Repairing drift
emporix repair # plan only — nothing is written
emporix repair --apply # PATCH metadata.mixins to the current URLsrepair scans one page per entity type (--sample). It only touches
metadata.mixins, and only for schemas that still exist —
orphaned references are reported by doctor but never rewritten, because there
is no correct URL to invent. It is idempotent: a second run finds nothing to do.
Scripting
--json replaces the human output with exactly one JSON document on stdout —
including on failure, so a wrapper never has to tell the two apart:
emporix --json diff | jq -e '.inSync'
emporix --json pull | jq '.resources[] | select(.changed > 0)'
emporix --json doctor | jq '.findings[] | select(.level == "error")'{ "ok": false, "error": "Unknown resource(s): widgets", "hint": "Available: currency, …" }CI
- run: npx emporix validate # no credentials needed
- run: npx emporix types generate --check # fails if committed types are stale
- run: npx emporix push --dry-run # fails the PR on breaking changes
env:
EMPORIX_TENANT: ${{ vars.EMPORIX_TENANT }}
EMPORIX_CLIENT_ID: ${{ secrets.EMPORIX_CLIENT_ID }}
EMPORIX_CLIENT_SECRET: ${{ secrets.EMPORIX_CLIENT_SECRET }}push and clone refuse to run non-interactively without --yes.
Project layout
emporix.json # dir + codegen defaults, written by `emporix init`
emporix/ # pulled tenant configuration, one JSON file per item
schemas/
sites/
shipping-zones/main/
src/emporix.gen.ts # generated, gitignoredDevelopment
npm install
npm run build
npm test # 99 tests, incl. end-to-end runs against a mock API
npm run typecheckThe end-to-end suite drives the built binary against a local stand-in for the
Emporix API, and the codegen tests compile their own output under strict
TypeScript — so a generated file that would not type-check fails the build.
Known limits
Validated read-only against a live tenant: endpoint paths, scope names,
pagination, X-Version, secured-value withholding, and round-trip fidelity —
after a pull, push --dry-run reports nothing to do.
The PATCH writes used by migrate and repair are still unverified,
because that validation never wrote anything. Both commands plan by default and
need an explicit --apply; use it on a test tenant first.
License
MIT. Unofficial — not affiliated with Emporix GmbH; see the note at the top.
