The command line and application files
The pgkiln command line runs migrations, exports and imports applications, and compares an application with files in git (APEX: SQLcl apex export and the APEXlang application files). An application can be exported as one JSON file or as a directory with one file per component that reads well in pull requests, and imported again, as a copy or over the existing application.
Running it
From a pgkiln checkout (it runs TypeScript with tsx, like the server):
npm run pgkiln -- apps # through npm
npx tsx src/cli/main.ts apps # directly
./bin/pgkiln.js apps # the package's bin entry (also after npm link)It connects with DATABASE_URL (the owner role) from the environment or .env, the same as the server and npm run db:migrate; --db <url> overrides it. Every command has --help.
| Exit code | Meaning |
|---|---|
| 0 | done; for diff: no differences |
| 1 | diff found differences |
| 2 | usage error (unknown command or option, missing argument, alias already taken) |
| 3 | failure (application not found, database error, invalid files) |
Errors go to standard error. The CLI never prints passwords or hashes; the exports don't contain any (see what is not exported).
Commands
| Command | What it does |
|---|---|
pgkiln migrate [--example <name>] | Applies the migrations that were not applied yet, then optionally examples/<name>/ (same as npm run db:migrate / npm run example:hr; --root and --seed as in scripts/migrate.ts) |
pgkiln apps [--json] | Lists the applications: alias, number of pages, name |
pgkiln export <alias> [--format json|dir|text] [--out <path>] | json (default): the pgkiln/2 document with sorted keys, to --out or standard output. dir: a directory (default ./<alias>), see below. text: the same directory in YAML with the code inline (text files) |
pgkiln import <path> [--alias <alias>] [--replace] | Imports a JSON export, an application directory or a .zip of one. --alias gives the copy another alias. --replace updates the application with that alias in place |
pgkiln diff <alias> <path> [--name-only | --quiet] | What differs between the application in the database and a directory (or JSON file, or zip) |
pgkiln mcp | An MCP server on standard input/output for AI coding agents, see chapter 20 |
pgkiln plugin build <dir> [-o <file>] | Builds a plug-in file (pgkiln-plugin/2) from a source directory, see plug-ins |
pgkiln plugin install <file|dir> --app <alias> [--replace] | Adds a plug-in (file or source directory) to an application; its install SQL is not run |
pgkiln users list [--developers] | Accounts with their applications and roles, or builder developers |
pgkiln users add <username> [--developer] [--app <alias> --roles a,b] [--name …] [--email …] | Adds an account (optionally with access to an application) or a builder developer |
pgkiln users password <username> [--developer] | Sets a password and ends that user's sessions |
users reads the password from standard input (the first line, e.g. from a secret store: printf '%s\n' "$PW" | pgkiln users add ops --developer) or asks for it twice on a terminal. It is never an argument, so it doesn't end up in the shell history, and it must meet the password policy.
The directory format
pgkiln export hr --format dir writes:
hr/
pgkiln.json {"format": "pgkiln/2", "layout": 1}
app.json the application's settings (app.pwa_icon.png next to it)
navigation.json the navigation menu as a tree
shared/
authorizations/manager.json
app-items/ai_ename.json
app-processes/0010-link-user-to-employee.json
app-processes/0010-link-user-to-employee.code.sql
lovs/jobs.json
lovs/jobs.query.sql
report-layouts/hr_directory.json (a logo as hr_directory.logo.png)
template-components/status_badge.json (named by static id; the template in status_badge.template.html)
automations/ document-templates/ task-definitions/ workflow-definitions/ rest-modules/
web-credentials/ rest-sources/ (web credentials never contain their secret)
lists/hr_shortcuts.json (a query list's query in hr_departments.query.sql)
list-entries.json the entries of every list, per list as a tree
automation-actions/remind-managers/0010-remind-the-manager.json (per automation; code in .code.sql, condition in .condition.sql)
supporting-objects/check-the-sample-data.json (the script in .script.sql; never run on import)
plugins/log_event.json (install SQL in log_event.install_sql.sql; never run on import)
group-roles.json
globalization/
text-messages.json
translations/nl.json
static/
files.json the static application files and their types
hr.js hr.css each file as itself
pages/
0003-employees-form/
page.json
regions/0010-employees.json
items/0020-p3_ename.json
buttons/0030-save.json
dynamic-actions/0020-suggest-a-salary-for-the-job.json
dynamic-actions/0020-suggest-a-salary-for-the-job.code.sql
validations/0010-commission-only-for-sales.json
validations/0010-commission-only-for-sales.expression.sql
processes/0010-process-form-employees.json
extra/ sections of newer pgkiln versions this one does not know- One file per component, named
<sequence>-<static id>.json(pages:<page number>-<name>; shared components: their name). The JSON holds the component's row as in themetatable, with sorted keys, two-space indentation, LF line ends and a final newline, so diffs show only real changes and a re-export of an unchanged application rewrites nothing. - Code in its own file: SQL, PL/pgSQL and templates that are longer than 60 characters or span lines move to
<base>.<column>.sql(.htmlfor static content and document templates) next to the JSON; the column is then left out of the JSON. Short values stay inline. Either way imports. - No database ids. Components refer to each other by static id: an item, button or process says
"region": "employees", a dynamic action"affected_region": "…", a facet or map region"report": "employees"in its settings. Navigation entries are nested instead of pointing at parent ids. A directory exported from two installations of the same application is identical. - Binary values (a report layout's logo, the PWA icon) are written as image files, and static application files as themselves under
static/(listed instatic/files.json).
Edit the files with any editor and import them again; the directory is the source of truth for the application, the database objects (tables, views, functions) stay in your own migration scripts.
Text files (APEXlang)
APEX 26.1 writes applications in APEXlang, a human-readable text format. pgkiln export hr --format text writes the same directory with every component as YAML instead of JSON, and the SQL, PL/pgSQL and templates inline as literal blocks, so a region with its query, or a process with its code, is one file you read top to bottom:
config:
link:
column: empno
items:
P3_EMPNO: "#empno#"
page: 3
seq: 10
source: |2-
select e.empno, e.ename, e.job, d.dname as department
from hr.emp e
left join hr.dept d on d.deptno = e.deptno
static_id: null
title: Employees
type: reportThe files are a strict subset of YAML 1.2 that any YAML tool reads: block mappings and lists, plain or double-quoted strings (JSON escapes), numbers, true/false/null, and literal blocks (|2-) for text over several lines; keys are sorted. pgkiln reads exactly that subset back, so anchors, tags, flow collections ([a, b], {a: 1}) and folded blocks are refused with the file and line; # comment lines are allowed (they are not kept by the next export). Text that could be read as something else (yes, 10, 2026-01-01, a: b) is written in double quotes. pgkiln.json stays JSON and says "style": "text".
import, diff and the builder's zip read JSON and YAML files alike, file by file, so a directory may mix them (the same component as both .json and .yaml is refused). diff exports the application in the directory's style, and compares YAML and JSON files by content. Switching an existing directory from dir to text (or back) rewrites every file once.
Static ids
APEX 26.1 gives components a static id so that application files diff cleanly and can be applied to another installation. pgkiln derives them without a schema change, from what already identifies a component:
| Component | Static id |
|---|---|
| page | its page number |
| region | its Static ID when it has one (0.31: an optional region attribute, unique on the page), else its title as a key (Who's out this week → who-s-out-this-week), its type when it has no title; -2, -3 for duplicates on a page |
| item, button, dynamic action, validation, process | its name as a key (P3_ENAME → p3_ename), else its label, event and action, item or type |
| shared components | their name |
A key is lower case a-z, 0-9, _ and -. The static id is the part of the file name after the sequence number; renaming a region without a stored Static ID renames its file in the next export (give regions that others refer to a Static ID to keep their files, references and, with import --replace, the users' saved reports stable). When you edit the files by hand, keep a reference and the file name of the region it points at in step: import and diff report a reference to a region that doesn't exist on the page.
Updating an application in place
pgkiln import hr/ --replace makes the application with alias hr look like the directory: its settings, pages and shared components are those of the files, and components that are not in the files any more are removed. What belongs to this installation stays:
- the application's id and alias (links, API URLs and bookmarks keep working),
- who may sign in and with which roles (accounts are never exported; identity-provider group mappings do come from the file), OAuth clients, sessions and "keep me signed in" tokens,
- saved reports of users (and their grid column layouts), which move to the new version of their region (same page number and static id),
- running tasks and workflows, which keep their definition (by name),
- for automations: whether each one is switched on, its next and last run, and its log. New automations arrive switched off, as with every import.
- the secrets of web credentials with the same name (exports never contain them; see chapter 19).
- the style variant each user chose (chapter 14); a style that the file no longer has falls back to the default.
It all happens in one transaction; any error leaves the application unchanged. Without an application with that alias, --replace simply imports, so the same command deploys the first time and every time after.
A typical flow with git:
pgkiln export hr --format dir --out apps/hr # in development, after changes in the builder
git diff apps/hr # review, commit, open a pull request
pgkiln diff hr apps/hr # on the target: what would change (exit code 1)
pgkiln import apps/hr --replace # deployexport --format dir into an existing directory removes the files of deleted components and leaves dot files (.git, .gitattributes) alone; it refuses a non-empty directory without a pgkiln.json, so a typo can't empty the wrong folder.
diff
pgkiln diff hr apps/hr exports the application in memory and compares file by file:
M pages/0002-employees/regions/0010-employees.source.sql
--- database/pages/0002-employees/regions/0010-employees.source.sql
+++ directory/pages/0002-employees/regions/0010-employees.source.sql
@@ -1,3 +1,4 @@
select empno, ename, job
from hr.emp
+ where active
A shared/lovs/locations.json
D pages/0009-audit-trail/page.jsonA: only in the directory (an import adds it), D: only in the database (an import with --replace removes it), M: different. JSON files compare by content, so key order and spacing don't count. --name-only lists the files, --quiet only sets the exit code (for CI). An imported copy differs from its source in app.json (the alias) and in automations' enabled.
In the builder
Export in the builder downloads the JSON file. /builder/apps/<id>/export?format=dir (or ?format=text) downloads the directory format as a .zip (one folder named after the alias, fixed timestamps, so the same application gives the same zip). pgkiln import hr.pgkiln.zip and pgkiln diff read the zip directly.
For pgkiln developers
src/appfiles.tsturns apgkiln/2document into files and back (pure functions, used by the CLI and the builder);meta.export_app()andmeta.import_app()stay the only exporter and importer. A new section or column needs nothing here: unknown sections go toextra/, unknown columns stay in the component's JSON, unknown arrays of a page inpage.json.src/cli/replace.ts(--replace) lists which tables of an application are its definition (replaced) and which belong to the installation (kept). A new table that referencesmeta.appor a component must be added there;test/cli.test.tsfails until it is, like the export test.test/cli.test.tscovers help and exit codes, the round trip directory → import → export, diff and replace.