Commands
| Command | What it does |
|---|---|
ekwo init |
The whole installation, interactive or not: the socle's migrations, the reference seeds, then the modules', as ekwo migrate applies them. --no-company stops before the first company: see several countries. |
ekwo migrate |
Applies the migrations this release adds, after showing the gap — the socle's, then the modules'. Re-applies the reference seeds, which are idempotent. --no-modules leaves the modules alone. |
ekwo status |
Schema version installed against available, pending migrations, the instance, its administrators, the country packs it holds and, per company, the pack version it copied. Exits 1 when something is pending. |
ekwo doctor |
Every object this release defines and every privilege it grants, against what the database holds; row level security on every table, a policy on every protected table, no pending migration, no membership pointing at a deleted user, every company with a bank account, statements that tie to their lines, posted entries that balance. Exits 1 on a problem, 0 on warnings. |
ekwo register |
Opt in to security advisories and release notes. Also the retry when the announcement did not go through. |
ekwo unregister |
Opt back out. Clears the address and the date on the instance row. |
ekwo demo |
Loads the sample company. Fictional data, explicit request only. |
ekwo module |
What is installed beside the socle, applies a module's migrations and its country seeds, and turns one on or off for a company. |
ekwo company |
new creates a company in its own country, through create_company(), and list shows the companies held here. One company leaves an installation with its books — export writes an archive anybody can read, as a member under row level security — and arrives in another one alive: import takes it in whole or not at all. |
ekwo pack |
Compiles a country pack into its seed, and refuses a seed that is no longer the output of its pack. Runs in a checkout of the repository only. |
ekwo login |
Signs in to an instance as yourself and keeps the session, in your own configuration directory. See acting as a person. |
ekwo logout |
Ends that session, here and on the instance. |
ekwo use <company> |
Picks the company the next commands run on. |
ekwo whoami |
Who you are on which instance, the companies you can see, and what you may do on the one in use. |
ekwo contact add / list |
A customer or a supplier, and finding one again. |
ekwo doc new / doc line add |
A draft document — any kind, with --type — and one more line on it. A draft books nothing. ekwo invoice is the old name of ekwo doc, kept as an alias. |
ekwo post <document> |
Books it, through post_document(). --dry-run shows the entry the database would write and writes nothing. |
ekwo cancel <document> |
Undoes a posted invoice, and says how. Back to draft, through unpost_document(), where its country allows it and nothing about it has left; otherwise through cancel_document(): the credit note that names it, posted and matched against it, and the invoice cancelled. --date books the credit note on another day than the invoice's, which is how a locked period is stepped over; --credit asks for the note where a draft was possible. |
ekwo reverse <entry> |
Undoes a posted entry keyed by hand, through reverse_entry(): its mirror, posted and matched against it. By id or by number; --date as for cancel. |
ekwo payment record |
Money in or out, booked and matched. With --doc, against that document. |
ekwo match <transaction> <document> |
A bank statement line pays a document, through settle_from_statement(). |
ekwo import <source> <file>… |
Books kept elsewhere — trial-balance, fec, journal-items, journal-report — whole or not at all, through import_books(), with a correspondence of accounts and journals you save and give back; or a bank statement — camt.053, coda, cfonb120 — as pending lines. --dry-run rehearses. See taking over books. |
ekwo doc list / show |
What exists, and with --unpaid what is posted and still owed. See keeping books. |
There is no eject, because there is nothing to eject from. The schema is in
your database, the migrations are in the repository under AGPL-3.0, and
supabase db push applies them without this CLI ever running again.
What a command answers: --json and the exit codes
Every command prints for a person by default — aligned columns, colour only on
a terminal and never when NO_COLOR is set, no spinner and no line redrawn in
place, so the output reads the same in a file or a CI log — and takes --json
for a program.
ekwo help --json answers the list of commands and, in usage, the whole
text of --help without colour: every flag and variable, for an assistant
that reads one document rather than a terminal.
Under --json the standard output is one JSON document and nothing else;
the prose still goes by, on the standard error. The document has the same
shape whatever happened:
{
"ok": false,
"command": "module enable",
"exitCode": 3,
"warnings": [],
"error": {
"kind": "refusal",
"name": "not_allowed",
"message": "not_allowed: enabling a module on this company needs company.write",
"sqlstate": "42501"
}
}
| Field | |
|---|---|
ok |
exitCode is 0. |
command |
The words that named it: status, pack upgrade. |
exitCode |
The code the process ends on. |
data |
What the command has to say. Its shape is per command, under $defs/data/<command> of the schema. Absent when it failed before having anything to say. |
warnings |
Every warning the command printed, without the colours. |
error |
Only when something went wrong: kind (refusal, usage or technical), the message word for word, the name it starts with when it has one, and the sqlstate, detail and hint when the database gave them. |
The shape is published as
schema/output.1.json, ships in the package, and is
what tests/cli/output-contract.test.ts validates every command against. An
amount is a decimal string and never a JSON number; a date is ISO 8601.
| Exit code | Means |
|---|---|
0 |
Done. |
1 |
It failed for a reason that is not the books — the network, a database that does not answer, a bug — or a check found something: a doctor problem, a pending migration in status, a stale seed in pack check, a company behind its pack. In the second case data says what and there is no error. |
2 |
The command was called wrong: an unknown option, a missing argument, or a question that needed an answer with no terminal to ask it on. |
3 |
The database refused. A locked period, a capability you do not hold, a row level security policy, a constraint. The call was well formed and everything worked; the accounting said no. |
A refusal is printed as the database wrote it — period_locked: …,
tax_territory_mismatch: … — and never rephrased; the CLI does not move a
date or retry differently to get past one. Its name is the part to match on,
in a field of its own under --json.
No command waits on a question when there is nobody to answer. Off a
terminal, or under --json, or with --yes, a missing answer is exit code 2
with the flag to pass. That holds underneath the commands too: a prompt that
is reached with no terminal stops instead of waiting.
Acting as a person: login, use, whoami
The commands above install and operate, and connect as the owner of the database — they say so when they connect. Anything that keeps books acts as a person, through the instance's API, under row level security: the same route, and the same functions of the schema, as the MCP server.
ekwo login --supabase-url https://<ref>.supabase.co --anon-key <publishable key> --email you@example.test
ekwo whoami
ekwo use "Example One"
ekwo whoami --json
login asks the instance for a session and keeps it. In a directory that has
an ekwo.json, the URL is read from it. The password is prompted, masked, when
--password and EKWO_PASSWORD are absent; it is sent to the instance once
and written nowhere. Signing in again after a session ended is ekwo login
and a password: the profile remembers the rest.
Where the session is kept. In $EKWO_CONFIG_DIR, else
$XDG_CONFIG_HOME/ekwo, else ~/.config/ekwo, in two files written 0600 in
a 0700 directory: profiles.json says where each profile points and holds
no token; credentials.json holds the access token and the refresh token.
The CLI refuses — config_dir_in_repository — to write either inside a
repository, where one git add . would publish them. The access token lasts
about an hour and is renewed on its own, ahead of time and again if the
instance answers 401 anyway; the rotated refresh token replaces the old one
on disk before the call is retried. A session that cannot be renewed is
session_expired, exit code 2, and the fix is ekwo login.
Profiles. --profile <name>, or EKWO_PROFILE: a demo instance,
production, one client of a firm. Each holds one instance, one person and one
company in use. The profile last signed in to is the one used when none is
named.
The environment comes first, and touches no file. With SUPABASE_URL,
SUPABASE_ANON_KEY and either EKWO_ACCESS_TOKEN or EKWO_EMAIL with
EKWO_PASSWORD — the variables the MCP server reads — a command signs in for
its own duration, reads no profile and writes nothing: a CI job. The
environment is taken whole: a user there with no instance beside it is a wrong
call, never a fallback on a profile's instance. It has nowhere to keep a
company, so pass --company.
The company in use. ekwo use <name or id> checks the company against the
instance, as you, and records it; --company names another for one command.
A company you cannot see is unknown_company, and is not named in the
refusal. Under --json every answer of a command that acts as a person
carries a context — the profile, the instance and the company the answer
was rendered for, null when none is in use — on success and on a refusal
alike. A caller that keeps two sets of books reads it before it believes the
rest.
{ "ok": true, "command": "whoami", "exitCode": 0,
"context": { "profile": "default", "instance": "https://<ref>.supabase.co",
"company": { "id": "…", "name": "Example One" } },
"data": { "user": { "id": "…", "email": "you@example.test" },
"instance": { "url": "…", "schemaVersion": "0.3.0" },
"capabilities": ["…"], "companies": [ … ] },
"warnings": [] }
whoami works nothing out. The companies are the rows the policies let you
read, the capabilities are what member_capabilities() answers for the
company in use — the function behind your_capabilities in the MCP server —
and the schema version is ekwo_schema_version().
Never a service_role key. It is refused by name, service_role_refused,
with exit code 2 and before anything is sent, at every door it can arrive by:
--anon-key or SUPABASE_ANON_KEY, EKWO_ACCESS_TOKEN, a session file
somebody edited, and --service-role-key typed out of habit. The test for it
and the sentence are in @ekwo-ai/core, where the MCP server reads them too.
ekwo init remains the one command that takes that key, to create the first
user, and never keeps it.
| Refusal of the CLI's own | Exit code | Means |
|---|---|---|
not_signed_in, unknown_profile, session_expired |
2 | There is nobody to act as. ekwo login. |
service_role_refused, config_dir_in_repository, missing_configuration |
2 | The call has to change, not be retried. |
unknown_company, ambiguous_company |
2 | Name it differently, or by its id. |
no_company |
2 | A verb that keeps books ran with no company in use. ekwo use, or --company. |
unknown_contact, ambiguous_contact, unknown_document, unknown_account_code, unknown_tax_code, document_not_draft, nothing_open, bad_line, unknown_field, bad_json |
2 | Decided before the database was asked, by the CLI or by the functions it shares with the MCP server. The call has to change. |
sign_in_failed, instance_unreachable |
1 | The instance declined the address and the password, or did not answer. |
A refusal of the database that arrives over this route is still exit code 3: PostgREST passes on the SQLSTATE, the detail and the hint, and the CLI reads them as it reads a driver's.
Keeping books
ekwo contact add "Client Example" --country <cc> --ref crm-42
ekwo doc new --contact client --date 2026-06-15 --ref job-7 \
--line "name=Audit,price=1500.00,account=<account code>,tax=<tax code>"
ekwo doc line add job-7 --name Travel --price 250.00 --account <account code>
ekwo post job-7 --dry-run # the entry the database would write; nothing is written
ekwo post job-7 # post_document()
ekwo cancel job-7 # back to draft where the country allows it, else the credit note
ekwo payment record --doc job-7 --amount 1750.00 --date 2026-06-30 --bank-account <id> --ref bank-1
ekwo match <bank transaction id> job-9
ekwo doc list --unpaid --since 2026-06-01 --json
They run as the person signed in, on the company in use (above),
and none is ever picked for you: with no company in use a verb ends on
no_company, exit code 2, and context.company is null.
Each verb is one function, and it is not ours. The functions live in
@ekwo-ai/core and the MCP server calls the same ones: contact add is
create_contact, doc new is create_document, post is
post_document, cancel is unpost_document or cancel_document — unpost_refusal chooses —, reverse is reverse_entry,
payment record is record_payment, doc list and
doc show are list_documents and get_document. Underneath them the rules
are the schema's — the balance, the numbering, the locks, the taxes, the
territory, the tax point. This CLI computes no amount: what you type goes
in as text, what is printed is what came back, and
tests/cli/no-rules.test.ts reads the commands to keep it that way. An amount
is a decimal string in both directions, 1500.00.
A refusal is the answer. A locked period, a policy, a constraint: exit
code 3, the database's sentence word for word, its name in error.name. The
CLI does not move a date or try something else. What is refused before the
database is asked — an account code that does not exist, a document that is
not a draft, a document with nothing open — is exit code 2: the call has to
change.
--ref, so that nothing is created twice. On what creates (contact add,
doc new, payment record), --ref <your reference> is kept on the row,
unique per company. The same reference a second time returns what the first
call created, with "replayed": true, and writes nothing — and finishes what
a dropped connection left half done: a draft whose lines never arrived, a
payment inserted and never booked. Two callers racing each other are settled
by the unique index, which refuses the slower one with exit code 3. A
<document> is its id, its number, or the --ref it was created under, which
is how a draft — it has no number yet — is named.
--dry-run, where the database can answer without writing. Today that is
post. rehearse_post_document() calls post_document() for real inside a
block it then rolls back, so the entry shown is the one that would be written,
under the number it would take, and a rehearsal is refused exactly as posting
would be. No other verb has one, because for no other verb does the database
know how.
--stdin, the form that is authoritative. One JSON object on the standard
input, with the fields of the MCP tool of the same meaning (contact_type,
document_date, lines: [{ name, unit_price, account_code, tax_code, … }],
client_ref). A field nobody defined is refused rather than dropped. Flags
given beside it win.
echo '{"contact":"client","document_date":"2026-06-15","client_ref":"job-8",
"lines":[{"name":"Review, \"urgent\"","unit_price":"200.00","account_code":"<code>"}]}' \
| ekwo doc new --stdin --json
--line, for a person. Named fields, never positions: name, price,
qty, account, tax, product, unit, discount, description; a comma
inside a value is \,. A tax and an account are named by their code —
never a rate, since several taxes share one. The free-text form
("Audit 1 500 EUR@21") is not accepted: 1 500 is one number or two, @21
is a rate where the books need a tax, and a currency belongs to the document
(--currency), not to a line.
Two values are supplied when nobody gives them, and said when they are:
--type is sale_invoice, and --date is today on the machine running the
command. Whether that date may be booked on is the database's decision.
Taking over books: ekwo import
To try Ekwo on your own books, bring them. One command, one reader per source, and nothing is posted while an account has no answer:
ekwo import fec 123456789FEC20251231.txt --dry-run --open-years --save-mapping map.json
# read, propose a correspondence, rehearse the import in the database, take it back
$EDITOR map.json # answer what is null — read `suggested` — correct what is wrong
ekwo import fec 123456789FEC20251231.txt --mapping map.json --open-years
ekwo import trial-balance balance.csv --opening-date 2026-01-01 --dry-run --save-mapping map.json
ekwo import journal-items items.csv accounts.csv partners.csv --dry-run --save-mapping map.json
ekwo import journal-report report.csv chart.csv --date-order dmy --dry-run --save-mapping map.json
ekwo import camt.053 statement.xml # or coda, cfonb120: pending lines for `ekwo match`
| Source | What it reads |
|---|---|
trial-balance |
A trial balance as CSV — account, debit, credit, or one signed balance — which becomes the opening entry of the year --opening-date starts |
fec |
A fichier des écritures comptables: the eighteen columns of the arrêté of 29 July 2013, tab or bar separated |
journal-items |
The lines of every entry exported as CSV from the list view of an ERP whose ledger is a table of lines, with the chart of accounts and the partners exported beside it |
journal-report |
A journal report or a general ledger detail, saved as CSV from a cloud service's spreadsheet export, with its Journal ID and Account Code columns, and the chart and the contacts beside it |
camt.053, coda, cfonb120 |
A bank statement: its lines, pending, ready for ekwo match |
Each reader is a brick of packages/formats/, named
after the file; its README says which columns it reads and which official
pages the format was read from. The exports of other ledgers can also be named
by the software they come from: ekwo import --help lists those names, and
docs/compatibility.md gives each one's export,
official page and state.
The correspondence is yours. Every account of the old chart has to become
an account of the company's chart, and every old journal a journal of the
company. The codes give a candidate — the same code, the same digits without
the zeros a chart pads with (411 and 411000), or the account whose digits
are the longest beginning of the old code, three at least (401ACME and
401000); a tie is no answer — and what the files say of the old account —
the type its export gives it, its name, the side of its balance — is held
against the type of the candidate in the chart, because two charts give the
same digits to different things. Only the same code, not contradicted, is
exact. Anything else is suggested, with its reason, and waits for you; a
candidate the files contradict is dropped for the one account of the chart of
the kind they say. --dry-run prints the lines to read first, each with its
reason, and --save-mapping writes the correspondence as JSON:
{
"version": 1,
"source": "fec",
"accounts": { "411000": "411000", "401ACME": null, "471200": null },
"journals": { "VE": "SAL", "AN": "@opening", "BQ1": "MISC" },
"suggested": { "401ACME": { "target": "401000", "reason": "the longest beginning of the code the chart has; …" } }
}
and --mapping gives it back: what it answers wins over the proposal, so the
second run posts what the first one showed. A suggestion is confirmed by
writing its code under accounts; --accept-suggestions takes all of them
once you have read them. Nothing is posted while one the books use is only
suggested. A journal mapped to @opening
becomes the opening entry of its year instead of ordinary entries — the
à-nouveaux of a FEC, typically. A journal whose code is the one the pack
opens years on is proposed as @opening on its own; * stands for entries
the source gives no journal.
Whole or not at all. import_books() is one call and one transaction: the
fiscal years it needs (with --open-years, as years of the same length and
first day as the company's own; without, an entry outside every year is
refused by name), the parties the lines name — found by their code or their
name, or created as customers or suppliers according to where their lines are
booked — every entry as a draft then posted by post_entry(), and the opening
through opening_balance(). One refusal — a locked period, an account the
chart does not have, a capability you do not hold — and nothing stays, not
even the years or the parties.
--dry-run is the real thing, taken back. The database runs the whole
import and rolls it back, so the numbers shown are the ones the entries would
take now and a refusal is the one the import would give. For a bank statement a
dry run reads the file and asks nothing: a statement books nothing anyway.
What else to know:
- Refused before the database is asked, and listed under
refusalsby a dry run: an account or a journal with no answer or only a suggestion, books whose reader found something that does not add up (an entry that does not balance, a line of a draft entry), a currency the files name that is not the company's. - The numbers are drawn by the journal each entry goes to; the old number
is kept as the entry's reference.
--keep-numbersposts each under its old number instead, where the country allows a number chosen by hand or you holdentries.import. - No tax. An imported line carries an account and an amount, not the tax that produced it: the history feeds the ledger, the trial balance and the statements, and no box of a VAT return. A period kept elsewhere was declared from where it was kept.
- The same files twice are refused (
import_already_done):book_importskeeps the checksum of what each import read, and the refusal says which files, when, and what that import wrote. - Dates and encodings are said, never guessed:
--encodingfor a file that is not UTF-8,--date-orderfor a report that writes dates in digits. - Reconciliation marks are read and not re-applied yet: the matching of an
imported receivable against its payment is done in Ekwo, with
reconcile.
The MCP server offers the same as import_books, and import_bank_statement
for a statement. docs/import.md is the long form.
ekwo module
A module is a Postgres schema beside the socle — assets for fixed assets,
budgets for a plan against the ledger. Its migrations travel with this
package, and ekwo migrate applies them by default.
ekwo module list # what this release carries, and what the database holds
ekwo module migrate [<code>] # the migrations, and the country seeds they need
ekwo module enable assets --company "…" # turn it on for one company
ekwo module disable assets --company "…" # turn it off; nothing it wrote is deleted
enable and disable go through enable_module() and disable_module()
rather than writing the table: the guard is in the function, so it applies to
psql and PostgREST alike. The CLI sets the request claim for an owner of the
company, the way ekwo register does, and --as-user <uuid> names another.
One thing this CLI cannot do, and says so every time: PostgREST serves a
schema other than public only once the project lists it under its exposed
schemas. That is a setting of the API, not of the database, so ekwo module enable prints the line to add — Supabase dashboard → Project Settings → API,
or [api] schemas in supabase/config.toml.
Before supabase db push, run ekwo migrate --no-modules. The Supabase
CLI knows the socle's migration files and not a module's, so it would report
them as history it has no file for.
ekwo company
A firm keeps several companies in one installation, each in its own country,
and each of them belongs to somebody. new creates one and list shows them —
see several countries:
ekwo company new "My Company Belgium" --country BE --chart default --language fr
ekwo company new "My Company France" --country FR --language fr
ekwo company list # name, country, currency, language, chart, pack, members
company new --json answers the company (id, country, currency,
language, chart, packVersion), its first financial year and its owner;
company list --json answers companies, one per company. Both shapes are
under $defs/data of the output schema, and the exit codes are the ones every
command has: 2 for a missing answer, 3 when the database refuses.
The two commands below are how a company leaves, and arrives somewhere else.
ekwo company export "My Company" --out ./my-company # manifest.json + data/<schema>.<table>.jsonl
ekwo company import ./my-company --owner <user id> # whole, or not at all
export runs as a member, under row level security, although the
connection belongs to the owner of the database: inside one transaction the CLI
steps down to authenticated with the claim of the member it acts for —
--as-user, an owner of the company by default. That member needs
company.export, which the owner and client presets hold. An archive is
whole or it is not written: a member who may not read one of the tables is
refused, by table, with exit code 3. The act is written on the audit trail of
the company.
import is for the installer or an administrator of the installation
(--as-user), the two who may create a company. The files are checked against
the manifest before the database is asked anything; then import_company()
takes all of it or none of it. A company already there is refused — exit code
3, company_already_here — which is also what running the command twice gets.
Members do not travel: --owner names the first one.
The files the attachments point at are not carried. They are in the storage
bucket, not in the database; manifest.json lists them and both commands say
how many are left to copy.
The format, what travels and what does not, and every refusal are in
docs/company-archive.md.
ekwo doctor
What a healthy installation is true of, and nothing in the schema can enforce on its own. It reads and reports; it never repairs, because the fix for a missing policy is a migration and the fix for an orphaned membership is a decision about who should have access.
ekwo doctor --db-url "$URL" # readable
ekwo doctor --db-url "$URL" --json # the whole report, findings included
The catalogue check compares your database to an inventory of everything
this release defines — tables and their columns, views, functions with their
identity arguments, policies, triggers and types. That inventory is
assets/expected-objects.json, generated from
the migrations themselves and shipped inside this package, so it cannot be a
list somebody forgot to update. In --json output it is the check named
catalogue. Four outcomes, and they are not the same thing:
| Finding | What it means | Severity |
|---|---|---|
| Missing | The installation is behind or has been damaged. | Problem |
| Extra | Your own table, function or trigger. Reported so you know it is there. | Information |
| Extra or missing policy on a table of this schema | Row level security is the security model. A policy that is gone closes everything; one that was added is a grant nobody reviewed. | Problem |
| A column whose type has moved | The schema was patched by hand. Reported as changed, not as missing: "missing" would send you looking for a migration that did land. | Problem |
A module's objects are required only of a database that carries the module. One you never installed is named and skipped.
A database older than this CLI is still compared. The report says which schema version the inventory describes and which one the database reports, and goes on to list what differs — refusing to look would be refusing the case the check exists for.
Exit codes. 0 when there is no problem, warnings and information
included; 1 when there is at least one problem, or when the schema is not
installed at all. Nothing else. So ekwo doctor is usable as a deployment
gate, and an operator's own extra table never turns a pipeline red.
What the catalogue does not cover. Constraints, indexes and the bodies of
functions. A dropped unique index is real damage and this check will not see
it: the question it answers is "is the object there, and is it still that
shape". docs/schema.md lists the constraints for a human reader, and the
argument against putting them in the inventory is that each is an order of
magnitude more text for a diff that would move on every Postgres upgrade — and
an inventory whose diff nobody reads is worth nothing.
The grants check compares the privileges, from the same inventory: the
grants section of each schema says which of anon, authenticated and
service_role may reach each table, view and function, and with which verbs.
Its own check rather than a category of catalogue, because the rule is not
the same.
| Finding | What it means | Severity |
|---|---|---|
| A privilege the release grants and the database does not hold | Nothing else notices it, and it reaches a client as permission denied for table companies. |
Problem |
Any privilege anon holds beyond what the release grants |
The anonymous role reaches the ten policy helpers and no table. One more is a surface nobody reviewed. | Problem |
A privilege authenticated or service_role holds and the release does not grant |
Usually a local customisation. Row level security is then the only thing refusing a verb the schema meant to withhold. | Warning |
| A default privilege still standing on a schema | A privilege that comes from there comes from something no migration wrote, and a recreated schema takes it away. | Warning |
In a checkout, npm run inventory regenerates the inventory from the
migrations; the CI regenerates it and fails on any difference, the way it does
for docs/schema.md, and a second job checks that the copy shipped in dist
is the one in the repository.
ekwo pack, in a checkout
A country is data: packs/<cc>/ holds a manifest, the chart of accounts as
CSV, the taxes and where they post, the boxes of the declaration, the financial
statements, the sentences the country requires on an invoice, the translations,
and a year of books with the figures it produces. The compiler turns one into
supabase/seed/<n>_pack_<cc>.sql, which is committed — and, where a pack
carries a section for a module, into
supabase/seed/modules/<code>/<n>_pack_<cc>.sql, applied by the module
migration runner and by nothing else.
ekwo pack list # the packs this checkout carries, and their certification
ekwo pack describe # everything each of them says; one country with `describe <cc>`
ekwo pack build be # write supabase/seed/10_pack_be.sql from packs/be, and the lists of packs
ekwo pack build --all
ekwo pack check be # validate one pack, compare its seed and the lists of packs
ekwo pack check --all # exit 1 if a committed seed or list is not the output of the packs
The lists are the blocks of other files that name every pack — the seeds of
supabase/config.toml and of the root README, the /packs/<cc>/ lines of
.github/CODEOWNERS, the table of docs/packs.md — each between a
generated:<name> marker and /generated. They are written from packs/ the
way a seed is, so a country is added in packs/<cc>/ and nowhere else.
list is a line per country. describe is the whole of one: the charts and
who each is published for, the taxes and their distinct rates, the periodic
declaration with its cadences and its boxes, whether the country states a rule
for when the return is due, the brick that writes the file it is deposited as
or that it is filed by hand on a portal, the e-invoicing profile and the day it
starts, the accounts the tax balance lands on, every bank statement format the
country names and whether anything here reads it, the financial statements, and
the texts the pack was built from with the day each was last opened. Every
answer is read from the pack, and a "not yet" is printed rather than left out.
ekwo pack describe --json | jq '.data.packs[] | {country, version}'
Under --json the whole description of every pack is the result. It is the
same object the site at ekwo.ai builds each
country's page from, so the site and the command line cannot come to say
different things about a country.
check validates every file of the pack against
packs/schema/pack.1.json and against the
rest of the pack, then compares the committed seed with what the compiler makes
of it now. It is what the CI runs, so the SQL cannot drift from the pack. Every
rule it applies is listed in docs/packs.md, under
"What ekwo pack check refuses".
Neither command touches a database: the seed is applied by ekwo init,
supabase db push or psql -f, like every other seed. A published
installation has the compiled seeds and no packs/ folder, and the command
says so rather than guessing.
Two commands under ekwo pack do the opposite and read an installation rather
than a checkout, so they take a connection and work without packs/:
ekwo pack status --db-url "$EKWO_DB_URL" # which pack version each company copied
ekwo pack upgrade "My Company" --db-url "…" # move it to the version this installation holds
status changes nothing and exits 1 while a company is behind, so a scheduled
job can ask. upgrade applies an addition and a closed validity by itself,
lists everything else for a person to read, and never removes anything from a
company's books; --apply is what accepts the differences it listed.
Flags
Every command takes the connection flags:
| Flag | Meaning |
|---|---|
--db-url <url> |
Postgres connection string. The reliable way. |
--project-ref <ref> |
With --db-password and --db-region, the session pooler host. |
--db-password <pw> |
Database password. Prompted, masked, when omitted. |
--db-region <region> |
With --project-ref, the session pooler in that region. Both generation prefixes are tried and the one that answers is kept. |
--supabase-url <url> |
https://<ref>.supabase.co. Needed only to create a user. |
--service-role-key <key> |
Needed only to create a user. Either form Supabase issues: a secret key, sb_secret_…, sent on the apikey header alone because it is not a JWT, or the legacy service_role JWT, sent on apikey and as a Bearer as before. |
--yes, -y |
Never ask a question. Everything must come from flags or the environment. |
--project-ref with --db-password and --db-region builds the session
pooler host. It no longer guesses which one: the pooler hostname carries a
generation prefix as well as a region, and the region does not determine it,
so aws-0-<region> and aws-1-<region> are both opened on port 5432 and the
one that answers is kept and printed. Without --db-region nothing is
derived — the CLI asks for the connection string, because the direct host
db.<ref>.supabase.co is IPv6-only on recent projects and deriving it
silently produces a hang rather than an error. --db-url, copied from the
dashboard under Connect → Session pooler, is the form that is never derived.
ekwo init adds:
| Flag | Meaning |
|---|---|
--country <cc> |
Which country pack: its chart of accounts, its journals, its taxes and its declaration. One of the packs the database holds — ekwo pack list names them, and there is no default. |
--chart <code> |
Which chart of accounts, where the country publishes several. Required outside a terminal when it does. |
--org <name> |
Your organisation, written on the instance row. |
--company <name> |
The first company. Defaults to --org. |
--admin-email <address> |
The first administrator, created in your Supabase Auth. |
--admin-password <pw> |
Their password. Omitted, an invite link is generated and printed. |
--admin-user-id <uuid> |
Use an account that already exists, instead of creating one. |
--fiscal-year <year> |
Calendar year of the first financial year. Defaults to this year. |
--fiscal-year-start <date> |
The day that year opens, as YYYY-MM-DD. Needed only where the pack names no usual opening month (defaults.fiscal_year_default). |
--currency <code> |
Currency of the company. Defaults to the pack's defaults.currency. |
--language <xx> |
Language of the books, two letters. Defaults to country_defaults.language_default, which the pack fills. It decides which label of the pack lands on each account; the others are kept in name_i18n. |
--iban <iban> |
Creates the main bank account, wired to the bank journal and its ledger account. Omitted, no bank account is created and ekwo doctor says so. |
--bic <bic> |
Optional, on that account. |
--bank-name <name> |
Optional. It also names the account in the books. |
--demo |
Also load the sample company. |
--register |
Register without being asked. --register-email sets the address. |
--registry-url <url> |
Where the registration is announced. |
--no-modules |
Leave the modules out. By default init installs them, as ekwo migrate does — empty schemas until a company enables one. |
--no-company |
Install without a company: schema, every pack, the modules, the first administrator, and no country on the instance row. The flags that only describe a company are refused with it. Then ekwo company new, once per company. |
ekwo company new <name> takes --country, --chart, --language,
--currency, --fiscal-year and --fiscal-year-start as init does, and
--as-user <uuid>: the administrator of the installation it is created as,
who becomes its owner — the only one, when there is exactly one.
Every command takes --json; see what a command answers.
ekwo migrate takes --skip-seeds.
Environment variables
| Variable | Same as |
|---|---|
EKWO_DB_URL |
--db-url. SUPABASE_DB_URL also works. |
EKWO_DB_PASSWORD |
--db-password |
SUPABASE_URL |
--supabase-url |
SUPABASE_SERVICE_ROLE_KEY |
--service-role-key. ekwo init only. |
SUPABASE_ANON_KEY |
--anon-key |
EKWO_EMAIL, EKWO_PASSWORD |
Sign in for one command, writing nothing. ekwo login reads them too. |
EKWO_ACCESS_TOKEN |
The same, with a session token already in hand. It is not renewed. |
EKWO_PROFILE |
--profile |
EKWO_CONFIG_DIR |
Where profiles and sessions are kept. |
EKWO_REGISTRY_URL |
--registry-url. Default https://api.ekwo.ai/v1/registrations. |
NO_COLOR |
Plain output. |
See .env.example at the root of the repository.
Secrets
The CLI never writes a password or a key to disk. The database password and
the service_role key are read from a flag, an environment variable or a
masked prompt, used, and forgotten; nothing is in ekwo.json but the project
URL, the country and the schema version.
One thing is kept, since ekwo login: the session of the person who signed
in — an access token and the refresh token that renews it — in their own
configuration directory, readable by them alone, and refused anywhere inside a
repository. It is in a file and not in the keychain of the operating system;
ekwo logout removes it and ends it on the instance, and a job that should
keep nothing sets the environment variables instead.
It has one runtime dependency from outside this repository, the Postgres
driver. Argument parsing, prompts and the masked input are a few dozen lines
each in this package rather than packages from the registry, because
everything this CLI is handed is a secret and every dependency added is one
more thing that could read it. The other dependency is @ekwo-ai/core, this
repository's own, where the CLI and the MCP server read a refusal of the
database the same way.
Registering with Ekwo
At the end of ekwo init you are asked:
Register this installation with Ekwo to receive security advisories and release notes?
The default answer is no, and no is supported forever. Community works
unregistered: nothing in the schema and nothing in this CLI reads
contact_email or registered_at to decide what you may do, and edition
gates no feature.
If you say yes, two things happen, independently. register_instance(email)
writes the address and a date onto your instance row, and a POST goes to
EKWO_REGISTRY_URL carrying exactly six fields:
{
"instance_id": "…",
"organization": "My Organisation",
"country": "BE",
"edition": "community",
"schema_version": "0.2.0",
"contact_email": "you@example.com"
}
No ledger data, no user list, no connection string. instance_id is generated
locally by your own database and is not a licence key: no code path anywhere
checks it.
The endpoint does not exist yet. A failed POST is a soft message, not a
failed install: the local record stands and ekwo register retries it later.
ekwo unregister clears the local fields; it sends nothing, because the CLI
only holds the local row.
Testing it against a real project
The test suite runs against Postgres compiled to WebAssembly, so it proves the migration runner, the installation sequence and the checks without a Supabase project. Four things it cannot prove: the network driver, PostgREST, GoTrue, and the extensions a hosted project has.
The automated way. From a checkout of the repository, against an empty project you can throw away:
npm run e2e:supabase
It installs, migrates, upgrades the pack, signs in, books, files the
declaration and closes the year, and prints a pass/fail table with how long
each step took — which is the number worth reading, because what matters
about a release is which step holds it rather than the total. Everything comes
from the environment and no secret reaches the output; it refuses a database
that already holds an instance row, and --reset empties a throwaway project
so a failed run can be replayed.
Point EKWO_E2E_PREVIOUS at the last tag to make the run upgrade an
installation instead of creating one. The packages are not on npm yet, so it
takes a path to a built binary of an older checkout rather than a version:
git worktree add /tmp/prev v0.2.0
(cd /tmp/prev && npm ci && npm run build)
EKWO_E2E_PREVIOUS=/tmp/prev/packages/cli/dist/bin.js npm run e2e:supabase
docs/releasing.md lists every variable it reads and
every refusal it makes. It is run by hand before a release is tagged, never by
the CI.
By hand, if you want to watch each step:
npm install && npm run build
# 1. A project you can throw away. Note its ref, password, URL and key.
# --chart and --language are required here and not optional: the Belgian
# pack publishes two charts of accounts and four languages, and `ekwo init`
# refuses to pick either for you when there is nobody to ask.
node packages/cli/dist/bin.js init \
--db-url "postgresql://postgres.SCRATCHREF:PASSWORD@aws-1-REGION.pooler.supabase.com:5432/postgres" \
--supabase-url "https://SCRATCHREF.supabase.co" \
--service-role-key "$KEY" \
--country BE --chart default --language fr \
--org "Scratch" --company "Scratch BV" \
--admin-email "you@example.com" --admin-password "a-long-password" \
--fiscal-year 2026 --iban "BE71096123456769" --yes
# 2. Everything should be green, and nothing pending.
node packages/cli/dist/bin.js status --db-url "$URL"
node packages/cli/dist/bin.js doctor --db-url "$URL"
# 3. The history must be the Supabase one: this should report no difference.
supabase link --project-ref SCRATCHREF
supabase migration list
# 4. And the other direction: db push finds nothing left to do.
supabase db push
# 5. Run init again. Every step should say it was already there.
node packages/cli/dist/bin.js init --db-url "$URL" --country BE \
--chart default --language fr \
--org "Scratch" --company "Scratch BV" --admin-email "you@example.com" \
--admin-user-id "<the uuid from step 1>" --fiscal-year 2026 --yes
# 6. Sign in as the administrator and confirm row level security really binds:
# a company you were not invited to must be invisible.
Licence
AGPL-3.0-only © Ekwo AI.