@datarails-ai/dr-appspace-cli
v0.12.3
Published
Datarails App Space CLI — build, try, push and publish App Space apps
Readme
dr-appspace — the Datarails App Space CLI
One binary, one job: App Space — build, try, push and publish App Space
apps. Nothing else of the Datarails system is exposed here; for everything
else, use dr (dr-cli). (The read-only dr-cli command groups this package
used to carry were removed — combining CLI capabilities is handled
elsewhere now.)
npm install -g @datarails-ai/dr-appspace-cli --registry https://datarails.jfrog.io/artifactory/api/npm/dr-cli-virtual
dr-appspace login -s DEV
dr-appspace app-space whoami -s DEVWhat's inside
- Auth — identical to dr-cli (the code is a port of dr-cli's auth stack):
OAuth device-flow
login, sessions in the OS keychain (servicedr-appspace-cli, keyed by(server, email)), automatic JWT refresh with the full 401 recovery ladder, and the credential-file store for keychain-less hosts (CI, containers, managed agents): when~/.dr/session.json(or$DR_CREDENTIALS_FILE) exists it wins over the keychain. The file contract is byte-compatible with dr-cli's ({access, refresh, server, email}), so one provisioning step signs in both CLIs — the samedr_cli_auth.jsonmount ai-service already provides to managed agents works here unchanged. app-spacecommands (ported fromdr-dev-cli app-space, re-based onto this auth):whoami,list,get,logs,try,push,publish,unpublish— plusscaffold,preview,test,contract,compileandeject.
A handler that takes a file
try and test can put a file in front of a handler, the way the page does:
upload it through the app's upload route, then send the signed reference as
{"<field>": {"upload": "…"}}.
dr-appspace app-space try upload_invoice --file pdf # the built-in sample invoice PDF
dr-appspace app-space try upload_invoice --file pdf=./acme-0417.pdf # a file of yours
dr-appspace app-space try upload_invoice --file pdf --payload @./payload.json--file <field>[=<path>] is repeatable; a field that is not a file column
of any table in data_model.json gets a warning, not a refusal. --payload
@<path> reads the whole payload from a JSON file. Paths are relative to the
working directory. The sample invoice (INV-0417, Acme Office Supplies Ltd,
subtotal 165.00, tax 33.00, total 198.00) is a one-page ASCII PDF generated in
src/commands/appspace/_samplePdf.ts.
test hands the same sample PDF to every form's file field, and a
tests.json case may upload files before its dry-run:
{"handlers": {"upload_invoice": [
{"name": "reads the sample invoice", "payload": {}, "files": {"pdf": "sample"},
"shape": {"row": {"id": "string", "status": "string"}}}
]}}A files value is "sample" (reserved: the built-in sample invoice) or a
path inside the app folder; a path whose real location is outside the
folder, through a symlink too, is refused. Every file is read before any is
uploaded. Each upload stores a real file, as a browser upload does, so a
handler that hands it to an agent runs that agent on every test.
compile knows a chart component: a root element that draws the rows one
list, inbox or custom handler answers (source), as a line, bar or
area (kind), with x on the category axis, the one numeric column y,
an optional series column (one line per distinct value) and an optional
format (currency, number, percent; absent, a decimal y reads as
currency). Over a list or an inbox its columns are checked against the
table; over a custom handler --check prints Not checked: chart columns
of …, and app-space test checks what the handler answers: a chart-empty
finding when the source answered no rows (ungated for a custom handler; over
a list or an inbox, only when seed.json says it should have rows), no
rows array, or rows that carry no x/series key or no number under y —
naming the keys it did return (names only; no value leaves the run). Like
every finding it fails the run only under --strict. A page holding a
chart loads [email protected] and dr-fmt (the compiler's FMT, 2.0.0
since P1b) from the shelf before the runtime. An older CLI's get drops the element.
A dashboard can be assembled rather than typed. compile --draft
<spec-draft.json> builds contract.json from the dashboard's skeleton
(--skeleton <file>, or the draft's own skeleton key) and one SPEC draft
per page — each card's props and SPEC row, plus its page, reads,
datasets and capabilities — and then compiles it like any other. The
app's name and description (the line under the name on its card, which
kit rule R1.5 requires) are the skeleton's app, as written. The rest of the
frame is the CLI's: the first page is ui/index.html at /, every other
ui/<name>.html at /<name>.html, the skeleton's sections and card order,
a page not built yet carries one card saying it comes next, and — only
when the draft states it as page.window — the window every page opens on
(mount.defaults.filters.period), which preview and the post-push load
apply too. A draft or contract where every card opts out of the period bar
is refused (assemble-period-override / period-override). With a contract.json already in the folder a draft replaces only its
own page — except that a draft carrying page.window also moves every
page's opening window, because one widget.js serves them all. --check assembles in memory and writes nothing; a draft it cannot
place is assemble-*, exit 1. --app-id <id> names the app a first pass is
for, and --expect <card>=<figure> holds a card to the figure the user
confirmed once the package runs (exit 6 when it does not).
A dataset series row may be drawn through its own transform — "transform":
"transforms/<name>.py", the App Builder's transform(rows, ctx, params),
handed the dataset's whole monthly history. A draft carries the file as
"transforms": {"transforms/<name>.py": "<text>"}; it is written beside
contract.json (not under --check) and compiled into the handler, and a
later draft's text replaces it and says so — unless a card that is not the
draft's is drawn through that file, which is refused. A skeleton page may
carry controls ({id, label, param, kind: "slider"|"number", min, max, step,
default}) and a card params: the control is written once a row on the page
declares its param, the card redraws with it, and a row whose own rule is not
the control's is refused. preview draws such a card in two round trips: the
server hands over the rows the transform would get, the transform runs here
with python3 -I — 20 s at most, what it prints shown under the card rather
than read as its answer — and the server draws what it returned, so the
figure printed is the card's. Only a transform the previewed file's drafts
carry runs here, never a file in the folder: get restores there what the
live app carries, whoever pushed it. compile's lint and the server's own
ast check run first, so preview refuses what the page would; neither is a
sandbox, and the run starts with no environment beyond PATH, HOME, the locale
and a Python version manager's own variables.
app-space ship [dir] --draft spec-draft-cfo.json --app-id app-7k2m3f --expect revenue=870,000ship is one pass of a dashboard build as one command — compile --push
over the assembled contract, R5 over every card, the --expect check — and
after a run that passed it prints the appspace_app_update block a build
agent's reply ends with, from the revision the push printed (compile
--update-block prints it too). After any failure it prints none.
compile also knows the two ways a file becomes the author's rather than
the compiler's, besides a custom handler:
- a
customelement —{"type": "custom", "props": {"module": "components/<name>.js", "source": "<handler>", "args": {…}}}— drawn byui/components/<name>.js, which callsdrApp.define("components/<name>.js", {render(el, data, ui) {…}})([email protected];uicarriesargs,fmt,call,refresh,chartandtheme).argsmust be an object;moduleis required. - an owned page —
pages[i].owned: true— whose markup is the file at its path, carrying<meta name="dr-owned" content="true">. An optionalpages[i].rolegates its nav entry, as a compiled page's screens do.
Both are compiler INPUTS, like a custom handler: they must be in the folder,
compile never writes them (not even under --force), records them under
inputs in .appspace/compile.json, never removes one as orphaned, and
--check carries them into its scratch package. A ui/components/*.js no
custom element names is refused like a stray handler — a push would ship it.
app-space test reports custom-undefined when a module never called
drApp.define for itself, custom-failed (with the module and the error)
when its component threw while drawing and the runtime drew its
<module> failed: <message> panel, and reads no layout rule into an owned
page. A custom element whose declares.shows names a number nothing inside
it projects once it drew (no [data-dr-field] for it) is
custom-shows-unprojected — a counted finding, so --strict fails on it: a
number no grader can read is not a measured one.
A card the contract gives no props.label is card-untitled, one per
element: the runtime titles it anyway — after its field, its id, or its
module, y column, source, entity (a form reads "New") — so a builder who
never named it ships a card called "Whatif":
[card-untitled] ui/cockpit.html → ui/cockpit.html#10: custom "whatif" has no label, so its card is titled "Whatif" after its id. Fix: give it props.label (what a reader should call this card). (contract.md §3.4 Pages)It reads the contract alone, for what a page draws as its own — its roots
and what sits in a region or a tab panel: custom, chart, widget,
stat, list, queue, form, and the parts that draw their label
(dialog, number_input, select, date_picker; a typed
props.props.label, or a dialog's props.props.title, names them). An
element's card.title names any of them (runtime 1.4.0's CardFrame draws
it); a widget's props.title does not — the runtime never reads it. A
field, a nav, a region, a tab panel, tabs, data_grid and
variance_table draw no title of their own and are never reported. Apps
only; counted, so --strict fails on it.
A page may declare its own params (pages[i].params, the seven kinds
[email protected]'s param store reads) and a source take them by name
in honors. Every source handler of such an app carries PARAMS — every
page's declarations — into sources.serve(…, TRANSFORMS, PARAMS), so a
transform's params holds only values the page declared; an app no page of
which declares one compiles exactly as before. A dimensions:<Name> honour
fills the source's filterable. compile refuses, with a pointer:
param_custom_multi / param_custom_period (a custom name of kind multi
or period — only scenario, dimensions:<Name> and period take those),
param_kind_value (a default, min/max/step, values or
max_selected its kind cannot hold), param_conflict (one name, two rules)
and param_unknown_honor (a source honours a name no page declares).
The look layer (runtime 1.4.0) is optional and per key: a band may state a
subtitle, a layout (stack | grid | kpi_strip | split | sidebar |
hero), a surface (none | tinted | inverse) and a sidebar's aside
(start | end); an element a card ({variant: plain | outlined | elevated
| accent, title, subtitle}) and an emphasis (low | normal | high).
app.theme ({file: "ui/theme.css", tokens, preset?, density?}) compiles
to ui/theme.css on body.dr-app-page in the --dr-app-theme: full form —
the --dr-* tokens and the shadcn triples computed from them by the
runtime's own map (contract/themeMap.gen.mjs, generated from
tokens.mjs) — linked after every shelf stylesheet; preset and density
become each page's data-dr-theme / data-dr-density where the page states
none. A theme compile cannot write is theme_shape.
app-space test holds every declared param to two counted findings, read
statically from the embedded contract and the pushed modules
(contract/levers.mjs, the App kit's A3.17/A3.18): control-unbound — no
control on the page writes it (no part in param mode, no custom whose module
calls ui.setParam("<name>", …) or whose declares.produces names it), so the
page only ever shows its default — and param-unread — no source a component
on the page reads honours it, directly or through what it is composed of, and
no custom module reads it off ui.params: moving it changes nothing. A page
with a module the run could not read, or a ui.setParam whose name is not a
literal, is not reported on. The App kit reports A3.18 as a violation and
A3.17 as a warning until dr-components ships param-mode controls.
It then RUNS each lever. On every page that declares params, once the page
has loaded (and before any form is submitted), app-space test moves one
param at a time through the page's own URL — #p= +
encodeURIComponent(JSON) of that one param, then hashchange, which
runtime 1.4.0's param store reads — and compares what the page draws with
what it drew at the defaults, NUMBERS only: every data-dr-value a part or
custom projects that reads as a number (never a control's own value, nor a
value whose field is the moved param), the figures in a stat's text (only
when the stat projects no data-dr-value; its dates and periods — 2026-01,
01/2026, Aug 2026, Q1 2026 — dropped, and any figure equal to the value
the param was at or moved to dropped as an echo), and the numbers in each
chart's series data as handed to echarts, gaps dropped. Labels never count
— a chart's categories, its series names, a month or a name in a cell or a
stat — so a start month that only relabels the months, every value the same,
is static. A number moves to its max (else double its default,
else default + step), a month one month on, a toggle flips, a select takes
another value, a multi another subset. A param whose first value moved no
number is moved once more — the other end of its range (else default +
step), a month one month back, another select value or subset — since a
lever can be inert at one end. Only a param neither value moved is
lever-static, counted: ui/index.html: moving param "hires" (5 → 20, and
5 → 0) changed no number on the page — its sources honour it, but nothing
drawn depends on it. Fix: Check the transform reads params.get("hires")
(transforms/plan.py) …. --json carries levers: [{page, param, from, to,
second?, moved: [uid…]}] (plus inconclusive for a lever it could not
judge). The moved reads go to the pushed app's real handlers, so the
transforms run with the moved params; a lever this run could not judge —
--skip-ui, a page that did not load, a move no source that honours the
param re-read after (a missed hashchange, a debounce past the grace — a
refetch of some other source does not count), a read that failed or was
still running with the param moved, a page that drew no number at its
defaults, a budget that ran out before the second value — is a Not checked line naming the param, never a pass and never
lever-static. Cost: one settle per declared param,
a second for each that moved nothing at its first value, plus one to move
back, on the page already mounted (the first render of each page only).
Roles are declared least senior first and the app's creator holds the last
one. compile refuses a list that reads the other way (role-order: the
default not first, or a creator's role that cannot call a handler the default
can), and app-space test reports role-sees-nothing when the app gates
components to roles and none of them to the role you hold — the page you open
is then only the ungated rest, although every render as the other roles
passes.
Before its findings, app-space test says how much of the pushed app is the
registry's and how much the builder's own code:
parts: 5 registry, 0 custom UI (0 lines), 0 custom handlers (0 lines), 2 transforms (30 lines) — 0% customCustom UI is each custom element (with its modules' lines), a custom handler
each custom-role handler (with its lines), and a transform each file under
transforms/ that data.sources names (once, however many sources share it,
by its non-blank lines — read out of the generated source handlers, which carry
it). The percent is custom UI's share of the components and nothing else.
--json carries the same counts as parts, the transforms as
parts.transforms: {count, lines}. A dashboard's line names no transforms.
app-space eject <target> [--dir <dir>] writes the starting file:
dr-appspace app-space eject handler:list_my_invoices # handlers/<name>.py as compiled, role "custom"
dr-appspace app-space eject page:ui/queue.html # the compiled page, marked owned; its elements leave the contract
dr-appspace app-space eject element:ui/overview.html#13 # a chart or a stat becomes a custom element + its moduleIt compiles in memory, writes only the ejected file and contract.json,
refuses a file already at that path unless it holds exactly what the
compiler would write, and refuses an eject after which the folder would not
compile (a page holding the only form a by handler is called from, a
handler a picker needs as a list). page: keeps the page's nav gate as
pages[i].role; an ejected handler's docstring says the file is yours.
The next compile carries the file as yours, and the one after writes
nothing.
Which kit made the app: push --kit
push can name the builder kit that made the package, so the platform's
contract record (app_contract.kit_version) says which kit built each app
rather than the extractor's stamp, dashboard-kit/contract@1, which every
kit shares:
dr-appspace app-space push --kit appspace-app-kit@12
DR_APPSPACE_KIT=appspace-app-kit@12 dr-appspace app-space pushThe value travels as the push body's optional kit field. --kit wins over
DR_APPSPACE_KIT; with neither set (or either left blank) the field is left
out, and the platform records the contract's generated_by as before.
compile --push, ship and compile --check build the same body, so they
honour the variable too; they have no --kit flag of their own. The platform
checks the shape: <name>@<number>, the name in a-z, 0-9 and -, 64
characters at most. Anything else is a 422 naming that shape. The field is a
column on the record and nothing else: the contract document, the compiled
pages and the dashboard are the same with or without it. The builder flows
set DR_APPSPACE_KIT in their sandboxes.
Push before get: get replaces handlers/ and ui/ with what the
platform holds, and refuses (unless --force) while the compile record lists
files as yours that the pushed package does not hold as written.
This package ships no skill and no generator. The guidance an agent reads
lives in dr-assets-service, attached to the agent by id — see the repo's
CLAUDE.md. Nothing here installs, prints or bundles a SKILL.md, and no
command calls a model: the builders write contract.json themselves and run
app-space compile.
Development
This package lives inside the dr-app-space repo but is independent of the
Python service: its own package.json, pnpm lockfile, and CI
(.github/workflows/cli-checks.yml).
cd cli
pnpm install
pnpm dev -- --help # run from source
pnpm typecheck && pnpm test
pnpm build # dist/cli.jsReleasing
.github/workflows/cli-release.yml releases automatically on every push to
main that touches cli/ (patch bump); manual dispatch remains for
minor/major or an explicit version. Flow: tests → resolve version → build →
publish to the dr-cli-public JFrog repo with --ignore-scripts → push a
cli-v<semver> tag (best-effort, traceability only). Versioning is
stateless because main's ruleset forbids direct pushes: no bump commit is
ever made — the next version is derived from the latest one already on the
registry, and package.json's version only seeds the very first publish.
Consumers
install through dr-cli-virtual, which fronts dr-cli-public + public npm
with anonymous read — the same registry managed agents already use for
dr-cli, so no new JFrog infrastructure is needed.
Squat protection: like dr-cli, the scoped name means federation with public
npm is safe; if an unscoped placeholder is ever wanted on npmjs, publish it
manually (0.0.1-locked), as was done for @datarails-ai/dr-cli.
Managed agents (later)
To expose this CLI on a managed agent the ai-service side needs the same
wiring dr-cli got under DR-50929 (cliAgentDefinition.ts and friends):
an install preamble (npm install -g @datarails-ai/dr-appspace-cli --registry
.../dr-cli-virtual), the mounted credential file (this CLI reads the exact
same ~/.dr/session.json contract), and a token-refresh custom tool. Nothing
in this package blocks that — the credential-file store is the piece that
makes it possible, and it ships here from day one.
